Skip to content

fix(auth): auth emails follow the deployment locale — five templates localized, all five sends name a locale - #9134

Draft
os-project-manager wants to merge 2 commits into
mainfrom
claude/issue-8195-auth-mail-locale
Draft

fix(auth): auth emails follow the deployment locale — five templates localized, all five sends name a locale#9134
os-project-manager wants to merge 2 commits into
mainfrom
claude/issue-8195-auth-mail-locale

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

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-Language rejected, no sys_user.locale column.

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.ts falls back to the en-US row body on a miss:

const preferred = input.locale && String(input.locale).trim();
const wanted = preferred || DEFAULT_TEMPLATE_LOCALE;
let row = await loader.load(input.template, wanted);
if (!row && wanted !== DEFAULT_TEMPLATE_LOCALE) {
  row = await loader.load(input.template, DEFAULT_TEMPLATE_LOCALE);
}

while the render filters are still handed the caller's locale:

const locale = preferred || row.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.tspassword_reset, verify_email, magic_link, invitation and two_factor_otp each gain zh-CN, ja-JP and es-ES rows. 15 new rows. email_change_notice already 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 to DEFAULT_FOOTER with that reasoning recorded.

Rows are composed into per-template *_TEMPLATES lists and BUILTIN_AUTH_TEMPLATES is 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 — new setDefaultEmailLocale(), mirroring the setDefaultSmsLocale() precedent (#2815) already in this file, plus a locale argument at all 5 sendTemplate call 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 (:1140 is 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. AuthPlugin resolves II18nService.getDefaultLocale() on kernel:ready and pushes it into the manager, exactly as it already pushes the SMS locale.

File-surface amendment, declared

auth-plugin.ts was 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 where ctx.getService('i18n') is reachable — the designation folded that into auth-manager.ts, but the manager has no kernel context. The existing setDefaultSmsLocale split 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() and sys_email_template disagree about spelling, and this is measured, not assumed:

  • getDefaultLocale() carries the message catalog language, whose English spelling is the bare enFileI18nAdapter: this.defaultLocale = options.defaultLocale ?? 'en'.
  • Template rows are keyed en-US, and SendTemplateInput.locale is 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. normalizeAuthEmailLocale therefore promotes a bare language subtag to the regional row the platform ships (enen-US, zhzh-CN, …) and passes everything else through untouched — an unshipped regional tag like en-GB or fr-FR may 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.locale setting is a select whose four options are byte-identical to the template locale set (en-US/zh-CN/ja-JP/es-ES) and whose default is en-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, no locale key is passed at all — not locale: undefined — and the ladder resolves its documented en-US default exactly as before. Both hops are probed rather than assumed: getService throws for an unregistered service, and getDefaultLocale is optional on the contract.

Tests

plugin-email/src/auth-templates-locales.test.ts (34 cases) drives the real EmailService over 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 via hasOwnProperty, since absence and undefined are 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_CN from 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 file from the committed branch (never git 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/main merged (24206416a) before this run; the merge moved packages/spec, so the closure was rebuilt and everything re-run at the merged head. All figures below are at 8e32236e6, the PR head.

  • Build closure: pnpm --filter '@objectstack/plugin-email^...' --filter '@objectstack/plugin-auth^...' build — green.
  • plugin-emailTest Files 25 passed (25) / Tests 409 passed (409)
  • plugin-authTest Files 55 passed (55) / Tests 1261 passed (1261)
  • typecheck both packages — tsc --noEmit, Done (script name echoed, so not a zero-match no-op).
  • Gates green: 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.
  • Gate list re-derived from the actual changed paths with scripts/pm/dispatch-gates.mjs rather than taken from the dispatch prompt; it added the changeset family and the convention-triggered test-surface family, both run above.
  • Control-character self-scan beyond the gate over all touched files: no hits. Relevant because this diff is dense with non-ASCII prose.

skip-changeset deliberately 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

claude added 2 commits August 16, 2026 12:56
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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-email.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via @objectstack/plugin-email)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/plugin-email)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth, @objectstack/plugin-email)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/plugin-email)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth emails are always en-US: no send names a locale and sys_user has no locale column, so localized template rows can never be selected

2 participants