feat(mail): send transactional emails in the UI language - #20
Merged
Conversation
There was a problem hiding this comment.
🟡 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsreads the cookie first and theAccept-Languageheader second. Thecookie 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
Requestrather than anH3Event, so the resolvertakes
Headersand both call paths feed it:resolveMailLocale(request?.headers)in the threeaccount 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:
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
STRINGSrecord inmail-templates.ts, server-side, not ini18n/locales/*.json. Those filesare shipped to the browser, and wording that only ever leaves the server has no reason to be in
them.
@nuxtjs/i18ndoes have a backend path —experimental.localeDetectorplususeTranslation(event)from
@intlify/h3, and the module already auto-imports the@intlify/utils/h3detection helpersinto Nitro. It was considered and set aside for this change: the middleware that backs
useTranslationis only mounted whenexperimental.localeDetectoris configured, it needs anH3Eventthe Better Auth callbacks do not have, and it would have moved the mail wording into theclient bundle. Worth revisiting on its own terms, not as a side effect of this.
Cookie and storage keys
i18n.detectBrowserLanguage.cookieKeyandcolorMode.storageKeyare now named explicitly —shhh_i18n_localeandshhh_color_mode— rather than left on the module defaultsi18n_redirectedand
nuxt-color-mode. On an instance hosted under a domain that also serves another Nuxt app, acookie 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-Languageand the theme tosystemuntil each visitor chooses again. Nothing tomigrate.
Also
The shared-paste expiry moved from
toUTCString(), which is English whatever the locale, toIntl.DateTimeFormatin the mail's language. Still rendered in UTC — the recipient's timezone isunknown, and a bare local time would be read as their own.
Checks
pnpm lint,pnpm typecheckandpnpm testpass (117 tests, up from 86),pnpm buildpassessharePasteByEmailgains alocaleparameter and is otherwise untouched — the fragment key handling, the Bcc constructionand the
paste_email_recipientsrows 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 readsshhh_color_mode.AI assistance
🤖 Generated with Claude Code