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:
+93
-132
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user