feat(email): write to people in their own language (E9.5 S7)
Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.
The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.
Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.
Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.
That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.
S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.
Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.
Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
This commit is contained in:
+90
-32
@@ -18,6 +18,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
@@ -63,12 +64,20 @@ _request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
|
||||
|
||||
|
||||
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
|
||||
def _notify_suspended(background: BackgroundTasks, user: User) -> None:
|
||||
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
|
||||
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
|
||||
triggered by someone who doesn't control the account."""
|
||||
if _suspend_email_limiter.allow(email):
|
||||
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
||||
triggered by someone who doesn't control the account.
|
||||
|
||||
Takes the user rather than the address because the mail needs their language, and the send runs
|
||||
as a background task — after this request's session, and the row, are out of reach."""
|
||||
if _suspend_email_limiter.allow(user.email):
|
||||
background.add_task(
|
||||
email_mod.send_account_suspended,
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
operator_contact(),
|
||||
)
|
||||
|
||||
|
||||
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
|
||||
@@ -101,18 +110,43 @@ def google_oauth(db: Session):
|
||||
return _oauth.google
|
||||
|
||||
|
||||
def admin_notify_emails(db: Session) -> list[str]:
|
||||
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
|
||||
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
|
||||
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
|
||||
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
|
||||
keeps a freshly-seeded instance working before any DB admin exists."""
|
||||
db_admins = db.execute(
|
||||
select(User.email).where(
|
||||
def admin_notify_groups(db: Session) -> dict[str, list[str]]:
|
||||
"""Who to email about admin events (e.g. a new access request), grouped by the language each of
|
||||
them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the
|
||||
env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is
|
||||
notified too (env alone would miss them, yet they CAN approve requests — the approve UI is
|
||||
role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working
|
||||
before any DB admin exists.
|
||||
|
||||
Grouped rather than one blast to everyone, because these recipients are people with their own UI
|
||||
language: a single message would have to pick one of them and be wrong for the rest. An env
|
||||
address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose
|
||||
account is (say) suspended is still written to in the language they chose."""
|
||||
rows = db.execute(
|
||||
select(User.email, User.preferences).where(
|
||||
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
|
||||
)
|
||||
).scalars()
|
||||
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
|
||||
).all()
|
||||
by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e}
|
||||
for email in settings.admin_email_set:
|
||||
if email not in by_email:
|
||||
by_email[email] = mailtext.lang_for_email(db, email)
|
||||
groups: dict[str, list[str]] = {}
|
||||
for email in sorted(by_email):
|
||||
groups.setdefault(by_email[email], []).append(email)
|
||||
return groups
|
||||
|
||||
|
||||
def notify_admins_of_request(
|
||||
db: Session, background: BackgroundTasks | None, requester: str
|
||||
) -> None:
|
||||
"""Tell every admin that someone is waiting — one mail per language group. Sends inline when
|
||||
there is no BackgroundTasks to hand (the registration path already runs outside a response)."""
|
||||
for lang, recipients in admin_notify_groups(db).items():
|
||||
if background is None:
|
||||
email_mod.send_admin_new_request(recipients, lang, requester)
|
||||
else:
|
||||
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
|
||||
|
||||
|
||||
def is_demo_allowed(db: Session, email: str) -> bool:
|
||||
@@ -172,23 +206,36 @@ class InviteRequest(NamedTuple):
|
||||
is_new: bool
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str) -> InviteRequest:
|
||||
"""Idempotently record an access request.
|
||||
def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest:
|
||||
"""Idempotently record an access request, and the language it was made in.
|
||||
|
||||
"Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one
|
||||
Gmail mailbox, and treating them as two is what put a second, silent access request in the admin
|
||||
queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must
|
||||
not reopen a decision that has already been made for that mailbox. The row is still STORED under
|
||||
the address as typed, because that is where mail has to go.
|
||||
|
||||
`lang` is the language of the surface the request came from — the sign-in page, or the locale
|
||||
Google reported for a refused sign-in. The latest one wins: someone who asks again from a
|
||||
Hungarian page is telling us something newer than last time. Anything unsupported is ignored
|
||||
rather than stored, so a forged body cannot put junk in the column.
|
||||
|
||||
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
|
||||
on that — `_register_account` persists its own flushed user precisely because a no-op here
|
||||
commits nothing (E9.5 S6).
|
||||
"""
|
||||
email = (email or "").lower()
|
||||
if not valid_email(email):
|
||||
return InviteRequest(None, False)
|
||||
lang = lang if lang in mailtext.SUPPORTED else None
|
||||
canon = canonical_email(email)
|
||||
rows = db.execute(select(Invite).where(Invite.email_canonical == canon)).scalars().all()
|
||||
# Prefer a decided row over a pending one, so a denied duplicate can't mask an approval.
|
||||
inv = next((r for r in rows if r.status == "approved"), None) or next(iter(rows), None)
|
||||
if inv is not None:
|
||||
relearned = lang is not None and inv.lang != lang
|
||||
if relearned:
|
||||
inv.lang = lang
|
||||
# Let a previously-denied person ask again; don't disturb approved/pending rows.
|
||||
if inv.status == "denied":
|
||||
inv.status = "pending"
|
||||
@@ -197,8 +244,10 @@ def upsert_pending_invite(db: Session, email: str) -> InviteRequest:
|
||||
inv.decided_by = None
|
||||
db.commit()
|
||||
return InviteRequest(inv, True)
|
||||
if relearned:
|
||||
db.commit()
|
||||
return InviteRequest(inv, False)
|
||||
inv = Invite(email=email, status="pending")
|
||||
inv = Invite(email=email, status="pending", lang=lang)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
return InviteRequest(inv, True)
|
||||
@@ -348,10 +397,11 @@ async def callback(
|
||||
# A denied Google login doubles as an access request: record it for the admin and
|
||||
# land the user on a friendly "request received" screen instead of a raw 403.
|
||||
if email:
|
||||
req = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if req.is_new and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
# Google just told us this person's locale — the only language signal that will ever
|
||||
# exist for an address with no account, and the decision mail is written from it.
|
||||
req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0])
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
|
||||
@@ -403,7 +453,7 @@ async def callback(
|
||||
# tell the (verified) owner why — a successful Google auth proves they control this account.
|
||||
if user.is_suspended:
|
||||
log.info("Suspended account blocked at Google login: %s", email)
|
||||
_notify_suspended(background, user.email)
|
||||
_notify_suspended(background, user)
|
||||
return RedirectResponse(url="/?login=suspended")
|
||||
# Record what Google actually answers with, beside the address the user registered with. The two
|
||||
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
|
||||
@@ -569,9 +619,17 @@ async def logout(request: Request):
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
|
||||
|
||||
class AccessRequestIn(EmailIn):
|
||||
# The language of the page this request was made from. Same reasoning as registration's `lang`
|
||||
# (and the same shape): it is the ONLY moment this preference exists anywhere the server can
|
||||
# see, since the requester has no account to carry it, and it decides what language the approval
|
||||
# or denial mail is written in.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(
|
||||
body: EmailIn,
|
||||
body: AccessRequestIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -592,10 +650,9 @@ async def request_access(
|
||||
return {"status": "pending"}
|
||||
if is_allowed(db, email):
|
||||
return {"status": "pending"} # already allowed — don't reveal it
|
||||
req = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if req.is_new and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
req = upsert_pending_invite(db, email, body.lang)
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return {"status": "pending"}
|
||||
|
||||
|
||||
@@ -792,14 +849,12 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
|
||||
db.commit()
|
||||
if email_ok:
|
||||
send_verification_link(db, user)
|
||||
admins = admin_notify_emails(db)
|
||||
# Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for
|
||||
# an address the admin already approved, nothing lands in the Access-requests queue, so this
|
||||
# is the only place the account's existence is announced at all. Delivery is a separate
|
||||
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
|
||||
# still needs the admin to notice them (roadmap UX-25).
|
||||
if admins:
|
||||
email_mod.send_admin_new_request(admins, email)
|
||||
notify_admins_of_request(db, None, email)
|
||||
|
||||
|
||||
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
|
||||
@@ -825,6 +880,7 @@ def send_verification_link(db: Session, user: User) -> None:
|
||||
"""
|
||||
email_mod.send_verify_email(
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
issue_verification_link(db, user),
|
||||
hours=int(VERIFY_TTL.total_seconds() // 3600),
|
||||
)
|
||||
@@ -937,7 +993,7 @@ def password_login(
|
||||
if user is None or user.is_demo or not password_ok:
|
||||
raise _login_refused(401, "invalid")
|
||||
if user.is_suspended:
|
||||
_notify_suspended(background, user.email)
|
||||
_notify_suspended(background, user)
|
||||
raise _login_refused(403, "suspended", user, operator=operator_contact() or None)
|
||||
if not user.email_verified:
|
||||
raise _login_refused(403, "email_unverified", user)
|
||||
@@ -985,7 +1041,9 @@ def _send_reset_if_eligible(email: str) -> None:
|
||||
if user.password_hash or user.password_revoked_at:
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
# The account's own address, as above — never the spelling that was typed into the form.
|
||||
email_mod.send_password_reset(user.email, f"{_app_base()}/#reset={raw}")
|
||||
email_mod.send_password_reset(
|
||||
user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password-reset/check")
|
||||
|
||||
Reference in New Issue
Block a user