feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""Authenticated, certificate-verified STARTTLS email notification transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Protocol, Self, cast
|
||||
|
||||
from backup_tool.db.models import NotificationEmailSettings, NotificationEvent
|
||||
|
||||
|
||||
class SMTPClient(Protocol):
|
||||
def __enter__(self) -> Self: ...
|
||||
|
||||
def __exit__(self, *args: object) -> None: ...
|
||||
|
||||
def ehlo(self) -> object: ...
|
||||
|
||||
def starttls(self, *, context: ssl.SSLContext) -> object: ...
|
||||
|
||||
def login(self, user: str, password: str) -> object: ...
|
||||
|
||||
def send_message(self, msg: EmailMessage) -> object: ...
|
||||
|
||||
|
||||
class EmailTransportError(RuntimeError):
|
||||
def __init__(self, message: str, *, transient: bool = False) -> None:
|
||||
super().__init__(message)
|
||||
self.transient = transient
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailResult:
|
||||
response_class: str
|
||||
|
||||
|
||||
def validate_address(value: str) -> str:
|
||||
if not value or len(value) > 320 or any(character in value for character in "\r\n"):
|
||||
raise EmailTransportError("email address is invalid")
|
||||
local, separator, domain = value.rpartition("@")
|
||||
if not separator or not local or not domain or any(character.isspace() for character in value):
|
||||
raise EmailTransportError("email address is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def validate_recipients(values: Sequence[str]) -> list[str]:
|
||||
if not values or len(values) > 20:
|
||||
raise EmailTransportError("one to 20 email recipients are required")
|
||||
recipients: list[str] = []
|
||||
for value in values:
|
||||
address = validate_address(value)
|
||||
if address not in recipients:
|
||||
recipients.append(address)
|
||||
return recipients
|
||||
|
||||
|
||||
def _message(
|
||||
settings: NotificationEmailSettings,
|
||||
event: NotificationEvent,
|
||||
recipients: Sequence[str],
|
||||
) -> EmailMessage:
|
||||
sender = validate_address(settings.sender)
|
||||
safe_recipients = validate_recipients(recipients)
|
||||
message = EmailMessage()
|
||||
message["From"] = formataddr(("Backup Tool", sender))
|
||||
message["To"] = ", ".join(safe_recipients)
|
||||
message["Subject"] = f"Backup Tool: {event.type} ({event.severity})"
|
||||
message["X-Backup-Event-ID"] = event.id
|
||||
# Do not put the full envelope, paths, raw errors, or credentials into mail.
|
||||
message.set_content(
|
||||
"Backup Tool operational event\n"
|
||||
f"Event ID: {event.id}\n"
|
||||
f"Type: {event.type}\n"
|
||||
f"Severity: {event.severity}\n"
|
||||
f"Occurred: {event.occurred_at.isoformat()}\n"
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def _deliver_sync(
|
||||
settings: NotificationEmailSettings,
|
||||
password: str,
|
||||
event: NotificationEvent,
|
||||
recipients: Sequence[str],
|
||||
smtp_factory: Callable[..., SMTPClient],
|
||||
) -> EmailResult:
|
||||
message = _message(settings, event, recipients)
|
||||
try:
|
||||
with smtp_factory(settings.host, settings.port, timeout=10) as client:
|
||||
client.ehlo()
|
||||
context = ssl.create_default_context()
|
||||
client.starttls(context=context)
|
||||
client.ehlo()
|
||||
client.login(settings.username, password)
|
||||
client.send_message(message)
|
||||
except smtplib.SMTPResponseException as error:
|
||||
raise EmailTransportError(
|
||||
f"smtp_{error.smtp_code}", transient=400 <= error.smtp_code < 500
|
||||
) from error
|
||||
except (smtplib.SMTPException, OSError) as error:
|
||||
raise EmailTransportError("smtp_transport_failed", transient=True) from error
|
||||
return EmailResult(response_class="smtp_2xx")
|
||||
|
||||
|
||||
async def deliver_email(
|
||||
settings: NotificationEmailSettings,
|
||||
password: str,
|
||||
event: NotificationEvent,
|
||||
recipients: Sequence[str],
|
||||
*,
|
||||
smtp_factory: Callable[..., SMTPClient] | None = None,
|
||||
) -> EmailResult:
|
||||
"""Run blocking SMTP only in the worker thread, never in the web process."""
|
||||
factory = smtp_factory or cast(Callable[..., SMTPClient], smtplib.SMTP)
|
||||
return await asyncio.to_thread(_deliver_sync, settings, password, event, recipients, factory)
|
||||
Reference in New Issue
Block a user