diff --git a/backend/alembic/versions/0082_invite_lang.py b/backend/alembic/versions/0082_invite_lang.py new file mode 100644 index 00000000..1d818c62 --- /dev/null +++ b/backend/alembic/versions/0082_invite_lang.py @@ -0,0 +1,34 @@ +"""invites.lang — the language this person asked us in + +Revision ID: 0082_invite_lang +Revises: 0081_invite_invited_at +Create Date: 2026-09-05 + +The mails an access request produces — approved, denied, invited — go to an address that often has +no account yet, so there is no `preferences.language` to read. The signal does exist, though, and +only at the moment of the request: the language of the page the form was submitted from, or the +locale Google reported for a sign-in we refused. Recording it there is what lets the decision mail +answer in the requester's own language instead of an admin's guess about a stranger. + +Captured rather than derived, for the third time in this epic: `preferences.language` (S6) and +`google_email` (S3) both taught that a signal which exists only in one moment cannot be backfilled +later. So no backfill here either — NULL means "we were never told", which is the honest state for +every historic row, and for every address an admin whitelists by hand. Those fall back to English +unless the admin picks otherwise on the invitation send. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0082_invite_lang" +down_revision: str | None = "0081_invite_invited_at" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column("invites", sa.Column("lang", sa.String(length=8), nullable=True)) + + +def downgrade() -> None: + op.drop_column("invites", "lang") diff --git a/backend/app/auth.py b/backend/app/auth.py index f2283b1f..bfe2fdb6 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from starlette.requests import HTTPConnection from app import email as email_mod +from app import mailtext from app import sysconfig from app.config import settings from app.db import get_db @@ -211,6 +212,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: touch any browser session — each caller handles its own.""" email = user.email.lower() user_id = user.id + # Read the language off the row while it still exists — the confirmation below is queued as a + # background task that runs after this session (and this user) are gone. + lang = mailtext.lang_for_user(user) tok = user.token # Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing # ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a @@ -231,7 +235,7 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: background.add_task(revoke_google_token, google_token) # Confirm the erasure to the (now former) user — GDPR good practice, and covers both the # self-service and admin deletion paths since both funnel through here. - background.add_task(email_mod.send_account_deleted, email, operator_contact()) + background.add_task(email_mod.send_account_deleted, email, lang, operator_contact()) ACTIVE_ACCOUNT_HEADER = "x-siftlode-account" diff --git a/backend/app/email.py b/backend/app/email.py index 18062b3e..57013360 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -21,7 +21,7 @@ from email.utils import formatdate from sqlalchemy import select from sqlalchemy.orm import Session -from app import sysconfig +from app import mailtext, sysconfig from app.config import settings from app.db import SessionLocal from app.models import User @@ -184,7 +184,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) - return False -def _next_step(verify_link: str | None, has_account: bool, hours: int) -> str: +def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str: """What this person actually has to do next, in the one paragraph that carries the whole flow. Three states, because a mail that guesses wrong sends someone looking for a link that isn't @@ -196,40 +196,44 @@ def _next_step(verify_link: str | None, has_account: bool, hours: int) -> str: messages. One mail, one click. """ if verify_link: - return ( - "You already started an account, so there is just one step left. Confirm your email " - "address and you're in:\n\n" - f"{verify_link}\n\n" - f"The link works once and expires in {hours} hours. If it does, you can ask for a fresh " - "one from the sign-in page." - ) + return mailtext.text("next_step.verify", lang, link=verify_link, hours=hours) if has_account: - return f"Your account is ready — just sign in:\n\n{settings.app_base}" - return ( - "Create your account whenever you feel like it:\n\n" - f"{settings.app_base}\n\n" - "We'll send you a confirmation link to finish, and then you're in." - ) + return mailtext.text("next_step.sign_in", lang, app=settings.app_base) + return mailtext.text("next_step.register", lang, app=settings.app_base) + + +def _reply_contact(lang: str) -> tuple[str | None, str]: + """The admin address to answer to, and the closing line that names it — empty when no admin + contact is configured, so the mail never promises a reply nobody will read.""" + admin = _admin_contact() + return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "") def send_access_approved( - email: str, verify_link: str | None = None, has_account: bool = False, hours: int = 24 + email: str, + lang: str, + verify_link: str | None = None, + has_account: bool = False, + hours: int = 24, ) -> bool: """Answer someone who ASKED for access. Sent automatically on approval, because a person who made a request is owed a reply — unlike the admin-initiated invitation below.""" - admin = _admin_contact() - body = ( - "Hi,\n\n" - "Your request to use Siftlode has been approved — welcome aboard.\n\n" - f"{_next_step(verify_link, has_account, hours)}\n\n" - "If you weren't expecting this, you can ignore the message.\n\n" - "— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "") + admin, contact = _reply_contact(lang) + body = mailtext.text( + "access_approved.body", + lang, + next_step=_next_step(verify_link, has_account, hours, lang), + contact=contact, ) - return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin) + return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin) def send_access_invitation( - email: str, verify_link: str | None = None, has_account: bool = False, hours: int = 24 + email: str, + lang: str, + verify_link: str | None = None, + has_account: bool = False, + hours: int = 24, ) -> bool: """Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send. @@ -237,131 +241,88 @@ def send_access_invitation( agreed it with the person, and an address can sit approved for weeks without anyone registering. So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why the closing line offers no deadline — nudging is the one thing this mail must not do. + + Its language is the one place the app has no signal to read: the admin typed this address in, so + nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7). """ - admin = _admin_contact() - body = ( - "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" - f"{_next_step(verify_link, has_account, hours)}\n\n" - "No rush — the door stays open.\n\n" - "— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "") + admin, contact = _reply_contact(lang) + body = mailtext.text( + "access_invitation.body", + lang, + next_step=_next_step(verify_link, has_account, hours, lang), + contact=contact, ) - return _send([email], "You're invited to Siftlode", body, reply_to=admin) + return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin) -def send_access_denied(email: str) -> bool: - admin = _admin_contact() - body = ( - "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" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "") +def send_access_denied(email: str, lang: str) -> bool: + admin, contact = _reply_contact(lang) + body = mailtext.text("access_denied.body", lang, contact=contact) + return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin) + + +def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool: + line = mailtext.text( + "role_changed.granted" if role == "admin" else "role_changed.revoked", lang ) - return _send([email], "About your Siftlode access request", body, reply_to=admin) + contact = mailtext.text("contact.operator", lang, operator=operator) if operator else "" + body = mailtext.text("role_changed.body", lang, line=line, contact=contact) + return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator) -def send_role_changed(to: str, role: str, operator: str | None) -> bool: - line = ( - "You've been granted admin access on Siftlode — you can now manage settings, the " - "scheduler and users." - if role == "admin" - else "Your admin access on Siftlode has been removed — you're now a regular user." - ) - contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else "" - body = "Hi,\n\n" + line + contact + "\n\n— Siftlode" - return _send([to], "Your Siftlode role has changed", body, reply_to=operator) +def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool: + contact = mailtext.text("contact.operator", lang, operator=operator) if operator else "" + body = mailtext.text("account_unsuspended.body", lang, contact=contact) + return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator) -def send_account_unsuspended(to: str, operator: str | None) -> bool: - contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else "" - body = ( - "Hi,\n\n" - "Your Siftlode account has been reinstated — the suspension was lifted and you can sign " - "in again." + contact + "\n\n— Siftlode" - ) - return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator) +def send_account_deleted(to: str, lang: str, operator: str | None) -> bool: + contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else "" + body = mailtext.text("account_deleted.body", lang, contact=contact) + return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator) -def send_account_deleted(to: str, operator: str | None) -> bool: +def send_account_suspended(to: str, lang: str, operator: str | None) -> bool: contact = ( - f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}." + mailtext.text("contact.suspended", lang, operator=operator) if operator - else "" + else mailtext.text("contact.suspended_unknown", lang) ) - body = ( - "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" + body = mailtext.text("account_suspended.body", lang, contact=contact) + return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator) + + +def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool: + """Tell the admins someone is waiting. One call per language group (see + `routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a + single blast to all of them would have to pick one and be wrong for the rest. + + The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The + requester address is spelled out (mail clients auto-link both the URL and the address), and the + reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the + sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.) + """ + body = mailtext.text( + "admin_new_request.body", + lang, + requester=requester, + link=f"{settings.app_base}/?admin=access-requests", ) - return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator) + subject = mailtext.text("admin_new_request.subject", lang, requester=requester) + return _send(admins, subject, body, reply_to=requester) -def send_account_suspended(to: str, operator: str | None) -> bool: - contact = ( - f"If you think this is a mistake or have questions, contact the Siftlode operator " - f"at {operator}.\n\n" - if operator - else "If you think this is a mistake, please contact the Siftlode operator.\n\n" - ) - body = ( - "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" - ) - return _send([to], "Your Siftlode account is suspended", body, reply_to=operator) +def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool: + # The body says what the link's rules are. The reset mail always has; this one left the reader to + # discover both by meeting a dead link, which is precisely the silence this epic removes. + body = mailtext.text("verify_email.body", lang, link=link, hours=hours) + return _send([to], mailtext.text("verify_email.subject", lang), body) -def send_admin_new_request(admins: list[str], requester: str) -> bool: - # The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The - # requester address is spelled out (mail clients auto-link both the URL and the address), and the - # reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the - # sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.) - approve_url = f"{settings.app_base}/?admin=access-requests" - body = ( - f"{requester} just requested access to Siftlode.\n\n" - f"Approve or deny it here:\n{approve_url}\n" - f"(or in Siftlode: open Users → Access requests)\n\n" - f"Requester's email: {requester}\n" - f"Replying to this message goes straight to the requester (it's the Reply-To address).\n" - ) - return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester) +def send_password_reset(to: str, lang: str, link: str) -> bool: + body = mailtext.text("password_reset.body", lang, link=link) + return _send([to], mailtext.text("password_reset.subject", lang), body) -def send_verify_email(to: str, link: str, hours: int = 24) -> bool: - body = ( - "Hi,\n\n" - "Confirm your email to finish creating your Siftlode account:\n\n" - f"{link}\n\n" - # Say what the link's rules are. The reset mail always has; this one left the reader to - # discover both by meeting a dead link, which is precisely the silence this epic removes. - f"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" - ) - return _send([to], "Confirm your Siftlode email", body) - - -def send_password_reset(to: str, link: str) -> bool: - body = ( - "Hi,\n\n" - "Use this link to set a new Siftlode password (it expires in an hour):\n\n" - f"{link}\n\n" - "If you didn't request this, you can ignore this message — your password is unchanged.\n\n" - "— Siftlode" - ) - return _send([to], "Reset your Siftlode password", body) - - -def send_test(to: str) -> bool: - body = ( - "This is a test email from Siftlode.\n\n" - "If you're reading this, your SMTP settings work.\n\n" - "— Siftlode" - ) - return _send([to], "Siftlode SMTP test", body) +def send_test(to: str, lang: str) -> bool: + return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang)) diff --git a/backend/app/mailtext.py b/backend/app/mailtext.py new file mode 100644 index 00000000..5573c991 --- /dev/null +++ b/backend/app/mailtext.py @@ -0,0 +1,359 @@ +"""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" + ), + }, +} diff --git a/backend/app/models.py b/backend/app/models.py index 2b3e5fca..8d16706f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -167,14 +167,17 @@ class Invite(Base, CanonicalEmailMixin): ) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email - # When the admin last SENT this person an invitation from the whitelist row. Adding an - # address stays silent (it opens a door, it does not knock), so telling them is a separate - # act — and this is what stops it happening twice to a real person. - invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # When the admin last SENT this person an invitation from the whitelist row. Adding an address # stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate # act, and this is what stops it happening twice to a real person. invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # The language this person asked us in ("en"/"hu"), captured when the request was made: the page + # their form was on, or the locale Google reported for a refused sign-in. NULL for a row the + # admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly + # why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason + # `preferences.language` and `google_email` are: the signal exists only in that one moment and + # cannot be backfilled afterwards. + lang: Mapped[str | None] = mapped_column(String(8)) class DemoWhitelist(Base, TimestampMixin): diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index da807d67..b10a90aa 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from app import audit from app import email as email_mod +from app import mailtext from app.audit import AuditAction from app.auth import ( admin_user, @@ -81,6 +82,10 @@ def _serialize(inv: Invite, *, has_account: bool = False) -> dict: # …and whether removing it would actually take access away, or leave the env allow-list # granting it anyway (for an ADMIN_EMAILS address, as an admin). "still_allowed_by_env": _env_allowed(inv.email), + # The language this person asked us in, or null when nobody ever told us (a hand-added + # whitelist row). The invitation dialog preselects it, and falls back to the admin's own UI + # language — a guess, but a visible one the admin can correct before sending. + "lang": inv.lang, } @@ -192,7 +197,8 @@ def approve_invite( # the task must not outlive it. Approval answers a request the person MADE, so unlike the # invitation below it is sent automatically — a reply is owed, not a nudge. link, has_account, hours = _welcome_mail_parts(db, inv.email) - background.add_task(email_mod.send_access_approved, inv.email, link, has_account, hours) + lang = mailtext.lang_for_email(db, inv.email) + background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours) return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) @@ -206,7 +212,9 @@ def deny_invite( # Symmetry with approve: the requester hears back either way, instead of a silent limbo. No # account is activated, so this is an email only (the requester has no in-app inbox yet). inv = _decide(db, invite_id, admin, approved=False) - background.add_task(email_mod.send_access_denied, inv.email) + background.add_task( + email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email) + ) return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) @@ -244,10 +252,18 @@ def add_invite( return _serialize(inv, has_account=bool(_accounts_for(db, [email]))) +class InviteSendIn(BaseModel): + # Which language to write this person in. Optional so an existing caller (and a test) can post + # an empty body, but the admin UI always sends it: this is the one recipient the app has no + # signal about, because the admin typed the address in themselves. + lang: str | None = None + + @router.post("/invites/{invite_id}/invite") def send_invitation( invite_id: int, background: BackgroundTasks, + body: InviteSendIn | None = None, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: @@ -273,6 +289,14 @@ def send_invitation( # never sent. raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"}) link, has_account, hours = _welcome_mail_parts(db, inv.email) + # An explicit pick from the dialog is recorded, so a later mail to this address matches the one + # the admin already sent. Only the explicit pick — writing the fallback back would turn a column + # that means "what this person told us" into "what we last guessed". + picked = body.lang if body else None + if picked in mailtext.SUPPORTED: + lang = inv.lang = picked + else: + lang = mailtext.lang_for_email(db, inv.email) inv.invited_at = datetime.now(timezone.utc) audit.record( db, @@ -283,7 +307,7 @@ def send_invitation( summary=inv.email, ) db.commit() - background.add_task(email_mod.send_access_invitation, inv.email, link, has_account, hours) + background.add_task(email_mod.send_access_invitation, inv.email, lang, link, has_account, hours) return _serialize(inv, has_account=has_account) @@ -400,7 +424,13 @@ def set_user_role( target.role = role db.commit() if changed: - background.add_task(email_mod.send_role_changed, target.email, role, operator_contact()) + background.add_task( + email_mod.send_role_changed, + target.email, + mailtext.lang_for_user(target), + role, + operator_contact(), + ) return _serialize_user(target) @@ -449,7 +479,12 @@ def set_user_suspended( target.is_suspended = suspended db.commit() if was_suspended and not suspended: - background.add_task(email_mod.send_account_unsuspended, target.email, operator_contact()) + background.add_task( + email_mod.send_account_unsuspended, + target.email, + mailtext.lang_for_user(target), + operator_contact(), + ) return _serialize_user(target) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 85234d01..712a20ef 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -18,6 +18,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app import email as email_mod +from app import mailtext from app import sysconfig from app.auth import ( _YOUTUBE_SCOPES, @@ -63,12 +64,20 @@ _request_access_limiter = RateLimiter(max_events=5, window_seconds=300) _suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600) -def _notify_suspended(background: BackgroundTasks, email: str) -> None: +def _notify_suspended(background: BackgroundTasks, user: User) -> None: """Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid) sign-in was just blocked. Gated by verified credentials at the call site, so this can't be - triggered by someone who doesn't control the account.""" - if _suspend_email_limiter.allow(email): - background.add_task(email_mod.send_account_suspended, email, operator_contact()) + triggered by someone who doesn't control the account. + + Takes the user rather than the address because the mail needs their language, and the send runs + as a background task — after this request's session, and the row, are out of reach.""" + if _suspend_email_limiter.allow(user.email): + background.add_task( + email_mod.send_account_suspended, + user.email, + mailtext.lang_for_user(user), + operator_contact(), + ) _demo_limiter = RateLimiter(max_events=5, window_seconds=60) @@ -101,18 +110,43 @@ def google_oauth(db: Session): return _oauth.google -def admin_notify_emails(db: Session) -> list[str]: - """Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active, - non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB - roles means an admin promoted through the UI is notified too (env alone would miss them, yet they - CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union - keeps a freshly-seeded instance working before any DB admin exists.""" - db_admins = db.execute( - select(User.email).where( +def admin_notify_groups(db: Session) -> dict[str, list[str]]: + """Who to email about admin events (e.g. a new access request), grouped by the language each of + them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the + env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is + notified too (env alone would miss them, yet they CAN approve requests — the approve UI is + role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working + before any DB admin exists. + + Grouped rather than one blast to everyone, because these recipients are people with their own UI + language: a single message would have to pick one of them and be wrong for the rest. An env + address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose + account is (say) suspended is still written to in the language they chose.""" + rows = db.execute( + select(User.email, User.preferences).where( User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False) ) - ).scalars() - return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set) + ).all() + by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e} + for email in settings.admin_email_set: + if email not in by_email: + by_email[email] = mailtext.lang_for_email(db, email) + groups: dict[str, list[str]] = {} + for email in sorted(by_email): + groups.setdefault(by_email[email], []).append(email) + return groups + + +def notify_admins_of_request( + db: Session, background: BackgroundTasks | None, requester: str +) -> None: + """Tell every admin that someone is waiting — one mail per language group. Sends inline when + there is no BackgroundTasks to hand (the registration path already runs outside a response).""" + for lang, recipients in admin_notify_groups(db).items(): + if background is None: + email_mod.send_admin_new_request(recipients, lang, requester) + else: + background.add_task(email_mod.send_admin_new_request, recipients, lang, requester) def is_demo_allowed(db: Session, email: str) -> bool: @@ -172,23 +206,36 @@ class InviteRequest(NamedTuple): is_new: bool -def upsert_pending_invite(db: Session, email: str) -> InviteRequest: - """Idempotently record an access request. +def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest: + """Idempotently record an access request, and the language it was made in. "Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one Gmail mailbox, and treating them as two is what put a second, silent access request in the admin queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must not reopen a decision that has already been made for that mailbox. The row is still STORED under the address as typed, because that is where mail has to go. + + `lang` is the language of the surface the request came from — the sign-in page, or the locale + Google reported for a refused sign-in. The latest one wins: someone who asks again from a + Hungarian page is telling us something newer than last time. Anything unsupported is ignored + rather than stored, so a forged body cannot put junk in the column. + + The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely + on that — `_register_account` persists its own flushed user precisely because a no-op here + commits nothing (E9.5 S6). """ email = (email or "").lower() if not valid_email(email): return InviteRequest(None, False) + lang = lang if lang in mailtext.SUPPORTED else None canon = canonical_email(email) rows = db.execute(select(Invite).where(Invite.email_canonical == canon)).scalars().all() # Prefer a decided row over a pending one, so a denied duplicate can't mask an approval. inv = next((r for r in rows if r.status == "approved"), None) or next(iter(rows), None) if inv is not None: + relearned = lang is not None and inv.lang != lang + if relearned: + inv.lang = lang # Let a previously-denied person ask again; don't disturb approved/pending rows. if inv.status == "denied": inv.status = "pending" @@ -197,8 +244,10 @@ def upsert_pending_invite(db: Session, email: str) -> InviteRequest: inv.decided_by = None db.commit() return InviteRequest(inv, True) + if relearned: + db.commit() return InviteRequest(inv, False) - inv = Invite(email=email, status="pending") + inv = Invite(email=email, status="pending", lang=lang) db.add(inv) db.commit() return InviteRequest(inv, True) @@ -348,10 +397,11 @@ async def callback( # A denied Google login doubles as an access request: record it for the admin and # land the user on a friendly "request received" screen instead of a raw 403. if email: - req = upsert_pending_invite(db, email) - admins = admin_notify_emails(db) - if req.is_new and admins: - background.add_task(email_mod.send_admin_new_request, admins, email) + # Google just told us this person's locale — the only language signal that will ever + # exist for an address with no account, and the decision mail is written from it. + req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0]) + if req.is_new: + notify_admins_of_request(db, background, email) return RedirectResponse(url=_access_notice_url("requested", email, req.invite)) return RedirectResponse(url=_access_notice_url("denied")) @@ -403,7 +453,7 @@ async def callback( # tell the (verified) owner why — a successful Google auth proves they control this account. if user.is_suspended: log.info("Suspended account blocked at Google login: %s", email) - _notify_suspended(background, user.email) + _notify_suspended(background, user) return RedirectResponse(url="/?login=suspended") # Record what Google actually answers with, beside the address the user registered with. The two # are the same string for most people; where they differ (a deliberate `+tag`, a different dot @@ -569,9 +619,17 @@ async def logout(request: Request): return JSONResponse({"ok": True, "switched": False}) +class AccessRequestIn(EmailIn): + # The language of the page this request was made from. Same reasoning as registration's `lang` + # (and the same shape): it is the ONLY moment this preference exists anywhere the server can + # see, since the requester has no account to carry it, and it decides what language the approval + # or denial mail is written in. + lang: str | None = None + + @router.post("/request-access") async def request_access( - body: EmailIn, + body: AccessRequestIn, request: Request, background: BackgroundTasks, db: Session = Depends(get_db), @@ -592,10 +650,9 @@ async def request_access( return {"status": "pending"} if is_allowed(db, email): return {"status": "pending"} # already allowed — don't reveal it - req = upsert_pending_invite(db, email) - admins = admin_notify_emails(db) - if req.is_new and admins: - background.add_task(email_mod.send_admin_new_request, admins, email) + req = upsert_pending_invite(db, email, body.lang) + if req.is_new: + notify_admins_of_request(db, background, email) return {"status": "pending"} @@ -792,14 +849,12 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non db.commit() if email_ok: send_verification_link(db, user) - admins = admin_notify_emails(db) # Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for # an address the admin already approved, nothing lands in the Access-requests queue, so this # is the only place the account's existence is announced at all. Delivery is a separate # matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant # still needs the admin to notice them (roadmap UX-25). - if admins: - email_mod.send_admin_new_request(admins, email) + notify_admins_of_request(db, None, email) def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str: @@ -825,6 +880,7 @@ def send_verification_link(db: Session, user: User) -> None: """ email_mod.send_verify_email( user.email, + mailtext.lang_for_user(user), issue_verification_link(db, user), hours=int(VERIFY_TTL.total_seconds() // 3600), ) @@ -937,7 +993,7 @@ def password_login( if user is None or user.is_demo or not password_ok: raise _login_refused(401, "invalid") if user.is_suspended: - _notify_suspended(background, user.email) + _notify_suspended(background, user) raise _login_refused(403, "suspended", user, operator=operator_contact() or None) if not user.email_verified: raise _login_refused(403, "email_unverified", user) @@ -985,7 +1041,9 @@ def _send_reset_if_eligible(email: str) -> None: if user.password_hash or user.password_revoked_at: raw = _issue_token(db, user, "reset", RESET_TTL) # The account's own address, as above — never the spelling that was typed into the form. - email_mod.send_password_reset(user.email, f"{_app_base()}/#reset={raw}") + email_mod.send_password_reset( + user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}" + ) @router.post("/password-reset/check") diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index 2086ae54..59f2afd3 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from app import audit from app import email as email_mod +from app import mailtext from app import sysconfig from app.audit import AuditAction from app.auth import admin_user @@ -115,7 +116,7 @@ def send_test_email( lets the admin verify the settings actually work.""" if not email_mod.email_enabled(): raise HTTPException(status_code=400, detail="SMTP is not configured.") - sent = email_mod.send_test(user.email) + sent = email_mod.send_test(user.email, mailtext.lang_for_user(user)) if not sent: raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.") return {"sent": True, "to": user.email} diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py index a033629d..1c1b5b58 100644 --- a/backend/app/routes/setup.py +++ b/backend/app/routes/setup.py @@ -14,6 +14,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app import email as email_mod +from app import mailtext from app import state, sysconfig from app.auth import PASSWORD_MIN_LEN from app.db import get_db @@ -103,6 +104,9 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set class SetupTestEmailIn(BaseModel): to: str = "" + # The wizard's page language. No account exists yet on a fresh instance, so this is the only + # thing that can say what language to write in; anything unsupported falls back to English. + lang: str | None = None @router.post("/test-email") @@ -111,7 +115,7 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup to = body.to.strip() if "@" not in to: raise HTTPException(status_code=400, detail="Enter a valid email address.") - if not email_mod.send_test(to): + if not email_mod.send_test(to, mailtext.normalize(body.lang)): raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.") return {"ok": True} diff --git a/backend/tests/test_admin_pending_access.py b/backend/tests/test_admin_pending_access.py index 3f286657..d337050a 100644 --- a/backend/tests/test_admin_pending_access.py +++ b/backend/tests/test_admin_pending_access.py @@ -128,7 +128,7 @@ def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link( monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True) sent: list[tuple[str, str]] = [] monkeypatch.setattr( - auth_routes.email_mod, "send_verify_email", lambda to, link, **kw: sent.append((to, link)) + auth_routes.email_mod, "send_verify_email", lambda to, lang, link, **kw: sent.append((to, link)) ) admin = make_user(email="admin@example.com", role="admin") # Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index 68a42286..b2c47cc4 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -200,7 +200,7 @@ def _no_smtp(monkeypatch): notified: list[str] = [] monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: False) monkeypatch.setattr( - auth_mod.email_mod, "send_admin_new_request", lambda admins, to: notified.append(to) or True + auth_mod.email_mod, "send_admin_new_request", lambda admins, lang, to: notified.append(to) or True ) return notified @@ -321,9 +321,9 @@ def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db, sent: list[str] = [] monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True) monkeypatch.setattr( - auth_mod.email_mod, "send_verify_email", lambda to, link, **kw: sent.append(to) + auth_mod.email_mod, "send_verify_email", lambda to, lang, link, **kw: sent.append(to) ) - monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, to: True) + monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, lang, to: True) db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com")) db.commit() @@ -422,7 +422,7 @@ def _capture_reset_links(monkeypatch) -> list[str]: from app.routes import auth as auth_mod sent: list[str] = [] - monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, link: sent.append(to)) + monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)) return sent diff --git a/backend/tests/test_email_alerts.py b/backend/tests/test_email_alerts.py index f8b66c94..0cb1679c 100644 --- a/backend/tests/test_email_alerts.py +++ b/backend/tests/test_email_alerts.py @@ -42,7 +42,7 @@ def test_a_skipped_email_notifies_every_admin(db, make_user): admin_b = make_user(email="admin-b@example.com", role="admin") plain = make_user(email="plain@example.com") - assert email_mod.send_test("someone@example.com") is False # fail-soft, as before + assert email_mod.send_test("someone@example.com", "en") is False # fail-soft, as before db.expire_all() for admin in (admin_a, admin_b): @@ -57,7 +57,7 @@ def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make admin = make_user(email="admin@example.com", role="admin") for _ in range(5): - email_mod.send_test("someone@example.com") + email_mod.send_test("someone@example.com", "en") db.expire_all() assert len(_notifications(db, admin.id)) == 1 @@ -84,7 +84,7 @@ def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_us monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom) - assert email_mod.send_test("someone@example.com") is False + assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() (n,) = _notifications(db, admin.id) @@ -96,7 +96,7 @@ def test_no_admins_means_no_notification_and_no_error(db, make_user): # rather than blowing up inside the fail-soft path it is supposed to make visible. plain = make_user(email="plain@example.com") - assert email_mod.send_test("someone@example.com") is False + assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() assert _notifications(db, plain.id) == [] @@ -106,10 +106,10 @@ def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user): # The throttle window must only be spent on a notice that was actually written. Otherwise a drop # during setup — before any admin exists — silences the next hour for the admin created a minute # later, which is exactly the blind spot this alert exists to remove. - email_mod.send_test("someone@example.com") # no admins yet + email_mod.send_test("someone@example.com", "en") # no admins yet admin = make_user(email="admin@example.com", role="admin") - assert email_mod.send_test("someone@example.com") is False + assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() assert len(_notifications(db, admin.id)) == 1 @@ -127,10 +127,10 @@ def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch return real(*a, **kw) monkeypatch.setattr(email_mod, "create_notification", _maybe_boom) - email_mod.send_test("someone@example.com") # the write blows up; nothing recorded + email_mod.send_test("someone@example.com", "en") # the write blows up; nothing recorded boom["on"] = False - assert email_mod.send_test("someone@example.com") is False + assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() assert len(_notifications(db, admin.id)) == 1 @@ -146,4 +146,4 @@ def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch): monkeypatch.setattr(email_mod, "create_notification", _boom) - assert email_mod.send_test("someone@example.com") is False + assert email_mod.send_test("someone@example.com", "en") is False diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py index f0934f0f..144d20e1 100644 --- a/backend/tests/test_email_canonical.py +++ b/backend/tests/test_email_canonical.py @@ -166,7 +166,7 @@ def _capture_reset_links(monkeypatch) -> list[str]: from app.routes import auth as auth_mod sent: list[str] = [] - monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, link: sent.append(to)) + monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)) return sent diff --git a/backend/tests/test_email_html_part.py b/backend/tests/test_email_html_part.py index 91f4ae85..dfe75479 100644 --- a/backend/tests/test_email_html_part.py +++ b/backend/tests/test_email_html_part.py @@ -103,7 +103,9 @@ def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch): monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP) assert ( - email_mod.send_verify_email("someone@example.com", "https://siftlode.example/#verify=tok") + email_mod.send_verify_email( + "someone@example.com", "en", "https://siftlode.example/#verify=tok" + ) is True ) diff --git a/backend/tests/test_mail_language.py b/backend/tests/test_mail_language.py new file mode 100644 index 00000000..0f60ae86 --- /dev/null +++ b/backend/tests/test_mail_language.py @@ -0,0 +1,263 @@ +"""E9.5 S7 — every outbound mail is written in the reader's language. + +Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced +here because 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. The second is the resolution chain: which +language a given recipient actually gets, and where that signal was captured, since for an address +with no account it exists only at the moment they asked us. +""" + +import string + +import pytest +from sqlalchemy import select + +from app import mailtext +from app.models import Invite, User +from app.routes import auth as auth_routes + +_PW = "correct horse battery" + + +def _placeholders(template: str) -> set[str]: + return {name for _, name, _, _ in string.Formatter().parse(template) if name} + + +# --- the catalog itself --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT)) +def test_every_mail_string_exists_in_both_languages(key): + assert set(mailtext.MAIL_TEXT[key]) == set(mailtext.SUPPORTED) + + +@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT)) +def test_every_translation_takes_the_same_placeholders(key): + # The parity that actually bites: a translation that drops `{link}` renders a mail with no link + # in it, and a translation that invents one raises KeyError inside a background task nobody is + # watching. Both are silent in production and loud here. + variants = mailtext.MAIL_TEXT[key] + expected = _placeholders(variants[mailtext.DEFAULT]) + for lang, template in variants.items(): + assert _placeholders(template) == expected, f"{key} [{lang}]" + + +def test_no_catalog_entry_is_orphaned(): + """Every key is referenced from the app, so a mail that stopped being sent can't leave its text + behind to be 'kept up to date' by the next person who greps for it.""" + from pathlib import Path + + app_dir = Path(__file__).resolve().parents[1] / "app" + sources = "\n".join(p.read_text(encoding="utf-8") for p in app_dir.rglob("*.py")) + unused = [k for k in mailtext.MAIL_TEXT if f'"{k}"' not in sources] + assert unused == [] + + +def test_an_unknown_language_falls_back_to_english_instead_of_raising(): + # `preferences.language` is free-form JSON written by older builds and by locales we may stop + # shipping. A send that raised on one would take out a background task with no user in sight. + assert mailtext.text("test.subject", "de") == mailtext.text("test.subject", "en") + assert mailtext.text("test.subject", None) == mailtext.text("test.subject", "en") + + +# --- who gets which language ---------------------------------------------------------------------- + + +def test_a_users_own_preference_decides_their_language(make_user): + hu = make_user(email="hu@example.com", preferences={"language": "hu"}) + en = make_user(email="en@example.com", preferences={"language": "en"}) + none = make_user(email="none@example.com", preferences=None) + + assert mailtext.lang_for_user(hu) == "hu" + assert mailtext.lang_for_user(en) == "en" + assert mailtext.lang_for_user(none) == "en" + + +def test_an_address_with_no_account_falls_back_to_the_language_it_asked_in(db): + db.add(Invite(email="asked@example.com", status="pending", lang="hu")) + db.commit() + + assert mailtext.lang_for_email(db, "asked@example.com") == "hu" + + +def test_the_account_wins_over_the_invite_row(db, make_user): + make_user(email="both@example.com", preferences={"language": "en"}) + db.add(Invite(email="both@example.com", status="approved", lang="hu")) + db.commit() + + # The invite records how they asked once; the account records what they have since chosen. + assert mailtext.lang_for_email(db, "both@example.com") == "en" + + +def test_a_row_that_never_recorded_a_language_is_walked_past(db, make_user): + # Prod carries TWO accounts for one Gmail mailbox (the 2026-09-03 pair). Stopping at the first + # row would answer English for a person whose other account says Hungarian. + make_user(email="dup.one@gmail.com", preferences={}) + make_user(email="dupone@gmail.com", preferences={"language": "hu"}) + + assert mailtext.lang_for_email(db, "dup.one@gmail.com") == "hu" + + +def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user): + make_user(email="canon.match@gmail.com", preferences={"language": "hu"}) + + assert mailtext.lang_for_email(db, "canonmatch+tag@gmail.com") == "hu" + + +# --- capturing the signal at the one moment it exists --------------------------------------------- + + +def test_registration_records_the_language_of_the_sign_up_page(db, client): + r = client.post( + "/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"} + ) + + assert r.status_code == 200 + user = db.execute(select(User).where(User.email == "signup@example.com")).scalar_one() + assert (user.preferences or {}).get("language") == "hu" + + +def test_an_access_request_records_the_language_it_was_made_in(db, client): + assert ( + client.post( + "/auth/request-access", json={"email": "asking@example.com", "lang": "hu"} + ).status_code + == 200 + ) + + inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() + assert inv.lang == "hu" + + +def test_asking_again_from_another_language_updates_the_row(db, client): + client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"}) + client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}) + + db.expire_all() + inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() + # The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert). + assert inv.lang == "hu" + assert db.execute(select(Invite)).scalars().all() == [inv] + + +def test_an_unsupported_language_is_ignored_rather_than_stored(db, client): + client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"}) + + inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() + assert inv.lang is None + + +# --- the mails themselves ------------------------------------------------------------------------- + + +def _bodies(monkeypatch): + """Capture what `_send` would have put on the wire, rather than the arguments a caller passed — + the language only matters if it reaches the body.""" + from app import email as email_mod + + out: list[tuple[list[str], str, str]] = [] + monkeypatch.setattr( + email_mod, "_send", lambda to, subject, body, reply_to=None: out.append((to, subject, body)) + ) + return out + + +def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, monkeypatch): + from app import email as email_mod + + monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) + sent = _bodies(monkeypatch) + + client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"}) + + (_, subject, body) = sent[0] + assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"] + assert "#verify=" in body + + +def test_the_approval_mail_answers_in_the_language_the_request_was_made_in( + db, client, make_user, as_user, monkeypatch +): + from app import email as email_mod + + monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) + sent = _bodies(monkeypatch) + admin = make_user(email="admin@example.com", role="admin") + client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}) + inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() + sent.clear() # drop the admin notification; this test is about the reply to the requester + + r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve") + + assert r.status_code == 200 + (to, subject, _) = sent[0] + assert to == ["asking@example.com"] + assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"] + + +def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch): + from app import email as email_mod + + monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) + sent = _bodies(monkeypatch) + admin = make_user(email="admin@example.com", role="admin") + client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}) + inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() + sent.clear() + + as_user(admin).post(f"/api/admin/invites/{inv.id}/deny") + + assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"] + + +def test_the_admins_pick_decides_an_invitation_and_is_remembered( + db, make_user, as_user, monkeypatch +): + from app import email as email_mod + + monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) + sent = _bodies(monkeypatch) + admin = make_user(email="admin@example.com", role="admin") + # Hand-whitelisted: nobody has ever told us anything about this mailbox, which is exactly why + # the send button asks. + inv = Invite(email="stranger@example.com", status="approved") + db.add(inv) + db.commit() + + r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "hu"}) + + assert r.status_code == 200 + assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["hu"] + db.expire_all() + # Recorded, so a later mail to this address matches the one the admin already sent. + assert db.get(Invite, inv.id).lang == "hu" + + +def test_an_invitation_without_a_pick_still_goes_out(db, make_user, as_user, monkeypatch): + from app import email as email_mod + + monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) + sent = _bodies(monkeypatch) + admin = make_user(email="admin@example.com", role="admin") + inv = Invite(email="stranger@example.com", status="approved") + db.add(inv) + db.commit() + + r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite") + + assert r.status_code == 200 + assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["en"] + db.expire_all() + # Nothing was picked, so nothing is remembered: the column means "what this person told us", + # not "what we last defaulted to". + assert db.get(Invite, inv.id).lang is None + + +def test_admins_are_notified_in_their_own_languages(db, make_user): + make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"}) + make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"}) + + groups = auth_routes.admin_notify_groups(db) + + # One mail per language, not one blast that has to be wrong for somebody. + assert groups == {"hu": ["hu.admin@example.com"], "en": ["en.admin@example.com"]} diff --git a/backend/tests/test_welcome_mail.py b/backend/tests/test_welcome_mail.py index ca5af938..2984cb1e 100644 --- a/backend/tests/test_welcome_mail.py +++ b/backend/tests/test_welcome_mail.py @@ -55,7 +55,7 @@ def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail( _approve(as_user(admin), inv.id) - (email, link, has_account, hours), _ = sent[0] + (email, _lang, link, has_account, hours), _ = sent[0] assert email == "waiting@example.com" and has_account is True assert link and "#verify=" in link # The whole point: this is the ONLY message they now have to open. @@ -73,7 +73,7 @@ def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_u email="waiting@example.com", password=_PW, email_verified=False, is_active=False ) _approve(as_user(admin), inv.id) - token = sent[0][0][1].split("#verify=")[1] + token = sent[0][0][2].split("#verify=")[1] r = as_user(admin).post("/auth/verify", json={"token": token}) @@ -112,7 +112,7 @@ def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_ _approve(as_user(admin), inv.id) - (_, link, has_account, _), _ = sent[0] + (_, _lang, link, has_account, _), _ = sent[0] assert link is None and has_account is True # no link to offer, and none needed @@ -128,7 +128,7 @@ def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming( _approve(as_user(admin), inv.id) - (_, link, has_account, _), _ = sent[0] + (_, _lang, link, has_account, _), _ = sent[0] # Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE # noticeable — the failure that went unnoticed on 2026-09-03. assert link is None and has_account is False diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index d02c0fc1..38a7aa68 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -28,6 +28,8 @@ import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import Tooltip from "./Tooltip"; import InlineNotice from "./InlineNotice"; import { Section } from "./ui/form"; +import SegmentedToggle from "./SegmentedToggle"; +import { LANGUAGES, type LangCode } from "../i18n"; import { LoadingState, StateMessage } from "./QueryState"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./Tabs"; @@ -458,10 +460,39 @@ function PendingAccessRowView({ ); } +/** The mail language for one invitation. Owns its own display state and reports every change up + * through `onPick`, because the dialog that hosts it resolves to a plain boolean — the choice has + * to live in the caller's closure, not in this component. Endonyms come from the app's own + * language list, so a third locale would appear here for free. */ +function InviteLangPicker({ + initial, + onPick, +}: { + initial: LangCode; + onPick: (lang: LangCode) => void; +}) { + const { t } = useTranslation(); + const [lang, setLang] = useState(initial); + return ( + ({ value: l.code, label: l.label }))} + value={lang} + onChange={(v) => { + setLang(v); + onPick(v); + }} + /> + ); +} + // --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account ------- function AdminInvites() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const me = useMe(); @@ -540,10 +571,10 @@ function AdminInvites() { // nudging someone who never asked. `invited_at` comes back on the row so a second click is a // decision rather than an accident. const invite = useMutation({ - mutationFn: (i: Invite) => api.sendInvitation(i.id), - onSuccess: (_r, i) => { + mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang), + onSuccess: (_r, v) => { refresh(); - notify({ level: "success", message: t("settings.invites.invited", { email: i.email }) }); + notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) }); }, onError: (err) => notify({ @@ -554,6 +585,33 @@ function AdminInvites() { : t("settings.invites.inviteFailed"), }), }); + // The one recipient the app has no language signal about: the admin typed this address in, so + // nobody ever told us how to write to its owner. The dialog is where that gets decided — and it + // is also where the "you already wrote to them" warning belongs, next to the button that would + // do it a second time, rather than only in a tooltip the admin may never hover. + const onInvite = async (inv: Invite) => { + let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang) + ? (inv.lang as LangCode) + : ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode); + const ok = await confirm({ + title: t("settings.invites.inviteTitle"), + message: ( + <> +

+ {t( + inv.invited_at + ? "settings.invites.inviteConfirmAgain" + : "settings.invites.inviteConfirm", + { email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() }, + )} +

+ (lang = v)} /> + + ), + confirmLabel: t("settings.invites.inviteSend"), + }); + if (ok) invite.mutate({ inv, lang }); + }; const add = useMutation({ mutationFn: (email: string) => api.addInvite(email), @@ -647,7 +705,7 @@ function AdminInvites() { } >