"""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)