e9fdc478f6
Five send sites now construct their envelopes through
`email_envelope.build_envelope` instead of building `EmailMessage`
ad hoc:
* `email_otc.py` — OTC mail (no List-Unsubscribe per the
proposal's tradeoff: the recipient explicitly requested the
code, so a list semantic would be wrong).
* `email_invite.py` — admin/per-RFC invite mail (mailto:-only
List-Unsubscribe — the invitee isn't a user yet, so no
per-user opt-out URL exists).
* `email._deliver` — watcher notifications (full one-click
unsubscribe: mailto + signed URL + List-Unsubscribe-Post per
RFC 8058, required by Gmail/Yahoo).
* `email._send_bundle` — the "while you were away" bundle,
one-click to the new `all` synthetic category (which lands
`email_opt_out_all = 1` because the bundle spans multiple
per-category flags).
* `digest.py` — same as the bundle: bulk-adjacent, one-click to
`all`.
`api_notifications.py` gains the POST `/api/email/unsubscribe`
endpoint (the matching receiver for `List-Unsubscribe-Post:
List-Unsubscribe=One-Click`) and accepts the `all` category in
both GET and POST handlers.
`EmailConfig` adds `unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`,
default = `EMAIL_FROM`) so deployments can route unsubscribe
courtesy mail to a humans-monitored mailbox distinct from the
no-reply sender.
The `_SENT` test buffer now also carries `envelope["message"]`
(the `EmailMessage` itself) so new tests can assert on headers
directly. Legacy `to`/`from`/`subject`/`body` keys remain for
backward compatibility.
10 new integration tests across test_otc_vertical /
test_admin_create_user_invite_vertical / test_notifications_vertical
covering: OTC has no List-Unsubscribe; invite has mailto: only;
notification has full one-click; POST one-click flips the
category; `all` category sets global opt-out via both GET and
POST.
Full suite: 277 passed.
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""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
|
|
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)
|
|
|
|
if not cfg.enabled:
|
|
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
|
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)
|
|
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()
|
|
return True
|
|
except Exception:
|
|
log.exception("otc email send failed: to=%s", to_address)
|
|
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"
|
|
)
|