feat(email): write to people in their own language (E9.5 S7)

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).
This commit is contained in:
2026-09-04 20:29:33 +02:00
parent a76bab9b34
commit 10c0ca4cf7
24 changed files with 1088 additions and 211 deletions
@@ -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")
+5 -1
View File
@@ -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"
+92 -131
View File
@@ -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_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_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
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.
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)
body = mailtext.text("verify_email.body", lang, link=link, hours=hours)
return _send([to], mailtext.text("verify_email.subject", lang), 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_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_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))
+359
View File
@@ -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"
),
},
}
+7 -4
View File
@@ -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):
+40 -5
View File
@@ -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)
+90 -32
View File
@@ -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")
+2 -1
View File
@@ -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}
+5 -1
View File
@@ -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}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+9 -9
View File
@@ -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
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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
)
+263
View File
@@ -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"]}
+4 -4
View File
@@ -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
+63 -5
View File
@@ -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<LangCode>(initial);
return (
<SegmentedToggle
className="mt-3"
size="md"
ariaLabel={t("settings.invites.inviteLang")}
label={t("settings.invites.inviteLang")}
options={LANGUAGES.map((l) => ({ 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: (
<>
<p>
{t(
inv.invited_at
? "settings.invites.inviteConfirmAgain"
: "settings.invites.inviteConfirm",
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
)}
</p>
<InviteLangPicker initial={lang} onPick={(v) => (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() {
}
>
<button
onClick={() => invite.mutate(i)}
onClick={() => onInvite(i)}
disabled={invite.isPending}
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
i.invited_at
@@ -24,7 +24,12 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
type ConfirmOpts = { title?: string; message?: string; confirmLabel?: string; danger?: boolean };
type ConfirmOpts = {
title?: string;
message?: React.ReactNode;
confirmLabel?: string;
danger?: boolean;
};
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
vi.mock("./ConfirmProvider", () => ({
useConfirm: () => confirmMock,
@@ -64,7 +69,7 @@ vi.mock("../lib/api", async (importOriginal) => {
...actual.api,
adminInvites: () => listMock(),
deleteInvite: (id: number) => deleteMock(id),
sendInvitation: (id: number) => inviteMock(id),
sendInvitation: (id: number, lang: string) => inviteMock(id, lang),
// The access tab also renders the stuck-sign-ups section (E9.5 S5); nothing stuck here, so it
// renders nothing and this file stays about the delete affordance.
adminPendingAccess: () => Promise.resolve([]),
@@ -86,6 +91,7 @@ const invite = (over: Partial<Record<string, unknown>> = {}) => ({
has_account: false,
still_allowed_by_env: false,
invited_at: null,
lang: null,
...over,
});
@@ -247,12 +253,65 @@ describe("inviting someone the admin whitelisted", () => {
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1));
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
);
});
it("asks first, and writes to nobody when the admin backs out", async () => {
confirmMock.mockResolvedValue(false);
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
expect(inviteMock).not.toHaveBeenCalled();
});
it("defaults to the language this person asked us in", async () => {
listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
});
it("sends in the language picked in the dialog, not the default", async () => {
// The dialog is the only place this decision can be made — and the pick has to survive back to
// the caller, because the confirmation resolves to a plain boolean.
let release: (ok: boolean) => void = () => {};
confirmMock.mockImplementation(() => new Promise<boolean>((r) => (release = r)));
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
fireEvent.click(dialog.getByText("Magyar"));
release(true);
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
});
it("warns in the dialog when this person has already been written to", async () => {
// The warning used to live only in a tooltip, which the admin may never hover. It belongs next
// to the button that would mail a real person a second time.
listMock.mockResolvedValue([
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }),
]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
});
it("offers none on a denied row, which would contradict the decision", async () => {
listMock.mockResolvedValue([invite({ status: "denied" })]);
await open();
@@ -163,6 +163,11 @@
"invited": "Invitation sent to {{email}}.",
"inviteFailed": "Couldn't send that invitation.",
"inviteEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email.",
"inviteTitle": "Send invitation?",
"inviteConfirm": "Email {{email}} to say their address is whitelisted and Siftlode is open for them.",
"inviteConfirmAgain": "You already invited {{email}} on {{time}}. Sending now writes to them a second time.",
"inviteSend": "Send",
"inviteLang": "Mail language",
"addFailed": "Couldn't add that email",
"requested": "requested {{time}}",
"approveHint": "Approve — whitelist this email and email them they're in.",
@@ -163,6 +163,11 @@
"invited": "Meghívó elküldve ide: {{email}}.",
"inviteFailed": "Nem sikerült elküldeni a meghívót.",
"inviteEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt.",
"inviteTitle": "Küldesz meghívót?",
"inviteConfirm": "Levél megy a(z) {{email}} címre arról, hogy a címe engedélyezve van, és nyitva áll előtte a Siftlode.",
"inviteConfirmAgain": "Ezt a címet már meghívtad ekkor: {{time}} ({{email}}). Ha most küldesz, másodszor is írsz neki.",
"inviteSend": "Küldés",
"inviteLang": "A levél nyelve",
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
"requested": "kérve: {{time}}",
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
+10 -2
View File
@@ -29,6 +29,10 @@ export interface Invite {
* is deliberately silent — it opens a door rather than knocking — so telling them is a separate
* act, and this is how the admin sees they have already done it. */
invited_at: string | null;
/** The language this person asked us in, or null when nobody ever told us — a row the admin
* whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI
* language, so the guess is visible and correctable before anything is sent. */
lang: string | null;
}
/** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the
@@ -199,8 +203,12 @@ export const adminApi = {
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
// Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so
// this is the deliberate act that actually reaches a person.
sendInvitation: (id: number): Promise<Invite> =>
req(`/api/admin/invites/${id}/invite`, { method: "POST" }, { quiet: true }),
sendInvitation: (id: number, lang: string): Promise<Invite> =>
req(
`/api/admin/invites/${id}/invite`,
{ method: "POST", body: JSON.stringify({ lang }) },
{ quiet: true },
),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
+11 -3
View File
@@ -1,7 +1,7 @@
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
// session management, access requests, and the hidden demo login. Errors on the interactive paths
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
import { req } from "./core";
import { req, uiLang } from "./core";
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
* so the card can render an honest state instead of a red line; `invalid` and `rate_limited` answer
@@ -61,10 +61,13 @@ export const authApi = {
}> => req("/auth/config"),
// --- email + password (errors handled inline via quiet) ---
// `lang` is stamped here rather than passed in: this is the ONE moment the sign-up language
// exists anywhere the server can see it (the account does not exist yet to carry it), and every
// mail this person ever gets is written from what lands here.
register: (email: string, password: string): Promise<{ status: string }> =>
req(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ method: "POST", body: JSON.stringify({ email, password, lang: uiLang() }) },
{ quiet: true },
),
// A refusal carries a structured `{code:"login", reason, …}` detail — see LoginBlocked above.
@@ -125,8 +128,13 @@ export const authApi = {
),
// --- onboarding: access requests + demo entry ---
// Same reasoning as register: the requester has no account to carry a language preference, so the
// page they asked from is what decides the language of the approval or denial mail.
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
req("/auth/request-access", {
method: "POST",
body: JSON.stringify({ email, lang: uiLang() }),
}),
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
+6
View File
@@ -8,6 +8,12 @@ import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
* every caller is one every caller can forget, which is how the registration language shipped as a
* field nobody ever filled (E9.5 S6 → S7). */
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
export class HttpError extends Error {
status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present
+6 -2
View File
@@ -1,7 +1,7 @@
// First-run install wizard: probes install state and drives admin/config/email setup with the
// one-time token printed to the container logs (carried via setupHeaders). Every write is quiet so
// the wizard renders errors inline. Only touches core's req + setupHeaders.
import { req, setupHeaders } from "./core";
import { req, setupHeaders, uiLang } from "./core";
export const setupApi = {
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
@@ -22,7 +22,11 @@ export const setupApi = {
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/test-email",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
{
method: "POST",
headers: setupHeaders(token),
body: JSON.stringify({ to, lang: uiLang() }),
},
{ quiet: true },
),
setupFinish: (token: string): Promise<{ ok: boolean }> =>