"""v0.18.0 / roadmap items #18 + #20: a shared envelope builder. Every outbound mail in rfc-app today (OTC, admin-invite, watcher notification, "while you were away" bundle, per-RFC invite) constructs its own `email.message.EmailMessage` ad-hoc. The four sites diverged just enough to be a deliverability hazard: missing `Date`, missing `Message-ID`, no `Auto-Submitted`, no `List-Unsubscribe` on the bulk-adjacent paths, no `multipart/alternative` body. This module is the one place an `EmailMessage` is constructed. Every send path imports `build_envelope` and calls it; the headers that matter for inbox placement (Date, Message-ID, Auto-Submitted) land uniformly, and the per-kind variations (unsubscribe semantics, HTML alternative) are explicit arguments rather than buried in each call site. Per the v0.18.0 proposal at `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`, the per-kind unsubscribe matrix is: * OTC: 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). * Admin invite / per-RFC invite: `mailto:` form only (the recipient isn't a user yet, so there's no per-user opt-out row to flip; the operator handles ad-hoc opt-outs manually). * Watcher notification / bundle: full `mailto:` + signed-URL `List-Unsubscribe` plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click` per RFC 8058 (Gmail and Yahoo enforce this for bulk-adjacent senders). The `is_transactional` flag governs `Auto-Submitted: auto-generated`, which prevents auto-responder loops on every kind of mail we send. All five mail kinds today are transactional in the SMTP sense (no human is at the From mailbox watching for replies), so the default is True; the argument is exposed for future symmetry. """ from __future__ import annotations from email.message import EmailMessage from email.utils import formataddr, formatdate, make_msgid def build_envelope( *, to_address: str, from_address: str, from_name: str, subject: str, body_plain: str, body_html: str | None = None, reply_to: str | None = None, unsubscribe_mailto: str | None = None, unsubscribe_url: str | None = None, is_transactional: bool = True, msgid_domain: str | None = None, ) -> EmailMessage: """Compose an `EmailMessage` with hardened headers. `to_address` / `from_address` are bare RFC 5322 addresses; `from_name` is the display label that goes through `formataddr` so spaces / commas in the display string are encoded correctly. `body_plain` is mandatory. `body_html` is **reserved and not yet enabled** (security-audit-0026 I3): no send path supplies it today — every rfc-app mail is plain text — and passing it raises `NotImplementedError`. The parameter is kept in the signature for documented future symmetry: when HTML mail is enabled it will land as the second part of a `multipart/alternative` body (text/plain first per RFC 2046, so a plain-text client picking the first part still gets the readable text). Enabling it is a deliberate act — the caller MUST HTML-escape any user content into `body_html` first (cf. the C1 stored-XSS class: a mail client renders the HTML) and remove the guard below in the same change. `reply_to`, when set, lets a send path point replies at a different mailbox than the From line (e.g., a watcher notification with From=notifications@... but Reply-To= ohm@... so a confused recipient who hits Reply lands at a monitored mailbox). `unsubscribe_mailto` / `unsubscribe_url` populate `List-Unsubscribe`. If `unsubscribe_url` is set, the helper also emits `List-Unsubscribe-Post: List-Unsubscribe=One-Click` per RFC 8058 — Gmail and Yahoo POST that payload on the user's one-click action. (Send paths that wire `unsubscribe_url` therefore MUST also expose a matching POST endpoint that accepts the same token; see `api_notifications.py:email_unsubscribe`.) `msgid_domain` defaults to the @-domain of `from_address` so Message-IDs are aligned with the sending domain by default. A deployment that wants the Message-ID domain to track a different surface (e.g., a tracking-domain that's separate from the From domain) can override. `Date` is RFC 5322 formatted via `email.utils.formatdate`; the `localtime=True` setting picks the running process's local timezone, which is what every popular MUA does too. (A deployment running in UTC stamps UTC; that's correct, not a bug.) """ msg = EmailMessage() msg["From"] = formataddr((from_name, from_address)) msg["To"] = to_address msg["Subject"] = subject msg["Date"] = formatdate(localtime=True) # If the caller didn't pin a Message-ID domain, derive it from the # From address. `make_msgid` accepts None and falls back to the # local hostname, which is the wrong shape for a deliverable # message (the hostname might be `gke-pool-xxx`); a deployment # without a configured From would surface that as a build-time # config error elsewhere, so the fallback here is just defensive. if msgid_domain is None: if "@" in from_address: msgid_domain = from_address.split("@", 1)[1] else: msgid_domain = "localhost" msg["Message-ID"] = make_msgid(domain=msgid_domain) if reply_to: msg["Reply-To"] = reply_to if is_transactional: # RFC 3834: prevents auto-responders (vacation replies, etc.) # from triggering on this message. Every kind of mail rfc-app # sends today is transactional in this sense. msg["Auto-Submitted"] = "auto-generated" if unsubscribe_mailto or unsubscribe_url: parts: list[str] = [] if unsubscribe_mailto: parts.append(f"") if unsubscribe_url: parts.append(f"<{unsubscribe_url}>") msg["List-Unsubscribe"] = ", ".join(parts) if unsubscribe_url: # RFC 8058 one-click. Gmail and Yahoo POST the payload # `List-Unsubscribe=One-Click` to the URL on the user's # one-click action; the matching POST endpoint must be # idempotent and not require auth. See # `api_notifications.py` for the receiver. msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" if body_html is not None: # I3 (security-audit-0026): the multipart/alternative HTML path # is intentionally NOT enabled. No send path passes `body_html` # today, and emitting an HTML body built from user-supplied # content without escaping it first would reintroduce the C1 # stored-XSS class in the mail channel (the recipient's client # renders the HTML). Fail loudly here rather than silently # shipping HTML: enabling HTML mail is a deliberate change that # MUST HTML-escape user content at the call site and remove this # guard together. The text/plain path below is the only live one. raise NotImplementedError( "HTML email is not enabled (security-audit-0026 I3): do not " "pass body_html until user content is HTML-escaped at the " "call site and this guard is intentionally removed." ) msg.set_content(body_plain) return msg