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:
@@ -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"}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user