The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
374 lines
17 KiB
Python
374 lines
17 KiB
Python
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
|
|
recipient gets.
|
|
|
|
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_language.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 link_expiry(hours: int, lang: str) -> str:
|
|
"""How long a link lasts, in a unit a reader converts on sight. A whole number of days is
|
|
said in days — the approval mail's token lives seven of them, and "168 hours" is not an answer
|
|
to "how long have I got". A single day stays "24 hours", which is how everyone writes it."""
|
|
if hours >= 48 and hours % 24 == 0:
|
|
return text("duration.days", lang, n=hours // 24)
|
|
return text("duration.hours", lang, n=hours)
|
|
|
|
|
|
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 their access request came in (the locale Google reported), 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]] = {
|
|
# ── How long a link lasts ───────────────────────────────────────────────────────────────
|
|
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days,
|
|
# and "expires in 168 hours" is a unit no reader converts.
|
|
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
|
|
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
|
|
# ── 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 {expiry}. 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 {expiry} 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 "
|
|
"Feedbe é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 {expiry}. 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 {expiry} 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"
|
|
),
|
|
},
|
|
}
|