Feature Requests
Planned

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 a build*() returning { subject, text, html } with French string literals inline.
  • User.locale already exists (schema.prisma:492, String @default("fr"), Paraglide code) and the shared Locale enum 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 (sendEmailChangeCode to 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:

  1. Plain locale-keyed record inside mail.service.tssubject: t(locale, "reset.subject") backed by a small typed object in apps/api/src/mail/i18n.ts. Zero dependency, matches the "prefer none" rule, and 15 templates × 2 locales is a bounded, readable table.
  2. 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 lang attribute 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!