121 lines
4.0 KiB
Python
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)
|