Translate transactional emails from the recipient's locale
Every transactional email is hardcoded French ("Réinitialise ton mot de passe Loomkeep", "Ton compte Loomkeep sera supprimé pour inactivité", …). An English-speaking user gets French mail regardless of their UI language.
This is the deliberate exception to the "API stays locale-agnostic" rule decided in "API error codes: envelope, status fallback and guardrails": emails are rendered server-side and have no client to translate them, so the API must own their translation. Two distinct mechanisms — don't try to unify them with the error-code work.
Current state
apps/api/src/mail/mail.service.ts— 914 lines, 15 templates, each abuild*()returning{ subject, text, html }with French string literals inline.User.localealready exists (schema.prisma:492,String @default("fr"), Paraglide code) and the sharedLocaleenum is["fr", "en"]. Admin stats already group accounts by it. So the data is there — nothing to migrate.- 15 call sites across
auth,users,newsletter,notifications,reports,admin.
The actual difficulty
MailService.send*() methods take an email address, not a user. sendPasswordResetLink(to: string, token: string) has no way to know the recipient's locale. That plumbing — not the translation itself — is the bulk of the work.
Each call site needs auditing, and a few are genuinely ambiguous:
- Password reset / verify email — the user exists, pass their locale through.
- Email change (
sendEmailChangeCodeto the new address) — the account is known, use its locale even though the address isn't confirmed yet. - Newsletter — recipients are rows with their own locale; already iterating, easy.
- Digests (episodes, reports) — sent to a known user, easy.
- Moderation decision — sent to
subjectEmail; the subject user is known upstream, thread the locale down. - Admin test-send / preview gallery — no recipient user at all. Needs an explicit locale picker in the admin UI, defaulting to the admin's own, so both versions can be proofread.
Recommended shape: send*(to: string, locale: Locale, …), explicit at every call site rather than a lookup by email inside MailService (which would be an extra query per mail and would break for addresses with no account).
Translation mechanism — to decide
Paraglide is a web-side build-time tool; reusing it from the API isn't a given. Options:
- Plain locale-keyed record inside
mail.service.ts—subject: t(locale, "reset.subject")backed by a small typed object inapps/api/src/mail/i18n.ts. Zero dependency, matches the "prefer none" rule, and 15 templates × 2 locales is a bounded, readable table. - Share Paraglide's compiled output through
packages/shared— more machinery, questionable payoff for 15 templates.
I'd go with 1 unless we expect the locale list to grow well past two.
Also worth doing in the same pass
wrapEmail()'s shared chrome (footer, unsubscribe link, legal mentions) has its own French strings — same treatment.- Add a
langattribute on the HTML root of the rendered mail. - The admin email gallery should let you preview any template in either locale, so translations are reviewable without sending.
Not in scope
Adding locales beyond fr/en. This ticket makes the mechanism exist; new languages are a separate, cheap follow-up once it does.
0 Comments
Sign in to comment
No comments yet. Be the first to share your thoughts!
