Files
backup-tool/backend/src/backup_tool/notifications/email.py
T

121 lines
4.0 KiB
Python

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