1b8c60fcb5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99 lines
3.5 KiB
Python
99 lines
3.5 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` (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
|
|
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)
|
|
|
|
|
|
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":
|
|
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}")
|