Eleven findings survived adversarial verification; these are the fixes.
**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.
**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.
**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.
**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.
**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.
**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
333 lines
15 KiB
Python
333 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 logging
|
|
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 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 = logging.getLogger("siftlode.email")
|
|
|
|
# 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"]):
|
|
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
|
_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.info("Sent email %r to %s", subject, recipients)
|
|
return True
|
|
except Exception:
|
|
log.exception("SMTP send failed (%r to %s)", subject, 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 (E9.5 S4 named that state on screen; 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 (E9.5 S7).
|
|
"""
|
|
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))
|