Skip to content

feat(mail): send transactional emails in the UI language - #20

Merged
thoda-dev merged 2 commits into
masterfrom
localise-transactional-emails
Aug 28, 2026
Merged

feat(mail): send transactional emails in the UI language#20
thoda-dev merged 2 commits into
masterfrom
localise-transactional-emails

Conversation

@thoda-dev

Copy link
Copy Markdown
Owner

What this changes

Closes #19

Every transactional email is now composed in the language of the request that triggered it, instead
of always in English. The locale comes from the language cookie the switcher writes, falling back to
Accept-Language, then to English.

Why

The UI has shipped in two languages since the start, but the five mail templates were hardcoded
English strings. Setting the instance to French and asking for a password reset gave a French page
and an English mail.

How the locale is resolved

server/utils/mail-locale.ts reads the cookie first and the Accept-Language header second. The
cookie carries a deliberate choice from the switcher, where the header is only whatever the browser
was installed with, so the cookie wins even when it names the less preferred language.

Better Auth hands its mail callbacks a standard Request rather than an H3Event, so the resolver
takes Headers and both call paths feed it: resolveMailLocale(request?.headers) in the three
account mails, mailLocaleFromEvent(event) in the two route-driven ones.

Two of the five cannot be exact, which is inherent to what they are rather than something left for
later:

  • Invitation — composed before the invitee has an account, so it follows the inviting admin.
  • Paste sharing — one Bcc'd message goes to every recipient, so there is a single language to
    pick; it follows the sender.

Verification, password reset and address change are triggered by the person who receives them, so
those are exact.

Where the strings live

In a STRINGS record in mail-templates.ts, server-side, not in i18n/locales/*.json. Those files
are shipped to the browser, and wording that only ever leaves the server has no reason to be in
them.

@nuxtjs/i18n does have a backend path — experimental.localeDetector plus useTranslation(event)
from @intlify/h3, and the module already auto-imports the @intlify/utils/h3 detection helpers
into Nitro. It was considered and set aside for this change: the middleware that backs
useTranslation is only mounted when experimental.localeDetector is configured, it needs an
H3Event the Better Auth callbacks do not have, and it would have moved the mail wording into the
client bundle. Worth revisiting on its own terms, not as a side effect of this.

Cookie and storage keys

i18n.detectBrowserLanguage.cookieKey and colorMode.storageKey are now named explicitly —
shhh_i18n_locale and shhh_color_mode — rather than left on the module defaults i18n_redirected
and nuxt-color-mode. On an instance hosted under a domain that also serves another Nuxt app, a
cookie scoped to the parent domain could otherwise decide the language here. The color-mode key sits
in localStorage, already isolated by origin, so that half is consistency rather than a fix.

Self-hosters upgrading will see visitors lose their stored preference once: the language falls
back to Accept-Language and the theme to system until each visitor chooses again. Nothing to
migrate.

Also

The shared-paste expiry moved from toUTCString(), which is English whatever the locale, to
Intl.DateTimeFormat in the mail's language. Still rendered in UTC — the recipient's timezone is
unknown, and a bare local time would be read as their own.

Checks

  • pnpm lint, pnpm typecheck and pnpm test pass (117 tests, up from 86), pnpm build passes
  • Schema change? None
  • Touches crypto, authorization, or the paste read counter? No. sharePasteByEmail gains a
    locale parameter and is otherwise untouched — the fragment key handling, the Bcc construction
    and the paste_email_recipients rows are unchanged.

The HTML escaping tests were extended rather than duplicated: a hostile sender name is still escaped
in the French template, and the text part of all ten renderings stays free of markup. The two cookie
keys were checked against the built output, not just the source — the Nitro runtime config carries
"cookieKey": "shhh_i18n_locale" and the blocking theme script reads shhh_color_mode.

AI assistance

  • An AI tool was used — Claude Code (Opus 5) wrote the whole change; reviewed before merge.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 28, 2026 13:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

resolveMailLocale can currently throw on malformed cookie percent-encoding (decodeURIComponent), enabling request-driven 500/DoS during locale resolution.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR updates the server-side transactional email system so that emails are rendered in the UI language associated with the triggering request (cookie-first, then Accept-Language, then English), and adds coverage around locale resolution and localized template rendering.

Changes:

  • Introduces a mail-locale resolver (resolveMailLocale) and threads the resolved locale through Better Auth callbacks and route-driven mail sends.
  • Refactors mail templates to use per-locale string tables and localized UTC expiry formatting for paste sharing.
  • Adds/extends tests to cover locale resolution and French template rendering; names i18n/theme storage keys explicitly in Nuxt config.
File summaries
File Description
apps/app/server/utils/mail-locale.ts Adds locale resolution from cookie / Accept-Language, plus an event helper.
apps/app/server/utils/mail-templates.ts Localizes all template strings and formats shared-paste expiry via Intl.DateTimeFormat in UTC.
apps/app/server/utils/auth.ts Passes request-derived locale into Better Auth transactional mail callbacks.
apps/app/server/utils/paste-sharing.ts Threads a required locale into shared paste email composition.
apps/app/server/api/pastes/index.post.ts Uses request/event locale when sharing a paste by email (sender-language heuristic).
apps/app/server/api/admin/invitations/index.post.ts Uses admin’s request/event locale for invitation mails (sender-language heuristic).
apps/app/nuxt.config.ts Sets explicit colorMode.storageKey and i18n.detectBrowserLanguage.cookieKey.
apps/app/tests/mail-locale.test.ts Adds unit tests for cookie/Accept-Language locale resolution behavior.
apps/app/tests/mail-templates.test.ts Extends template tests to validate French output and localized wording/date behavior.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/app/server/utils/mail-locale.ts
decodeURIComponent throws URIError on broken percent-encoding, and the
Cookie header is caller-controlled: a corrupted shhh_i18n_locale turned
paste creation and the Better Auth mail callbacks into a 500. An
unusable cookie now reads as no cookie and falls through to
Accept-Language.
@thoda-dev
thoda-dev merged commit 6ad025b into master Aug 28, 2026
5 checks passed
@thoda-dev
thoda-dev deleted the localise-transactional-emails branch August 28, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transactional emails are always in English, whatever the UI language

2 participants