Files
wiggleverse-ecomm/backend/app/platform/mailer.py
T
2026-06-10 10:26:58 -07:00

51 lines
1.8 KiB
Python

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