SLICE-2: identity — email + one-time-code sign-up, sessions, Landing/Sign-in (SD-0001 §7.2) #6
@@ -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}")
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user