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).
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""E9.5 S7 — every outbound mail is written in the reader's language.
|
|
|
|
Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced
|
|
here because 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. The second is the resolution chain: which
|
|
language a given recipient actually gets, and where that signal was captured, since for an address
|
|
with no account it exists only at the moment they asked us.
|
|
"""
|
|
|
|
import string
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app import mailtext
|
|
from app.models import Invite, User
|
|
from app.routes import auth as auth_routes
|
|
|
|
_PW = "correct horse battery"
|
|
|
|
|
|
def _placeholders(template: str) -> set[str]:
|
|
return {name for _, name, _, _ in string.Formatter().parse(template) if name}
|
|
|
|
|
|
# --- the catalog itself ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
|
def test_every_mail_string_exists_in_both_languages(key):
|
|
assert set(mailtext.MAIL_TEXT[key]) == set(mailtext.SUPPORTED)
|
|
|
|
|
|
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
|
def test_every_translation_takes_the_same_placeholders(key):
|
|
# The parity that actually bites: a translation that drops `{link}` renders a mail with no link
|
|
# in it, and a translation that invents one raises KeyError inside a background task nobody is
|
|
# watching. Both are silent in production and loud here.
|
|
variants = mailtext.MAIL_TEXT[key]
|
|
expected = _placeholders(variants[mailtext.DEFAULT])
|
|
for lang, template in variants.items():
|
|
assert _placeholders(template) == expected, f"{key} [{lang}]"
|
|
|
|
|
|
def test_no_catalog_entry_is_orphaned():
|
|
"""Every key is referenced from the app, so a mail that stopped being sent can't leave its text
|
|
behind to be 'kept up to date' by the next person who greps for it."""
|
|
from pathlib import Path
|
|
|
|
app_dir = Path(__file__).resolve().parents[1] / "app"
|
|
sources = "\n".join(p.read_text(encoding="utf-8") for p in app_dir.rglob("*.py"))
|
|
unused = [k for k in mailtext.MAIL_TEXT if f'"{k}"' not in sources]
|
|
assert unused == []
|
|
|
|
|
|
def test_an_unknown_language_falls_back_to_english_instead_of_raising():
|
|
# `preferences.language` is free-form JSON written by older builds and by locales we may stop
|
|
# shipping. A send that raised on one would take out a background task with no user in sight.
|
|
assert mailtext.text("test.subject", "de") == mailtext.text("test.subject", "en")
|
|
assert mailtext.text("test.subject", None) == mailtext.text("test.subject", "en")
|
|
|
|
|
|
# --- who gets which language ----------------------------------------------------------------------
|
|
|
|
|
|
def test_a_users_own_preference_decides_their_language(make_user):
|
|
hu = make_user(email="hu@example.com", preferences={"language": "hu"})
|
|
en = make_user(email="en@example.com", preferences={"language": "en"})
|
|
none = make_user(email="none@example.com", preferences=None)
|
|
|
|
assert mailtext.lang_for_user(hu) == "hu"
|
|
assert mailtext.lang_for_user(en) == "en"
|
|
assert mailtext.lang_for_user(none) == "en"
|
|
|
|
|
|
def test_an_address_with_no_account_falls_back_to_the_language_it_asked_in(db):
|
|
db.add(Invite(email="asked@example.com", status="pending", lang="hu"))
|
|
db.commit()
|
|
|
|
assert mailtext.lang_for_email(db, "asked@example.com") == "hu"
|
|
|
|
|
|
def test_the_account_wins_over_the_invite_row(db, make_user):
|
|
make_user(email="both@example.com", preferences={"language": "en"})
|
|
db.add(Invite(email="both@example.com", status="approved", lang="hu"))
|
|
db.commit()
|
|
|
|
# The invite records how they asked once; the account records what they have since chosen.
|
|
assert mailtext.lang_for_email(db, "both@example.com") == "en"
|
|
|
|
|
|
def test_a_row_that_never_recorded_a_language_is_walked_past(db, make_user):
|
|
# Prod carries TWO accounts for one Gmail mailbox (the 2026-09-03 pair). Stopping at the first
|
|
# row would answer English for a person whose other account says Hungarian.
|
|
make_user(email="dup.one@gmail.com", preferences={})
|
|
make_user(email="dupone@gmail.com", preferences={"language": "hu"})
|
|
|
|
assert mailtext.lang_for_email(db, "dup.one@gmail.com") == "hu"
|
|
|
|
|
|
def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user):
|
|
make_user(email="canon.match@gmail.com", preferences={"language": "hu"})
|
|
|
|
assert mailtext.lang_for_email(db, "canonmatch+tag@gmail.com") == "hu"
|
|
|
|
|
|
# --- capturing the signal at the one moment it exists ---------------------------------------------
|
|
|
|
|
|
def test_registration_records_the_language_of_the_sign_up_page(db, client):
|
|
r = client.post(
|
|
"/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"}
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
user = db.execute(select(User).where(User.email == "signup@example.com")).scalar_one()
|
|
assert (user.preferences or {}).get("language") == "hu"
|
|
|
|
|
|
def test_an_access_request_records_the_language_it_was_made_in(db, client):
|
|
assert (
|
|
client.post(
|
|
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
|
assert inv.lang == "hu"
|
|
|
|
|
|
def test_asking_again_from_another_language_updates_the_row(db, client):
|
|
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
|
|
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
|
|
|
db.expire_all()
|
|
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
|
# The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert).
|
|
assert inv.lang == "hu"
|
|
assert db.execute(select(Invite)).scalars().all() == [inv]
|
|
|
|
|
|
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
|
|
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"})
|
|
|
|
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
|
assert inv.lang is None
|
|
|
|
|
|
# --- the mails themselves -------------------------------------------------------------------------
|
|
|
|
|
|
def _bodies(monkeypatch):
|
|
"""Capture what `_send` would have put on the wire, rather than the arguments a caller passed —
|
|
the language only matters if it reaches the body."""
|
|
from app import email as email_mod
|
|
|
|
out: list[tuple[list[str], str, str]] = []
|
|
monkeypatch.setattr(
|
|
email_mod, "_send", lambda to, subject, body, reply_to=None: out.append((to, subject, body))
|
|
)
|
|
return out
|
|
|
|
|
|
def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, monkeypatch):
|
|
from app import email as email_mod
|
|
|
|
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
|
sent = _bodies(monkeypatch)
|
|
|
|
client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"})
|
|
|
|
(_, subject, body) = sent[0]
|
|
assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"]
|
|
assert "#verify=" in body
|
|
|
|
|
|
def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
|
|
db, client, make_user, as_user, monkeypatch
|
|
):
|
|
from app import email as email_mod
|
|
|
|
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
|
sent = _bodies(monkeypatch)
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
|
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
|
sent.clear() # drop the admin notification; this test is about the reply to the requester
|
|
|
|
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
|
|
|
|
assert r.status_code == 200
|
|
(to, subject, _) = sent[0]
|
|
assert to == ["asking@example.com"]
|
|
assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"]
|
|
|
|
|
|
def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch):
|
|
from app import email as email_mod
|
|
|
|
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
|
sent = _bodies(monkeypatch)
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
|
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
|
sent.clear()
|
|
|
|
as_user(admin).post(f"/api/admin/invites/{inv.id}/deny")
|
|
|
|
assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"]
|
|
|
|
|
|
def test_the_admins_pick_decides_an_invitation_and_is_remembered(
|
|
db, make_user, as_user, monkeypatch
|
|
):
|
|
from app import email as email_mod
|
|
|
|
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
|
sent = _bodies(monkeypatch)
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
# Hand-whitelisted: nobody has ever told us anything about this mailbox, which is exactly why
|
|
# the send button asks.
|
|
inv = Invite(email="stranger@example.com", status="approved")
|
|
db.add(inv)
|
|
db.commit()
|
|
|
|
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "hu"})
|
|
|
|
assert r.status_code == 200
|
|
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["hu"]
|
|
db.expire_all()
|
|
# Recorded, so a later mail to this address matches the one the admin already sent.
|
|
assert db.get(Invite, inv.id).lang == "hu"
|
|
|
|
|
|
def test_an_invitation_without_a_pick_still_goes_out(db, make_user, as_user, monkeypatch):
|
|
from app import email as email_mod
|
|
|
|
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
|
sent = _bodies(monkeypatch)
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
inv = Invite(email="stranger@example.com", status="approved")
|
|
db.add(inv)
|
|
db.commit()
|
|
|
|
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
|
|
|
assert r.status_code == 200
|
|
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["en"]
|
|
db.expire_all()
|
|
# Nothing was picked, so nothing is remembered: the column means "what this person told us",
|
|
# not "what we last defaulted to".
|
|
assert db.get(Invite, inv.id).lang is None
|
|
|
|
|
|
def test_admins_are_notified_in_their_own_languages(db, make_user):
|
|
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
|
|
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
|
|
|
|
groups = auth_routes.admin_notify_groups(db)
|
|
|
|
# One mail per language, not one blast that has to be wrong for somebody.
|
|
assert groups == {"hu": ["hu.admin@example.com"], "en": ["en.admin@example.com"]}
|