"""Outbound OTC email — a thin wrapper over the existing SMTP layer. The §15.4 notification mailer in `email.py` is purpose-built for inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling). OTC mail is structurally different: it carries a credential, has no inbox row behind it, and ignores user-preferences (a contributor who's opted out of every notification still needs to receive the code they explicitly requested). So this module reuses `EmailConfig.from_env()` for the SMTP plumbing and the From identity, but writes its own envelope. In dev (no SMTP_HOST set), the envelope is logged at INFO level and pushed to the same `_SENT` buffer the notification mailer uses, so the integration tests can assert on the outbound shape without standing up an SMTP server. The send is synchronous. The `/auth/otc/request` endpoint always returns 202 regardless of send outcome — the user-facing surface doesn't know whether the SMTP relay was reachable, since revealing that would let an attacker probe for valid emails on a tight loop. """ from __future__ import annotations import logging import smtplib from email.utils import formataddr from .email import EmailConfig, _SENT, record_outbound from .email_envelope import build_envelope log = logging.getLogger(__name__) def send_otc_email(to_address: str, code: str) -> bool: """Compose and send the one-time-code email. Returns True on the happy path; False on SMTP failure. The notifier-side buffer `_SENT` is appended either way so tests can assert on content. The subject and body intentionally avoid branding strings that belong to a deployment — only `EMAIL_FROM_NAME` (operator-supplied via env) lands in the From line. The body names the code, the TTL, and a single instruction line. No tracking pixel, no deep-link query, no embedded JS — plain text only.""" cfg = EmailConfig.from_env() subject = f"Your sign-in code for {cfg.from_name}" body = _body(code, cfg) # v0.18.0: OTC mail carries NO List-Unsubscribe — the recipient # explicitly requested the code; advertising an unsubscribe # header would imply OHM has them on a list, which it doesn't. # See the proposal's "Tradeoff discussion" for the binding # rationale. msg = build_envelope( to_address=to_address, from_address=cfg.from_address, from_name=cfg.from_name, subject=subject, body_plain=body, ) envelope = { "to": to_address, "from": formataddr((cfg.from_name, cfg.from_address)), "subject": subject, "body": body, "kind": "otc", "message": msg, } _SENT.append(envelope) message_id = msg["Message-ID"] if not cfg.enabled: log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address) record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="otc", status="deferred", message_id=message_id, ) return True if not cfg.smtp_host: # Dev fallback: surface the code at INFO so the operator can # complete a sign-in flow without an SMTP relay. In production # SMTP_HOST is always set per OHM's overlay. log.info("otc email (stdout fallback): to=%s code=%s", to_address, code) record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="otc", status="deferred", message_id=message_id, ) return True try: smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) try: if cfg.smtp_starttls: smtp.starttls() if cfg.smtp_user: smtp.login(cfg.smtp_user, cfg.smtp_password) smtp.send_message(msg) finally: smtp.quit() record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="otc", status="sent", message_id=message_id, ) return True except Exception as exc: log.exception("otc email send failed: to=%s", to_address) record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="otc", status="failed", error=f"{type(exc).__name__}: {exc}", message_id=message_id, ) return False def _body(code: str, cfg: EmailConfig) -> str: return ( f"Your sign-in code is:\n\n" f" {code}\n\n" f"Enter this code in the sign-in screen to finish signing in.\n" f"The code expires in 10 minutes. If you did not request this,\n" f"you can safely ignore this email — no account was created.\n\n" f"---\n" f"{cfg.from_name} · {cfg.app_url}\n" )