fix(auth): auth emails follow the deployment locale — five templates localized, all five sends name a locale - #9134
Conversation
Localize the five remaining auth email templates to zh-CN/ja-JP/es-ES (15 new rows, seeded) and name the deployment-default locale on all five sendTemplate call sites, resolved from II18nService.getDefaultLocale() at the plugin layer per the maintainer ruling of 2026-08-13. Both halves land together deliberately: shipping the resolution alone falls back to the en-US row body while handing the caller locale to the render filters, i.e. English prose with zh-CN dates inside one message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #8195
Implements the settled 2026-08-13 maintainer ruling, on the cross-domain exception path designated by triage on 2026-08-16. Nothing here re-adjudicates the ruling: deployment-default locale via
II18nService.getDefaultLocale(), all auth templates localized in the same change,Accept-Languagerejected, nosys_user.localecolumn.Why both halves are one PR
This is the part worth reading before the diff. Shipping either half alone was measured to be worse than today's English-only state, so "a smaller first step" is disqualified by evidence rather than by preference.
The ladder in
packages/plugins/plugin-email/src/email-service.tsfalls back to the en-US row body on a miss:while the render filters are still handed the caller's locale:
So resolution-without-templates on a zh-CN deployment produces English prose carrying zh-CN-formatted dates and numbers inside one message — precisely what the row-locale authority (#7801) exists to prevent. Templates-without-resolution leaves the new rows unselectable, which is the state this card was filed about.
What changed
packages/plugins/plugin-email/src/templates/auth-templates.ts—password_reset,verify_email,magic_link,invitationandtwo_factor_otpeach gainzh-CN,ja-JPandes-ESrows. 15 new rows.email_change_noticealready had all four from #8019 and is untouched.Each localized row also carries a localized footer.
wrap()supplies an English footer by default, so a row that simply forgets one renders fluent translated prose under an English sign-off — the mixed-language artefact in miniature, and the likeliest slip in this file. The per-locale footer constants sit next toDEFAULT_FOOTERwith that reasoning recorded.Rows are composed into per-template
*_TEMPLATESlists andBUILTIN_AUTH_TEMPLATESis built from those, because seeding is what makes a row selectable — an exported-but-unseeded row resolves to nothing and silently falls back to en-US.packages/plugins/plugin-auth/src/auth-manager.ts— newsetDefaultEmailLocale(), mirroring thesetDefaultSmsLocale()precedent (#2815) already in this file, plus alocaleargument at all 5sendTemplatecall sites. Re-anchored on symbols, not the card's 2026-08-13 line numbers — the file has taken merges since and every number had moved (:1140is now:1151,:1196⇒:1207,:2415⇒:2438,:2546⇒:2569,:3061⇒:3136). Confirmed exactly 5 real call sites; the 6th grep hit is prose in a comment, as the card said.packages/plugins/plugin-auth/src/auth-plugin.ts— the plugin-layer read.AuthPluginresolvesII18nService.getDefaultLocale()onkernel:readyand pushes it into the manager, exactly as it already pushes the SMS locale.File-surface amendment, declared
auth-plugin.tswas not in the verbatim surface the triage designation listed, and I am naming that rather than letting it pass silently. It is unavoidable: the ruling says "resolved at the plugin layer", and the plugin layer is wherectx.getService('i18n')is reachable — the designation folded that intoauth-manager.ts, but the manager has no kernel context. The existingsetDefaultSmsLocalesplit proves the shape: the setter lives in the manager, the read lives in the plugin. Same package, same defect class, mechanical, and the in-flight check below covers the whole package rather than the single file. The claim comment has been amended.One measured gap the ruling did not reach
II18nService.getDefaultLocale()andsys_email_templatedisagree about spelling, and this is measured, not assumed:getDefaultLocale()carries the message catalog language, whose English spelling is the bareen—FileI18nAdapter:this.defaultLocale = options.defaultLocale ?? 'en'.en-US, andSendTemplateInput.localeis documented as matched exactly, with "no language-only prefix matching" stated in the contract.Passed through raw, the commonest deployment of all would miss every row and lean on the en-US fallback while telling the render filters
en.normalizeAuthEmailLocaletherefore promotes a bare language subtag to the regional row the platform ships (en⇒en-US,zh⇒zh-CN, …) and passes everything else through untouched — an unshipped regional tag likeen-GBorfr-FRmay well be a tenant's own overlay row, and swallowing it would re-create this exact bug from the fifth locale onward.This is an implementation detail of the ruled source, not a change of source. Flagging it because it is the one place the ruling's wording and the runtime's values did not line up.
Worth noting for whoever revisits this: the
localization.localesetting is aselectwhose four options are byte-identical to the template locale set (en-US/zh-CN/ja-JP/es-ES) and whose default isen-US. The ruling named the i18n service, so that is what this uses; the setting is mentioned only so the next reader knows the two axes exist and differ.Nothing changes for an unconfigured deployment
With no i18n service registered, or one not declaring the optional
getDefaultLocale, nolocalekey is passed at all — notlocale: undefined— and the ladder resolves its documenteden-USdefault exactly as before. Both hops are probed rather than assumed:getServicethrows for an unregistered service, andgetDefaultLocaleis optional on the contract.Tests
plugin-email/src/auth-templates-locales.test.ts(34 cases) drives the realEmailServiceover the real seeded rows and asserts rendered output. Every expectation is an independent literal — deriving them from the constants the templates are built from would make the file agree with any edit, including pasting the English body under a zh-CN tag. Covers per-locale subject, a distinctive body phrase, the footer, and that the link/code hole survived translation; plus the negative case — a zh-CN send renders none of the en-US subject, body phrase or footer — and that a no-locale send still resolves en-US.plugin-auth/src/auth-email-locale.test.ts(11 cases) drives all five send callbacks off the captured better-auth config and asserts each names the locale, that an unconfigured deployment names none (asserted viahasOwnProperty, since absence andundefinedare different facts to the ladder's contract), that the payload is otherwise undisturbed, and the normalizer's behaviour in both directions.Reverse verification — direction predicted before running, both times
Ablation A — neutralize
emailLocaleArg()to return{}. Predicted 4 red / 7 pass: the three "stamps the locale" cases plus "the normalizer is actually applied"; the "unconfigured names NO locale" case and the 5 pure-function cases stay green because they do not depend on the spread. Measured:Tests 4 failed | 7 passed (11)— exactly those four.Ablation B — drop
FOOTER_ZH_CNfrom one row, i.e. the "forgot the footer" slip itself. Predicted 2 red / 32 pass: the zh-CN password-reset render pin and its negative twin. Measured:Tests 2 failed | 32 passed (34)— exactly those two.Both restored with
git checkout claude/issue-8195-auth-mail-locale -- the filefrom the committed branch (nevergit stash), and the working tree confirmed byte-identical to the commit afterwards.No dogfood ablation is involved, so no
dist/rebuild claim is made.Verification
origin/mainmerged (24206416a) before this run; the merge movedpackages/spec, so the closure was rebuilt and everything re-run at the merged head. All figures below are at8e32236e6, the PR head.pnpm --filter '@objectstack/plugin-email^...' --filter '@objectstack/plugin-auth^...' build— green.plugin-email—Test Files 25 passed (25) / Tests 409 passed (409)plugin-auth—Test Files 55 passed (55) / Tests 1261 passed (1261)typecheckboth packages —tsc --noEmit, Done (script name echoed, so not a zero-match no-op).check:nul-bytes(5992 files, no raw control bytes),check:where-matcher(247 matchers, no files added vs baseline),check:query-options-erasure(67 non-test sites, none new),check:engine-double-contract(311 pinned — no new fake engine: the new plugin-auth test uses the captured-config harness rather than a data engine),check:type-check-coverage,check:test-source-alias,check:type-source-resolution,check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset.scripts/pm/dispatch-gates.mjsrather than taken from the dispatch prompt; it added the changeset family and the convention-triggered test-surface family, both run above.skip-changesetdeliberately not applied — this PR ships src changes and a changeset, so it is not a tests-only PR.content/docs/releases/untouched.Generated by Claude Code