feat(slice-4): SmtpMailer — real mail over a configured relay, log-hygienic (INV-8, §6.6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 23:33:40 -07:00
parent 9f4295b77e
commit 1b8c60fcb5
3 changed files with 174 additions and 11 deletions
+31 -1
View File
@@ -36,5 +36,35 @@ def cookie_secure() -> bool:
def mailer_kind() -> str:
"""Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed; SLICE-4)."""
"""Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed)."""
return os.environ.get("ECOMM_MAILER") or "log"
# SMTP relay coordinates (deployed envs only; INV-8 — host/port/user/from are non-secret
# overlay values, the password is a Secret Manager reference resolved by the deploy).
def smtp_host() -> str:
return os.environ.get("ECOMM_SMTP_HOST", "")
def smtp_port() -> int:
return int(os.environ.get("ECOMM_SMTP_PORT") or "587")
def smtp_user() -> str:
return os.environ.get("ECOMM_SMTP_USER", "")
def smtp_password() -> str:
return os.environ.get("ECOMM_SMTP_PASSWORD", "")
def smtp_from() -> str:
"""The From header; defaults to the relay user."""
return os.environ.get("ECOMM_SMTP_FROM") or smtp_user()
def smtp_starttls() -> bool:
"""STARTTLS on the relay connection (default on; disable only for odd relays)."""
return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"}
+53 -5
View File
@@ -2,20 +2,30 @@
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` (SLICE-4). The adapter is chosen by configuration at startup
(INV-8), 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. The deployed mailer (SLICE-4) will NOT log the body (INV-3 / §6.6 log hygiene).
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
@@ -41,10 +51,48 @@ class LogMailer:
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":
raise NotImplementedError("SmtpMailer arrives in SLICE-4 (deployed environments)")
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}")