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: 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" 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 — 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 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 deployed `SmtpMailer` (relay coordinates from configuration, INV-8). The adapter is chosen
(INV-8), so no deployment shape lives in the domain. LogMailer logs the full body on by configuration at startup, so no deployment shape lives in the domain. LogMailer logs the
purpose: that is PUC-10's "local dev channel" — the one-time code reaches the developer in full body on purpose: that is PUC-10's "local dev channel" — the one-time code reaches the
the terminal. The deployed mailer (SLICE-4) will NOT log the body (INV-3 / §6.6 log hygiene). 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 from __future__ import annotations
import hashlib
import logging import logging
import smtplib
from dataclasses import dataclass from dataclasses import dataclass
from email.message import EmailMessage
from typing import Protocol from typing import Protocol
from app.platform import config
logger = logging.getLogger("ecomm.mailer") logger = logging.getLogger("ecomm.mailer")
class MailerError(Exception):
"""Delivery failed — the relay refused or was unreachable (INV-9)."""
@dataclass(frozen=True) @dataclass(frozen=True)
class SentMessage: class SentMessage:
to: str to: str
@@ -41,10 +51,48 @@ class LogMailer:
logger.info("LogMailer -> %s | %s\n%s", to, subject, body) 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: def build_mailer(kind: str) -> Mailer:
"""Select the mailer adapter by configured kind (config.mailer_kind(), INV-8).""" """Select the mailer adapter by configured kind (config.mailer_kind(), INV-8)."""
if kind == "log": if kind == "log":
return LogMailer() return LogMailer()
if kind == "smtp": 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}") raise ValueError(f"unknown mailer kind: {kind!r}")
+90 -5
View File
@@ -17,10 +17,95 @@ def test_build_mailer_log_kind():
assert isinstance(m, mailer.LogMailer) assert isinstance(m, mailer.LogMailer)
def test_build_mailer_smtp_not_yet_available(): class _FakeSMTP:
# SmtpMailer lands in SLICE-4; until then asking for it is an explicit error, not a """Stand-in for smtplib.SMTP capturing the call sequence (no network)."""
# silent fallback to LogMailer (which would send no real mail in a deployed env).
instances: list["_FakeSMTP"] = []
fail_on_send = False
def __init__(self, host, port, timeout=None):
self.host, self.port, self.timeout = host, port, timeout
self.calls: list[str] = []
self.message = None
_FakeSMTP.instances.append(self)
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
self.calls.append("starttls")
def login(self, user, password):
self.calls.append(f"login:{user}")
def send_message(self, msg):
if _FakeSMTP.fail_on_send:
raise RuntimeError("relay refused")
self.calls.append("send_message")
self.message = msg
def _smtp_env(monkeypatch):
monkeypatch.setattr(mailer.smtplib, "SMTP", _FakeSMTP)
_FakeSMTP.instances.clear()
_FakeSMTP.fail_on_send = False
monkeypatch.setenv("ECOMM_SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("ECOMM_SMTP_PORT", "587")
monkeypatch.setenv("ECOMM_SMTP_USER", "sender@example.com")
monkeypatch.setenv("ECOMM_SMTP_PASSWORD", "not-a-real-password")
monkeypatch.setenv("ECOMM_SMTP_FROM", "ecomm <sender@example.com>")
def test_build_mailer_smtp_builds_from_config(monkeypatch):
_smtp_env(monkeypatch)
m = mailer.build_mailer("smtp")
assert isinstance(m, mailer.SmtpMailer)
def test_smtpmailer_sends_via_starttls_login(monkeypatch):
_smtp_env(monkeypatch)
m = mailer.build_mailer("smtp")
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456")
smtp = _FakeSMTP.instances[-1]
assert (smtp.host, smtp.port) == ("smtp.example.com", 587)
assert smtp.calls == ["starttls", "login:sender@example.com", "send_message"]
assert smtp.message["To"] == "merchant@example.com"
assert smtp.message["Subject"] == "Your ecomm code: 123456"
assert smtp.message["From"] == "ecomm <sender@example.com>"
assert "123456" in smtp.message.get_content()
def test_smtpmailer_failure_raises_mailer_error(monkeypatch):
_smtp_env(monkeypatch)
_FakeSMTP.fail_on_send = True
import pytest import pytest
with pytest.raises(NotImplementedError): m = mailer.build_mailer("smtp")
mailer.build_mailer("smtp") with pytest.raises(mailer.MailerError):
m.send("merchant@example.com", "subject", "body")
def test_smtpmailer_never_logs_the_body(monkeypatch):
# §6.6 log hygiene: codes never appear in deployed logs. LogMailer logging the body
# is dev-only by configuration; the deployed adapter must not. The handler attaches
# directly to the ecomm.mailer logger (it doesn't propagate to root).
import logging
_smtp_env(monkeypatch)
records: list[str] = []
handler = logging.Handler()
handler.emit = lambda r: records.append(r.getMessage()) # type: ignore[method-assign]
lg = logging.getLogger("ecomm.mailer")
lg.addHandler(handler)
lg.setLevel(logging.DEBUG)
try:
mailer.build_mailer("smtp").send(
"merchant@example.com", "Your ecomm code: 123456", "Code: 123456"
)
finally:
lg.removeHandler(handler)
assert records, "the deployed adapter should log the send event (observability §6.6)"
assert all("123456" not in r and "merchant@example.com" not in r for r in records)