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:
2026-09-04 20:29:33 +02:00
parent a76bab9b34
commit 10c0ca4cf7
24 changed files with 1088 additions and 211 deletions
+10 -2
View File
@@ -29,6 +29,10 @@ export interface Invite {
* is deliberately silent — it opens a door rather than knocking — so telling them is a separate
* act, and this is how the admin sees they have already done it. */
invited_at: string | null;
/** The language this person asked us in, or null when nobody ever told us — a row the admin
* whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI
* language, so the guess is visible and correctable before anything is sent. */
lang: string | null;
}
/** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the
@@ -199,8 +203,12 @@ export const adminApi = {
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
// Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so
// this is the deliberate act that actually reaches a person.
sendInvitation: (id: number): Promise<Invite> =>
req(`/api/admin/invites/${id}/invite`, { method: "POST" }, { quiet: true }),
sendInvitation: (id: number, lang: string): Promise<Invite> =>
req(
`/api/admin/invites/${id}/invite`,
{ method: "POST", body: JSON.stringify({ lang }) },
{ quiet: true },
),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
+11 -3
View File
@@ -1,7 +1,7 @@
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
// session management, access requests, and the hidden demo login. Errors on the interactive paths
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
import { req } from "./core";
import { req, uiLang } from "./core";
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
* so the card can render an honest state instead of a red line; `invalid` and `rate_limited` answer
@@ -61,10 +61,13 @@ export const authApi = {
}> => req("/auth/config"),
// --- email + password (errors handled inline via quiet) ---
// `lang` is stamped here rather than passed in: this is the ONE moment the sign-up language
// exists anywhere the server can see it (the account does not exist yet to carry it), and every
// mail this person ever gets is written from what lands here.
register: (email: string, password: string): Promise<{ status: string }> =>
req(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ method: "POST", body: JSON.stringify({ email, password, lang: uiLang() }) },
{ quiet: true },
),
// A refusal carries a structured `{code:"login", reason, …}` detail — see LoginBlocked above.
@@ -125,8 +128,13 @@ export const authApi = {
),
// --- onboarding: access requests + demo entry ---
// Same reasoning as register: the requester has no account to carry a language preference, so the
// page they asked from is what decides the language of the approval or denial mail.
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
req("/auth/request-access", {
method: "POST",
body: JSON.stringify({ email, lang: uiLang() }),
}),
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
+6
View File
@@ -8,6 +8,12 @@ import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
* every caller is one every caller can forget, which is how the registration language shipped as a
* field nobody ever filled (E9.5 S6 → S7). */
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
export class HttpError extends Error {
status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present
+6 -2
View File
@@ -1,7 +1,7 @@
// First-run install wizard: probes install state and drives admin/config/email setup with the
// one-time token printed to the container logs (carried via setupHeaders). Every write is quiet so
// the wizard renders errors inline. Only touches core's req + setupHeaders.
import { req, setupHeaders } from "./core";
import { req, setupHeaders, uiLang } from "./core";
export const setupApi = {
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
@@ -22,7 +22,11 @@ export const setupApi = {
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/test-email",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
{
method: "POST",
headers: setupHeaders(token),
body: JSON.stringify({ to, lang: uiLang() }),
},
{ quiet: true },
),
setupFinish: (token: string): Promise<{ ok: boolean }> =>