From 446c13211aa9d520e5795dfc0b0db2355d85ae74 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 10:26:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice-2):=20platform/mailer=20port=20+=20L?= =?UTF-8?q?ogMailer=20(dev=20channel,=20=C2=A76.2/PUC-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/platform/mailer.py | 50 ++++++++++++++++++++++++++++++++++ backend/tests/test_mailer.py | 26 ++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 backend/app/platform/mailer.py create mode 100644 backend/tests/test_mailer.py diff --git a/backend/app/platform/mailer.py b/backend/app/platform/mailer.py new file mode 100644 index 0000000..f38eb66 --- /dev/null +++ b/backend/app/platform/mailer.py @@ -0,0 +1,50 @@ +"""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` (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). +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Protocol + +logger = logging.getLogger("ecomm.mailer") + + +@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) + + +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)") + raise ValueError(f"unknown mailer kind: {kind!r}") diff --git a/backend/tests/test_mailer.py b/backend/tests/test_mailer.py new file mode 100644 index 0000000..961bd4e --- /dev/null +++ b/backend/tests/test_mailer.py @@ -0,0 +1,26 @@ +from app.platform import mailer + + +def test_logmailer_captures_to_outbox(): + m = mailer.LogMailer() + assert m.outbox == [] + m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456 (valid 10 min)") + assert len(m.outbox) == 1 + msg = m.outbox[-1] + assert msg.to == "merchant@example.com" + assert msg.subject == "Your ecomm code: 123456" + assert "123456" in msg.body + + +def test_build_mailer_log_kind(): + m = mailer.build_mailer("log") + 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). + import pytest + + with pytest.raises(NotImplementedError): + mailer.build_mailer("smtp")