Files
siftlode/backend/app/mailtext.py
T
peter 10c0ca4cf7 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).
2026-09-04 20:29:33 +02:00

360 lines
16 KiB
Python

"""Bilingual text for every outbound mail, and the one resolver that decides which language a
recipient gets (E9.5 S7).
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
language — it arrives once, in whatever language we picked, and stays that way. `test_mail_text.py`
enforces that every key carries both languages *and* the same `{placeholders}`, so a half-translated
mail fails the gate instead of reaching a person.
The language is resolved by the REQUEST handler and handed to `send_*` as a plain code. It is never
resolved inside the mail: every send runs as a `BackgroundTasks` job, after the request's session is
gone — the same reason `_welcome_mail_parts` computes its link in the handler and not in the task.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Invite, User
from app.utils import canonical_email
SUPPORTED: tuple[str, ...] = ("en", "hu")
DEFAULT = "en"
def normalize(lang: str | None) -> str:
"""Coerce anything to a language we can actually render. Storage is not a guarantee: a
`preferences.language` written by an older build (or a locale we no longer ship) must degrade to
English rather than raise inside a background task nobody is watching."""
return lang if lang in SUPPORTED else DEFAULT
def text(key: str, lang: str | None, **values: object) -> str:
"""Render one catalog entry. Unknown languages fall back to English (see `normalize`); an
unknown KEY raises, because that is a programming error the tests must surface, not something to
paper over with an empty line in someone's inbox."""
return MAIL_TEXT[key][normalize(lang)].format(**values)
def lang_for_user(user: User | None) -> str:
"""The language a signed-up recipient gets: the one they picked in the app."""
prefs = (user.preferences or {}) if user is not None else {}
return normalize(prefs.get("language"))
def lang_for_email(db: Session, email: str) -> str:
"""The language for an address that may not have an account yet: their own account preference,
else the language they filled our access-request form in, else English.
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
carry no usable code instead of stopping at the first one — prod really does hold two accounts
for a single mailbox, and only one of them may have ever picked a language.
"""
canon = canonical_email(email)
for prefs in (
db.execute(select(User.preferences).where(User.email_canonical == canon).order_by(User.id))
.scalars()
.all()
):
code = (prefs or {}).get("language")
if code in SUPPORTED:
return code
for code in (
db.execute(select(Invite.lang).where(Invite.email_canonical == canon).order_by(Invite.id))
.scalars()
.all()
):
if code in SUPPORTED:
return code
return DEFAULT
# The brand line ("— Siftlode") and the product name are deliberately NOT catalog entries: they are
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
MAIL_TEXT: dict[str, dict[str, str]] = {
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
"contact.reply": {
"en": "\n\nQuestions? Just reply to this email ({admin}).",
"hu": "\n\nKérdésed van? Csak válaszolj erre a levélre ({admin}).",
},
"contact.operator": {
"en": "\n\nQuestions? Contact the operator at {operator}.",
"hu": "\n\nKérdésed van? Írj az üzemeltetőnek: {operator}.",
},
"contact.deleted": {
"en": "\n\nIf you didn't request this, contact the Siftlode operator at {operator}.",
"hu": "\n\nHa nem te kérted, írj a Siftlode üzemeltetőjének: {operator}.",
},
"contact.suspended": {
"en": (
"If you think this is a mistake or have questions, contact the Siftlode operator "
"at {operator}.\n\n"
),
"hu": (
"Ha szerinted ez tévedés, vagy kérdésed van, írj a Siftlode üzemeltetőjének: "
"{operator}.\n\n"
),
},
"contact.suspended_unknown": {
"en": "If you think this is a mistake, please contact the Siftlode operator.\n\n",
"hu": "Ha szerinted ez tévedés, keresd a Siftlode üzemeltetőjét.\n\n",
},
# ── The one paragraph that carries the whole onboarding flow (see email._next_step) ──────────
"next_step.verify": {
"en": (
"You already started an account, so there is just one step left. Confirm your email "
"address and you're in:\n\n"
"{link}\n\n"
"The link works once and expires in {hours} hours. If it does, you can ask for a fresh "
"one from the sign-in page."
),
"hu": (
"A fiókodat már elkezdted, úgyhogy egyetlen lépés maradt. Erősítsd meg az "
"e-mail-címed, és bent is vagy:\n\n"
"{link}\n\n"
"A link egyszer használható, és {hours} óra múlva lejár. Ha lejárt, a belépési oldalon "
"kérhetsz újat."
),
},
"next_step.sign_in": {
"en": "Your account is ready — just sign in:\n\n{app}",
"hu": "A fiókod készen áll — csak lépj be:\n\n{app}",
},
"next_step.register": {
"en": (
"Create your account whenever you feel like it:\n\n"
"{app}\n\n"
"We'll send you a confirmation link to finish, and then you're in."
),
"hu": (
"Hozd létre a fiókod, amikor kedved tartja:\n\n"
"{app}\n\n"
"Küldünk egy megerősítő linket a befejezéshez, és már bent is vagy."
),
},
# ── Access decision ─────────────────────────────────────────────────────────────────────────
"access_approved.subject": {
"en": "You're in — your Siftlode access is approved",
"hu": "Bent vagy — jóváhagytuk a Siftlode-hozzáférésed",
},
"access_approved.body": {
"en": (
"Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
"{next_step}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Jóváhagytuk a Siftlode-hozzáférési kérelmed — üdv a fedélzeten.\n\n"
"{next_step}\n\n"
"Ha nem számítottál erre, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
"— Siftlode{contact}"
),
},
"access_invitation.subject": {
"en": "You're invited to Siftlode",
"hu": "Meghívtak a Siftlode-ra",
},
"access_invitation.body": {
"en": (
"Hi,\n\n"
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
"subscriptions. Every upload from the channels you follow lands in one feed you can "
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
"livestreams unless you want them.\n\n"
"{next_step}\n\n"
"No rush — the door stays open.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
"{next_step}\n\n"
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
"— Siftlode{contact}"
),
},
"access_denied.subject": {
"en": "About your Siftlode access request",
"hu": "A Siftlode-hozzáférési kérelmedről",
},
"access_denied.body": {
"en": (
"Hi,\n\n"
"Thanks for your interest in Siftlode. After review, your access request wasn't "
"approved this time.\n\n"
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Köszönjük az érdeklődésed a Siftlode iránt. Átnéztük a kérelmed, de most nem hagytuk "
"jóvá.\n\n"
"Ha szerinted ez tévedés, válaszolj nyugodtan erre a levélre.\n\n"
"— Siftlode{contact}"
),
},
# ── Account lifecycle ───────────────────────────────────────────────────────────────────────
"role_changed.subject": {
"en": "Your Siftlode role has changed",
"hu": "Megváltozott a szereped a Siftlode-on",
},
"role_changed.granted": {
"en": (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
),
"hu": (
"Admin jogot kaptál a Siftlode-on — mostantól kezelheted a beállításokat, az ütemezőt "
"és a felhasználókat."
),
},
"role_changed.revoked": {
"en": "Your admin access on Siftlode has been removed — you're now a regular user.",
"hu": "Elvettük az admin jogod a Siftlode-on — mostantól sima felhasználó vagy.",
},
"role_changed.body": {
"en": "Hi,\n\n{line}{contact}\n\n— Siftlode",
"hu": "Szia,\n\n{line}{contact}\n\n— Siftlode",
},
"account_unsuspended.subject": {
"en": "Your Siftlode account has been reinstated",
"hu": "Visszaállítottuk a Siftlode-fiókod",
},
"account_unsuspended.body": {
"en": (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can "
"sign in again.{contact}\n\n— Siftlode"
),
"hu": (
"Szia,\n\n"
"Visszaállítottuk a Siftlode-fiókod — a felfüggesztést feloldottuk, újra be tudsz "
"lépni.{contact}\n\n— Siftlode"
),
},
"account_deleted.subject": {
"en": "Your Siftlode account has been deleted",
"hu": "Töröltük a Siftlode-fiókod",
},
"account_deleted.body": {
"en": (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode.{contact}\n\n— Siftlode"
),
"hu": (
"Szia,\n\n"
"A Siftlode-fiókod és minden hozzá tartozó adat — feliratkozások, címkék, megtekintési "
"előzmények, lejátszási listák és beállítások — véglegesen törölve. Semmit nem őrzünk "
"meg.\n\n"
"Köszönjük, hogy használtad a Siftlode-ot.{contact}\n\n— Siftlode"
),
},
"account_suspended.subject": {
"en": "Your Siftlode account is suspended",
"hu": "A Siftlode-fiókod fel van függesztve",
},
"account_suspended.body": {
"en": (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n{contact}— Siftlode"
),
"hu": (
"Szia,\n\n"
"Valaki most próbált belépni a Siftlode-fiókodba, de a fiók jelenleg fel van "
"függesztve, ezért a belépést elutasítottuk.\n\n{contact}— Siftlode"
),
},
# ── To the admins ───────────────────────────────────────────────────────────────────────────
"admin_new_request.subject": {
"en": "Siftlode access request from {requester}",
"hu": "Siftlode hozzáférési kérelem: {requester}",
},
"admin_new_request.body": {
"en": (
"{requester} just requested access to Siftlode.\n\n"
"Approve or deny it here:\n{link}\n"
"(or in Siftlode: open Users → Access requests)\n\n"
"Requester's email: {requester}\n"
"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
),
"hu": (
"{requester} most kért hozzáférést a Siftlode-hoz.\n\n"
"Itt hagyhatod jóvá vagy utasíthatod el:\n{link}\n"
"(vagy a Siftlode-ban: Felhasználók → Hozzáférési kérések)\n\n"
"A kérelmező e-mail-címe: {requester}\n"
"Ha erre a levélre válaszolsz, az egyenesen a kérelmezőhöz megy (ő a Reply-To cím).\n"
),
},
# ── Link mails ──────────────────────────────────────────────────────────────────────────────
"verify_email.subject": {
"en": "Confirm your Siftlode email",
"hu": "Erősítsd meg a Siftlode e-mail-címed",
},
"verify_email.body": {
"en": (
"Hi,\n\n"
"Confirm your email to finish creating your Siftlode account:\n\n"
"{link}\n\n"
"The link works once and expires in {hours} hours. If it does, you can ask for a fresh "
"one from the sign-in page.\n\n"
"If you didn't sign up, you can ignore this message.\n\n"
"— Siftlode"
),
"hu": (
"Szia,\n\n"
"Erősítsd meg az e-mail-címed, hogy elkészüljön a Siftlode-fiókod:\n\n"
"{link}\n\n"
"A link egyszer használható, és {hours} óra múlva lejár. Ha lejárt, a belépési oldalon "
"kérhetsz újat.\n\n"
"Ha nem te regisztráltál, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
"— Siftlode"
),
},
"password_reset.subject": {
"en": "Reset your Siftlode password",
"hu": "Állíts be új Siftlode-jelszót",
},
"password_reset.body": {
"en": (
"Hi,\n\n"
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
"{link}\n\n"
"If you didn't request this, you can ignore this message — your password is "
"unchanged.\n\n"
"— Siftlode"
),
"hu": (
"Szia,\n\n"
"Ezzel a linkkel állíthatsz be új Siftlode-jelszót (egy óra múlva lejár):\n\n"
"{link}\n\n"
"Ha nem te kérted, hagyd figyelmen kívül ezt a levelet — a jelszavad változatlan.\n\n"
"— Siftlode"
),
},
"test.subject": {
"en": "Siftlode SMTP test",
"hu": "Siftlode SMTP-teszt",
},
"test.body": {
"en": (
"This is a test email from Siftlode.\n\n"
"If you're reading this, your SMTP settings work.\n\n"
"— Siftlode"
),
"hu": (
"Ez egy teszt e-mail a Siftlode-tól.\n\n"
"Ha ezt olvasod, az SMTP-beállításaid működnek.\n\n"
"— Siftlode"
),
},
}