"""platform/mailer — the outbound email port (SD-0001 §6.2). A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests — the message lands in the app log and an in-memory outbox tests read back, §6.8) and the deployed `SmtpMailer` (relay coordinates from configuration, INV-8). The adapter is chosen by configuration at startup, so no deployment shape lives in the domain. LogMailer logs the full body on purpose: that is PUC-10's "local dev channel" — the one-time code reaches the developer in the terminal. SmtpMailer never logs the body or the recipient (INV-3 / §6.6 log hygiene) and raises MailerError on delivery failure (INV-9 → the §6.4 502). """ from __future__ import annotations import hashlib import logging import smtplib from dataclasses import dataclass from email.message import EmailMessage from typing import Protocol from app.platform import config logger = logging.getLogger("ecomm.mailer") class MailerError(Exception): """Delivery failed — the relay refused or was unreachable (INV-9).""" @dataclass(frozen=True) class SentMessage: to: str subject: str body: str class Mailer(Protocol): """The outbound-message port. Raises on delivery failure (INV-9 → §6.4 502).""" def send(self, to: str, subject: str, body: str) -> None: ... class LogMailer: """Dev/test adapter: records to an in-memory outbox and logs the message (PUC-10).""" def __init__(self) -> None: self.outbox: list[SentMessage] = [] def send(self, to: str, subject: str, body: str) -> None: self.outbox.append(SentMessage(to=to, subject=subject, body=body)) # Full body incl. the code — the local dev channel. Dev/test only (never deployed). logger.info("LogMailer -> %s | %s\n%s", to, subject, body) class SmtpMailer: """Deployed adapter: real mail over an SMTP relay (STARTTLS + login by default). Logs only a hashed recipient — never the address, subject, or body (§6.6). """ def __init__(self, host: str, port: int, user: str, password: str, sender: str, starttls: bool) -> None: self._host, self._port = host, port self._user, self._password = user, password self._sender, self._starttls = sender, starttls def send(self, to: str, subject: str, body: str) -> None: msg = EmailMessage() msg["To"] = to msg["From"] = self._sender msg["Subject"] = subject msg.set_content(body) to_hash = hashlib.sha256(to.encode("utf-8")).hexdigest()[:8] try: with smtplib.SMTP(self._host, self._port, timeout=10) as smtp: if self._starttls: smtp.starttls() if self._user: smtp.login(self._user, self._password) smtp.send_message(msg) except Exception as exc: logger.warning("smtp send FAILED to=%s: %s", to_hash, type(exc).__name__) raise MailerError(str(exc)) from exc logger.info("smtp sent to=%s", to_hash) def build_mailer(kind: str) -> Mailer: """Select the mailer adapter by configured kind (config.mailer_kind(), INV-8).""" if kind == "log": return LogMailer() if kind == "smtp": return SmtpMailer( host=config.smtp_host(), port=config.smtp_port(), user=config.smtp_user(), password=config.smtp_password(), sender=config.smtp_from(), starttls=config.smtp_starttls(), ) raise ValueError(f"unknown mailer kind: {kind!r}")