"""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" ), }, }