Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).
Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
it (the overlay stack's own push is the one exemption).
"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.
Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
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 (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_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 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]] = {
|
|
# ── How long a link lasts ───────────────────────────────────────────────────────────────
|
|
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days
|
|
# (E9.5 S6), 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"
|
|
),
|
|
},
|
|
}
|