"""SMTP email provider. Points at Mailpit in local dev (see docker-compose.yml) and any real SMTP server in production - same code path either way. Uses stdlib `smtplib` off the event loop via `asyncio.to_thread` rather than adding an async SMTP dependency. """ from __future__ import annotations import asyncio import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from app.core.config import Settings from app.core.logging import get_logger from app.notifications.base import DeliveryResult, NotificationMessage logger = get_logger(__name__) class SmtpEmailProvider: provider_name = "smtp" def __init__(self, settings: Settings) -> None: self._settings = settings async def send(self, message: NotificationMessage) -> DeliveryResult: try: await asyncio.to_thread(self._send_sync, message) return DeliveryResult(success=True) except Exception as exc: # pragma: no cover - network/SMTP failure path logger.error("smtp_send_failed", error=str(exc)) return DeliveryResult(success=False, error=str(exc)) def _send_sync(self, message: NotificationMessage) -> None: mime_message = MIMEMultipart("alternative") mime_message["Subject"] = message.subject mime_message["From"] = self._settings.smtp_from_email mime_message["To"] = message.destination_value mime_message.attach(MIMEText(message.body_text, "plain")) if message.body_html: mime_message.attach(MIMEText(message.body_html, "html")) with smtplib.SMTP(self._settings.smtp_host, self._settings.smtp_port, timeout=10) as server: if self._settings.smtp_use_tls: server.starttls() if self._settings.smtp_username: server.login(self._settings.smtp_username, self._settings.smtp_password) server.sendmail( self._settings.smtp_from_email, [message.destination_value], mime_message.as_string(), )