diff --git a/backend/app/platform/config.py b/backend/app/platform/config.py index 70eae74..70251e7 100644 --- a/backend/app/platform/config.py +++ b/backend/app/platform/config.py @@ -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"} diff --git a/backend/app/platform/mailer.py b/backend/app/platform/mailer.py index f38eb66..92dae36 100644 --- a/backend/app/platform/mailer.py +++ b/backend/app/platform/mailer.py @@ -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}") diff --git a/backend/tests/test_mailer.py b/backend/tests/test_mailer.py index 961bd4e..7eee087 100644 --- a/backend/tests/test_mailer.py +++ b/backend/tests/test_mailer.py @@ -17,10 +17,95 @@ def test_build_mailer_log_kind(): assert isinstance(m, mailer.LogMailer) -def test_build_mailer_smtp_not_yet_available(): - # SmtpMailer lands in SLICE-4; until then asking for it is an explicit error, not a - # silent fallback to LogMailer (which would send no real mail in a deployed env). +class _FakeSMTP: + """Stand-in for smtplib.SMTP capturing the call sequence (no network).""" + + 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 ") + + +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 " + 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 - with pytest.raises(NotImplementedError): - mailer.build_mailer("smtp") + m = 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)