The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""Outbound email for onboarding (access requests + approval notices).
|
|
|
|
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
|
|
or a send errors, we log and return False so the caller can fall back to in-app
|
|
notifications — email is never load-bearing for the app's core flow.
|
|
|
|
Fail-soft must not mean invisible, though: every drop also leaves an in-app notice in the admins'
|
|
inbox (see `_alert_admins`). An instance whose SMTP was never filled in silently swallows
|
|
verification links, password resets and access-request notices, and the operator's first clue used
|
|
to be a person telling them nothing arrived.
|
|
"""
|
|
|
|
import html
|
|
import re
|
|
import smtplib
|
|
import ssl
|
|
from email.message import EmailMessage
|
|
from email.utils import formatdate
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import logs, mailtext, sysconfig
|
|
from app.config import settings
|
|
from app.db import SessionLocal
|
|
from app.models import User
|
|
from app.notifications import create_notification
|
|
from app.ratelimit import RateLimiter
|
|
|
|
log = logs.get_logger(__name__)
|
|
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
|
|
# stay readable; the log keeps the full record either way.
|
|
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
|
|
|
|
|
|
def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
|
|
"""Record an in-app notice that a message did NOT go out. Best effort in every direction: it
|
|
opens its own session (callers are often background tasks), swallows its own errors, and never
|
|
reports failure to the caller — this is the fallback path, it must not become a new way to
|
|
break a send."""
|
|
if not _alert_throttle.allow(reason):
|
|
return
|
|
try:
|
|
with SessionLocal() as db:
|
|
admins = (
|
|
db.execute(
|
|
select(User.id).where(
|
|
User.role == "admin",
|
|
User.is_active.is_(True),
|
|
User.is_suspended.is_(False),
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
if not admins:
|
|
# Nothing was recorded, so don't let this cost the next hour of alerts on the very
|
|
# path whose job is to stop drops going unseen. Same on the failure branch below.
|
|
_alert_throttle.reset()
|
|
return
|
|
for uid in admins:
|
|
create_notification(
|
|
db,
|
|
user_id=uid,
|
|
type="email_undelivered",
|
|
# English fallback; the inbox renders a translated version from `data`.
|
|
title="Email could not be sent",
|
|
body=subject,
|
|
data={"reason": reason, "subject": subject, "recipients": recipients},
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
except Exception:
|
|
_alert_throttle.reset()
|
|
log.exception("Could not record the undelivered-email notice (%r)", subject)
|
|
|
|
|
|
def _read_smtp(db: Session) -> dict:
|
|
return {
|
|
"host": sysconfig.get_str(db, "smtp_host"),
|
|
"port": sysconfig.get_int(db, "smtp_port"),
|
|
"user": sysconfig.get_str(db, "smtp_user"),
|
|
"password": sysconfig.get_str(db, "smtp_password"),
|
|
"from": sysconfig.get_str(db, "smtp_from"),
|
|
}
|
|
|
|
|
|
def _smtp(db: Session | None = None) -> dict:
|
|
"""Effective SMTP config (admin DB override over env defaults). Opens its own short session when
|
|
the caller has none, because email is usually sent from a BackgroundTask outside a request's db.
|
|
A caller that IS inside a request should pass its session: reading the password decrypts a Fernet
|
|
value, and /api/me polls this for every admin tab."""
|
|
if db is not None:
|
|
return _read_smtp(db)
|
|
with SessionLocal() as own:
|
|
return _read_smtp(own)
|
|
|
|
|
|
def email_enabled(db: Session | None = None) -> bool:
|
|
c = _smtp(db)
|
|
return bool(c["host"] and c["user"] and c["password"])
|
|
|
|
|
|
def _admin_contact() -> str | None:
|
|
return next(iter(sorted(settings.admin_email_set)), None)
|
|
|
|
|
|
# A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail,
|
|
# Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the
|
|
# link as dead text — which, for a verification or password-reset mail, is the whole message. So
|
|
# every mail also carries an HTML alternative built from the SAME plain body (below), rather than
|
|
# per-template HTML that would have to be kept in sync with it.
|
|
#
|
|
# `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim
|
|
# keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping
|
|
# turns `&` into `&`, so a query string can legitimately end in a semicolon.
|
|
_URL_RE = re.compile(r"https?://[^\s<]+")
|
|
|
|
|
|
def _html_body(text: str) -> str:
|
|
"""Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with
|
|
real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies
|
|
interpolate untrusted values (a requester's email address, the operator contact), so no caller
|
|
can inject markup, and `&` inside a URL is already `&` by the time it reaches the href, which
|
|
is what an HTML attribute wants anyway."""
|
|
|
|
def _anchor(m: re.Match[str]) -> str:
|
|
url = m.group(0)
|
|
trail = ""
|
|
while url and url[-1] in ".,!?)":
|
|
url, trail = url[:-1], url[-1] + trail
|
|
if not url:
|
|
return trail
|
|
return f'<a href="{url}">{url}</a>{trail}'
|
|
|
|
paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()]
|
|
rendered = "".join(
|
|
f"<p>{_URL_RE.sub(_anchor, p).replace(chr(10), '<br>')}</p>" for p in paragraphs
|
|
)
|
|
# Inline styles only — many clients drop <style> blocks. No images, no external CSS, no tracking:
|
|
# the HTML part exists to make the link clickable, not to become a template to maintain.
|
|
return (
|
|
'<html><body style="margin:0;padding:16px;font-family:system-ui,-apple-system,'
|
|
"'Segoe UI',sans-serif;font-size:15px;line-height:1.6\">"
|
|
f'<div style="max-width:560px">{rendered}</div>'
|
|
"</body></html>"
|
|
)
|
|
|
|
|
|
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
|
|
recipients = [e for e in to if e]
|
|
if not recipients:
|
|
return False
|
|
c = _smtp()
|
|
if not (c["host"] and c["user"] and c["password"]):
|
|
# The count, not the addresses: `redact_text` rewrites every one of them to `[email]`
|
|
# before either sink sees the line, so the list only ever printed as noise. The subject
|
|
# stays — the templates are static, and the one that interpolates (`admin_new_request`)
|
|
# interpolates an address, which the same redactor covers.
|
|
log.info(
|
|
"SMTP not configured; skipping email %r to %d recipient(s)",
|
|
subject,
|
|
len(recipients), # log-pii-ok: how many, not who
|
|
)
|
|
_alert_admins("not_configured", subject, recipients)
|
|
return False
|
|
msg = EmailMessage()
|
|
msg["Subject"] = subject
|
|
msg["From"] = c["from"] or c["user"]
|
|
msg["To"] = ", ".join(recipients)
|
|
# A real Date and a Reply-To (so it's a conversation, not a no-reply blast) both
|
|
# nudge spam filters the right way; reputation still does most of the work.
|
|
msg["Date"] = formatdate(localtime=True)
|
|
if reply_to:
|
|
msg["Reply-To"] = reply_to
|
|
# text/plain stays the primary part (it is the source of truth, and a text-only client still
|
|
# gets the full message); the HTML alternative below is what makes the link clickable everywhere.
|
|
msg.set_content(body)
|
|
msg.add_alternative(_html_body(body), subtype="html")
|
|
try:
|
|
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
|
|
s.starttls(context=ssl.create_default_context())
|
|
s.login(c["user"], c["password"])
|
|
s.send_message(msg)
|
|
# log-pii-ok: how many, not who
|
|
log.info("Sent email %r to %d recipient(s)", subject, len(recipients))
|
|
return True
|
|
except Exception:
|
|
# log-pii-ok: how many, not who
|
|
log.exception("SMTP send failed (%r to %d recipient(s))", subject, len(recipients))
|
|
_alert_admins("send_failed", subject, recipients)
|
|
return False
|
|
|
|
|
|
def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str:
|
|
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
|
|
|
|
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
|
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
|
refuse them (the sign-in screen names that state; the mail must not contradict it).
|
|
|
|
The link branch is the point of the C option: in the common ordering — approve first, the person
|
|
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
|
messages. One mail, one click.
|
|
"""
|
|
if verify_link:
|
|
return mailtext.text(
|
|
"next_step.verify", lang, link=verify_link, expiry=mailtext.link_expiry(hours, lang)
|
|
)
|
|
if has_account:
|
|
return mailtext.text("next_step.sign_in", lang, app=settings.app_base)
|
|
return mailtext.text("next_step.register", lang, app=settings.app_base)
|
|
|
|
|
|
def _reply_contact(lang: str) -> tuple[str | None, str]:
|
|
"""The admin address to answer to, and the closing line that names it — empty when no admin
|
|
contact is configured, so the mail never promises a reply nobody will read."""
|
|
admin = _admin_contact()
|
|
return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "")
|
|
|
|
|
|
def send_access_approved(
|
|
email: str,
|
|
lang: str,
|
|
verify_link: str | None = None,
|
|
has_account: bool = False,
|
|
hours: int = 24,
|
|
) -> bool:
|
|
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
|
|
made a request is owed a reply — unlike the admin-initiated invitation below."""
|
|
admin, contact = _reply_contact(lang)
|
|
body = mailtext.text(
|
|
"access_approved.body",
|
|
lang,
|
|
next_step=_next_step(verify_link, has_account, hours, lang),
|
|
contact=contact,
|
|
)
|
|
return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin)
|
|
|
|
|
|
def send_access_invitation(
|
|
email: str,
|
|
lang: str,
|
|
verify_link: str | None = None,
|
|
has_account: bool = False,
|
|
hours: int = 24,
|
|
) -> bool:
|
|
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
|
|
|
|
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
|
|
agreed it with the person, and an address can sit approved for weeks without anyone registering.
|
|
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
|
|
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
|
|
|
Its language is the one place the app has no signal to read: the admin typed this address in, so
|
|
nobody has ever told us how to address its owner. That is why the send button asks.
|
|
"""
|
|
admin, contact = _reply_contact(lang)
|
|
body = mailtext.text(
|
|
"access_invitation.body",
|
|
lang,
|
|
next_step=_next_step(verify_link, has_account, hours, lang),
|
|
contact=contact,
|
|
)
|
|
return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin)
|
|
|
|
|
|
def send_access_denied(email: str, lang: str) -> bool:
|
|
admin, contact = _reply_contact(lang)
|
|
body = mailtext.text("access_denied.body", lang, contact=contact)
|
|
return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin)
|
|
|
|
|
|
def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool:
|
|
line = mailtext.text(
|
|
"role_changed.granted" if role == "admin" else "role_changed.revoked", lang
|
|
)
|
|
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
|
body = mailtext.text("role_changed.body", lang, line=line, contact=contact)
|
|
return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool:
|
|
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
|
body = mailtext.text("account_unsuspended.body", lang, contact=contact)
|
|
return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_account_deleted(to: str, lang: str, operator: str | None) -> bool:
|
|
contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else ""
|
|
body = mailtext.text("account_deleted.body", lang, contact=contact)
|
|
return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_account_suspended(to: str, lang: str, operator: str | None) -> bool:
|
|
contact = (
|
|
mailtext.text("contact.suspended", lang, operator=operator)
|
|
if operator
|
|
else mailtext.text("contact.suspended_unknown", lang)
|
|
)
|
|
body = mailtext.text("account_suspended.body", lang, contact=contact)
|
|
return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool:
|
|
"""Tell the admins someone is waiting. One call per language group (see
|
|
`routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a
|
|
single blast to all of them would have to pick one and be wrong for the rest.
|
|
|
|
The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
|
requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
|
reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
|
sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
|
"""
|
|
body = mailtext.text(
|
|
"admin_new_request.body",
|
|
lang,
|
|
requester=requester,
|
|
link=f"{settings.app_base}/?admin=access-requests",
|
|
)
|
|
subject = mailtext.text("admin_new_request.subject", lang, requester=requester)
|
|
return _send(admins, subject, body, reply_to=requester)
|
|
|
|
|
|
def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool:
|
|
# The body says what the link's rules are. The reset mail always has; this one left the reader to
|
|
# discover both by meeting a dead link, which is precisely the silence this epic removes.
|
|
body = mailtext.text(
|
|
"verify_email.body", lang, link=link, expiry=mailtext.link_expiry(hours, lang)
|
|
)
|
|
return _send([to], mailtext.text("verify_email.subject", lang), body)
|
|
|
|
|
|
def send_password_reset(to: str, lang: str, link: str) -> bool:
|
|
body = mailtext.text("password_reset.body", lang, link=link)
|
|
return _send([to], mailtext.text("password_reset.subject", lang), body)
|
|
|
|
|
|
def send_test(to: str, lang: str) -> bool:
|
|
return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang))
|