From 10c0ca4cf73d396aefddeeaced042d616c5c34a2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 4 Sep 2026 20:29:33 +0200 Subject: [PATCH] feat(email): write to people in their own language (E9.5 S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5. A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives once, in whatever language we picked, and stays that way. That made the epic's own headline mail — "one mail, one click" — English for a Hungarian reader. The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity rule is now a test rather than a habit: every key must carry both languages AND the same `{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents a placeholder raises inside a background task nobody is watching. Both are silent in production. Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never inside the mail — these all run as background tasks, after the request's session is gone, which is the same reason the approval mail computes its link in the handler. Where the language comes from, in order: the account's own preference; failing that, the language the person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` — because the mails an access request produces go to an address that usually has no account yet, and the signal does exist: the page their form was submitted from, or the locale Google reported for a sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address they typed in themselves, where nobody has ever told us anything. That send is now a dialog rather than a bare click, which is also where the "you already wrote to them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one action whose failure mode is mailing a real person twice. S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the frontend never sent it, so every account has been storing nothing. The request builders now stamp the UI language themselves — a signal that has to be passed in by every caller is one every caller can forget, which is exactly how it shipped unset. Admins are notified one mail per language group instead of one blast to everyone. They are people with their own UI language; a single message would have to pick one of them and be wrong for the rest. Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions landed in S6). --- backend/alembic/versions/0082_invite_lang.py | 34 ++ backend/app/auth.py | 6 +- backend/app/email.py | 225 +++++------ backend/app/mailtext.py | 359 ++++++++++++++++++ backend/app/models.py | 11 +- backend/app/routes/admin.py | 45 ++- backend/app/routes/auth.py | 122 ++++-- backend/app/routes/config.py | 3 +- backend/app/routes/setup.py | 6 +- backend/tests/test_admin_pending_access.py | 2 +- backend/tests/test_auth_session.py | 8 +- backend/tests/test_email_alerts.py | 18 +- backend/tests/test_email_canonical.py | 2 +- backend/tests/test_email_html_part.py | 4 +- backend/tests/test_mail_language.py | 263 +++++++++++++ backend/tests/test_welcome_mail.py | 8 +- frontend/src/components/AdminUsers.tsx | 68 +++- .../src/components/adminInvites.dom.test.tsx | 65 +++- frontend/src/i18n/locales/en/settings.json | 5 + frontend/src/i18n/locales/hu/settings.json | 5 + frontend/src/lib/api/admin.ts | 12 +- frontend/src/lib/api/auth.ts | 14 +- frontend/src/lib/api/core.ts | 6 + frontend/src/lib/api/setup.ts | 8 +- 24 files changed, 1088 insertions(+), 211 deletions(-) create mode 100644 backend/alembic/versions/0082_invite_lang.py create mode 100644 backend/app/mailtext.py create mode 100644 backend/tests/test_mail_language.py 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() { } >