"""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}")