diff --git a/.changeset/auth-email-accept-language.md b/.changeset/auth-email-accept-language.md new file mode 100644 index 0000000000..e8d9745aca --- /dev/null +++ b/.changeset/auth-email-accept-language.md @@ -0,0 +1,37 @@ +--- +"@objectstack/plugin-auth": minor +--- + +feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319) + +Request-triggered auth email — signup verification, password reset, magic link, +and the change-email notice — now picks its `sys_email_template` row from the +requesting caller's `Accept-Language`, falling back to the deployment default +(`localization.locale`, then `i18n.defaultLocale`) and finally to +`EmailService`'s documented `en-US`. + +The motivating case is the one no deployment default can answer: at cloud +self-service signup there is no workspace yet, so nothing on the server +represents that person's language — a Chinese browser reached a Chinese signup +screen and received an English verification email. + +The header is parsed by the platform's existing `preferredLocaleFromHeader`, +the same function REST uses for metadata translation and the runtime dispatcher +uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the +screen that triggered it. A requested locale takes effect only when it names one +of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything +else falls through rather than naming a row that does not exist. + +Two deliberate exclusions. **Invitations keep the deployment default**: +better-auth hands that callback a request too, but it is the *inviter's*, and +stamping their browser language onto the invitee's mail would reproduce this +same defect one seat over. **Per-user language stays deferred** — `sys_user` +grows no locale column here. + +This ships as `minor` because it changes which template row an existing +deployment sends: a workspace whose users' browsers ask for a different language +than the workspace declares will now send in the browser's language. + +**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had +rejected `Accept-Language` outright. Both are recorded, with the older one +marked superseded, on `AuthManager.setDefaultEmailLocale`. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b9c44ea6c5..b297a4ca71 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1345` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | diff --git a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts index effe7f71ed..39c937f456 100644 --- a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts +++ b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts @@ -3,16 +3,22 @@ /** * #8195 — every auth email names the deployment-default locale. * - * Maintainer ruling 2026-08-13: the recipient locale is the **deployment - * default**, read from `II18nService.getDefaultLocale()` and resolved at the - * plugin layer; `Accept-Language` is rejected; no `sys_user.locale` column. + * Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one + * this file was written against: a request-triggered auth email takes the + * caller's own `Accept-Language` first (only when it names a locale in + * `AUTH_EMAIL_TEMPLATE_LOCALES`), and the deployment default second. The + * 2026-08-13 ruling had made the deployment default the whole answer and + * rejected `Accept-Language` outright. Still no `sys_user.locale` column — + * that half stayed deferred. The ruling text of record lives on + * `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the + * request rung's own cases are the last describe block in this file. * * Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`, * so `EmailService`'s ladder always resolved `en-US` and the localized rows * were unreachable through the platform's own send path — a zh-CN deployment * received English credential mail while its UI spoke Chinese. * - * This file owns the SENDING half: that all five sites name the locale, that + * This file owns the SENDING half: that all five sites name a locale, that * an unconfigured deployment still names nothing, and that the catalog spelling * (`en`) is mapped onto the row spelling (`en-US`). The template half — that a * row actually exists in each locale and reads naturally — is @@ -20,7 +26,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AuthManager, normalizeAuthEmailLocale } from './auth-manager'; +import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager'; vi.mock('better-auth', () => ({ betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), @@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => { for (const input of sent) expect(input.locale).toBe('en-US'); }); }); + +// ── #14319 — the request rung ────────────────────────────────────────────── + +/** + * Maintainer ruling 2026-09-02, quoted verbatim and untranslated: + * + * > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的 + * > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是 + * > 部署默认(`localization.locale` → `i18n.defaultLocale`)。 + * + * Asserted at the LOCALE named on the send, which is this layer's whole output. + * That a `zh-CN` row then renders a Chinese subject and no en-US text is + * `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half; + * the two together are the card's acceptance criterion. + * + * The four sends driven here are the ones where the requester IS the recipient. + * The invitation is asserted to ABSTAIN in its own case below — better-auth + * hands it a request too, but it is the inviter's. + */ +async function driveWithHeader( + deploymentLocale: string | undefined, + header: string | undefined, +) { + const { capturedConfig, sent } = await boot(deploymentLocale); + const request = + header === undefined + ? undefined + : new Request('http://x/any', { headers: { 'accept-language': header } }); + + // Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation + // receive `ctx.request`, magic-link receives the endpoint `ctx`, and the + // change-email notice fires from the global after-hook's `ctx`. + await capturedConfig.emailAndPassword.sendResetPassword( + { user: USER, url: 'http://x/reset', token: 't' }, + request, + ); + await capturedConfig.emailVerification.sendVerificationEmail( + { user: USER, url: 'http://x/verify', token: 't' }, + request, + ); + + const org = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await org._opts.sendInvitationEmail( + { + email: 'invitee@example.com', + invitation: { id: 'inv1', organizationId: 'o1', role: 'member' }, + organization: { name: 'Northwind' }, + inviter: { user: { email: 'dana@example.com', name: 'Dana' } }, + }, + request, + ); + + const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link'); + await magic._opts.sendMagicLink( + { email: 'ada@example.com', url: 'http://x/magic', token: 't' }, + request ? { request } : undefined, + ); + + await capturedConfig.hooks.after({ + path: '/change-email', + body: { newEmail: 'new@example.com' }, + request, + context: { + __osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' }, + returned: { status: true }, + }, + }); + + const byTemplate = (name: string) => sent.find((x: any) => x.template === name); + return { + sent, + /** The four sends whose recipient is the requester. */ + requesterIsRecipient: [ + 'auth.password_reset', + 'auth.verify_email', + 'auth.magic_link', + 'auth.email_change_notice', + ].map((t) => byTemplate(t)!), + invitation: byTemplate('auth.invitation')!, + }; +} + +describe('#14319 — Accept-Language outranks the deployment default', () => { + const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED; + beforeEach(() => { + vi.clearAllMocks(); + process.env.OS_MCP_SERVER_ENABLED = 'false'; + }); + afterEach(() => { + if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv; + }); + + it('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => { + // The card's repro: Chinese browser, English deployment default. Before + // this ruling every one of these read `en-US`. + const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8'); + // A send that never happened would make the locale assertion vacuous. + expect(sent).toHaveLength(5); + for (const input of requesterIsRecipient) { + expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN'); + } + }); + + it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => { + const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag); + for (const input of requesterIsRecipient) expect(input.locale).toBe(tag); + }); + + it('a caller who asked for nothing falls back to the deployment default', async () => { + const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined); + expect(sent).toHaveLength(5); + for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN'); + }); + + it.each(['fr-FR', 'de', 'pt-BR', '*'])( + 'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default', + async (tag) => { + // The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring + // an unshipped tag would name a row that does not exist, which is the + // row-locale vs filter-locale split all over again. + const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag); + for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN'); + }, + ); + + it('with NO deployment default and an unshipped request, nothing is named at all', async () => { + // Both rungs silent ⇒ absent key, which is what EmailService's ladder + // contract ("no locale means the DOCUMENTED default") is written against. + const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR'); + for (const input of requesterIsRecipient) { + expect(input.locale).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false); + } + }); + + it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => { + const { invitation } = await driveWithHeader('zh-CN', 'en-US'); + // An English-speaking admin must not force English on their Chinese + // workspace's invitees; this send keeps the deployment rung. + expect(invitation.locale).toBe('zh-CN'); + }); + + it('naming a request locale does not disturb the rest of the payload', async () => { + const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN'); + const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!; + expect(reset.data.resetUrl).toBe('http://x/reset'); + expect(reset.relatedObject).toBe('sys_user'); + expect(reset.relatedId).toBe('u1'); + }); +}); + +describe('#14319 — authEmailLocaleFromRequest', () => { + it('reads a Web Request and strips the quality weights', () => { + const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } }); + expect(authEmailLocaleFromRequest(req)).toBe('zh-CN'); + }); + + it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => { + // Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`. + const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } }); + expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP'); + }); + + it('reads a plain header bag, either spelling', () => { + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES'); + expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES'); + }); + + it('promotes a bare language to the row we ship for it', () => { + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN'); + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US'); + }); + + it('refuses a locale we ship no auth row for, rather than naming a missing row', () => { + for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) { + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined(); + } + }); + + it('treats an absent, empty or wildcard header as no preference', () => { + expect(authEmailLocaleFromRequest(undefined)).toBeUndefined(); + expect(authEmailLocaleFromRequest(null)).toBeUndefined(); + expect(authEmailLocaleFromRequest({})).toBeUndefined(); + expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined(); + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined(); + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined(); + }); + + it('never throws when the header bag itself is hostile', () => { + // A vendor changing the shape it hands a callback must degrade to the + // deployment default, never fail the send. + const hostile = { headers: { get() { throw new Error('boom'); } } }; + expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow(); + expect(authEmailLocaleFromRequest(hostile)).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 54a3d29bae..8d04e11c0b 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -11,7 +11,11 @@ import type { AuthPluginConfig, OidcProvidersConfig, } from '@objectstack/spec/system'; -import { SystemObjectName, audiencePermitsSelfRegistration } from '@objectstack/spec/system'; +import { + SystemObjectName, + audiencePermitsSelfRegistration, + preferredLocaleFromHeader, +} from '@objectstack/spec/system'; import { assertAudienceConfig, classifyCreationMethod, @@ -988,6 +992,85 @@ export function normalizeAuthEmailLocale(raw: string | undefined): string | unde return byLanguage ?? value; } +/** + * #14319 — the auth email locale the REQUEST asked for, or `undefined`. + * + * Maintainer ruling 2026-09-02 (in session). Quoted verbatim and untranslated, + * as rulings are: + * + * > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的 + * > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是 + * > 部署默认(`localization.locale` → `i18n.defaultLocale`)。 + * + * The motivating case is the one no deployment default can answer: at cloud + * self-service signup there is no workspace yet, so nothing on the server + * represents this person's language — only the request does. + * + * The header is parsed by the platform's ONE parser, + * {@link preferredLocaleFromHeader}, which REST already uses for metadata + * translation and the runtime dispatcher for `ExecutionContext.requestLocale`. + * A second parser here would let the mail a user receives disagree with the + * screen that triggered it — the very class of defect this card is about. + * + * A hit is REQUIRED, not merely preferred: only a locale this platform ships an + * auth row for takes effect, so `fr-FR` falls through to the deployment default + * instead of naming a row that does not exist. That is deliberately narrower + * than {@link normalizeAuthEmailLocale}, which passes an unshipped regional tag + * through because a tenant may overlay `en-GB` rows and the deployment default + * may legitimately ask for them. The asymmetry is the ruling's own: a + * per-request header is a weaker claim than a deployment's declaration. + * + * ⚠️ The source is read defensively because better-auth is NOT consistent about + * what it hands these callbacks — measured against the installed 1.7.x, not + * assumed: `sendResetPassword`, `sendVerificationEmail` and + * `sendInvitationEmail` are called with `ctx.request` (a Web `Request`), while + * `sendMagicLink` is called with the endpoint `ctx` itself, and the + * change-email notice fires from the global `after` hook, which also holds a + * `ctx`. One reader covering all three shapes beats three call sites each + * guessing at one. + */ +export function authEmailLocaleFromRequest(source: unknown): string | undefined { + const header = acceptLanguageHeader(source); + if (!header) return undefined; + const preferred = preferredLocaleFromHeader(header); + if (!preferred) return undefined; + const normalized = normalizeAuthEmailLocale(preferred); + const shipped: readonly string[] = AUTH_EMAIL_TEMPLATE_LOCALES; + return normalized && shipped.includes(normalized) ? normalized : undefined; +} + +/** + * The `accept-language` value off a Web `Request`, a better-auth endpoint + * context, or anything carrying either. Never throws: a vendor changing the + * shape it hands a callback must degrade to the deployment default, never fail + * the send. + */ +function acceptLanguageHeader(source: unknown): string | undefined { + if (!source || typeof source !== 'object') return undefined; + const nested = (source as { request?: unknown }).request; + const bags = [ + (source as { headers?: unknown }).headers, + nested && typeof nested === 'object' ? (nested as { headers?: unknown }).headers : undefined, + ]; + for (const bag of bags) { + if (!bag || typeof bag !== 'object') continue; + try { + const getter = (bag as { get?: unknown }).get; + if (typeof getter === 'function') { + const value = (bag as Headers).get('accept-language'); + if (typeof value === 'string' && value) return value; + continue; + } + const record = bag as Record; + const value = record['accept-language'] ?? record['Accept-Language']; + if (typeof value === 'string' && value) return value; + } catch { + // A header bag that throws on read is not a reason to fail the send. + } + } + return undefined; +} + /** * #6039 — the 429 an SMS quota refusal must reach the caller as. * @@ -1333,7 +1416,8 @@ export class AuthManager { ? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {}), ...(this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}), - sendResetPassword: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => { + // #14319 — better-auth calls this as `sendResetPassword(data, ctx.request)`. + sendResetPassword: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => { // #2766 V1.5 — placeholder addresses (phone-only users) are never // real recipients. Refuse loudly instead of "sending" into the void; // the reset path for these users is phone sign-in / an admin @@ -1365,7 +1449,7 @@ export class AuthManager { const result = await email.sendTemplate({ template: 'auth.password_reset', to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, - ...this.emailLocaleArg(), + ...this.emailLocaleArg(request), data: { user: { name: user.name || user.email, email: user.email, id: user.id }, resetUrl: url, @@ -1396,7 +1480,8 @@ export class AuthManager { ? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {}), ...(this.config.emailVerification?.expiresIn != null ? { expiresIn: this.config.emailVerification.expiresIn } : {}), - sendVerificationEmail: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => { + // #14319 — better-auth calls this as `sendVerificationEmail(data, ctx.request)`. + sendVerificationEmail: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => { const email = this.getEmailService(); if (!email) { // Verification is enabled (this callback only exists when it is) @@ -1422,7 +1507,7 @@ export class AuthManager { const result = await email.sendTemplate({ template: 'auth.verify_email', to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, - ...this.emailLocaleArg(), + ...this.emailLocaleArg(request), data: { user: { name: user.name || user.email, email: user.email, id: user.id }, verificationUrl: url, @@ -2102,7 +2187,9 @@ export class AuthManager { } const newEmail = typeof ctx?.body?.newEmail === 'string' ? ctx.body.newEmail : ''; if (succeeded && from?.email && newEmail) { - await this.sendChangeEmailNotice(from, newEmail); + // #14319 — the notice goes to the account owner, who IS the + // caller here, so the request rung applies. + await this.sendChangeEmailNotice(from, newEmail, ctx); } return; } @@ -2919,6 +3006,15 @@ export class AuthManager { await emailService.sendTemplate({ template: 'auth.invitation', to: recipientEmail, + // #14319 — DELIBERATELY no request argument, and the only one of + // the five sends without one. better-auth DOES hand this callback + // a `ctx.request`, but it is the INVITER's: stamping their + // browser language onto the invitee's mail would recreate this + // very card one seat over. The 2026-09-02 ruling enumerates + // signup, sign-in and password reset — sends where the requester + // IS the recipient — and the superseded 2026-08-13 ruling named + // invitations as its own counterexample. So an invitee gets the + // deployment default until a per-user language exists to read. ...this.emailLocaleArg(), data: { inviter: { @@ -3064,7 +3160,9 @@ export class AuthManager { const { magicLink } = await import('better-auth/plugins/magic-link'); // magic-link reuses the `verification` table — no extra schema mapping needed. return magicLink({ - sendMagicLink: async ({ email: recipientEmail, url, token }) => { + // #14319 — this one is called with the endpoint CTX, not a Request + // (measured; magic-link/index.mjs `sendMagicLink({...}, ctx)`). + sendMagicLink: async ({ email: recipientEmail, url, token }, ctx?: unknown) => { // #2766 V1.5 — placeholder addresses are never real recipients. if (isPlaceholderEmail(recipientEmail)) { throw new Error( @@ -3086,7 +3184,7 @@ export class AuthManager { await emailService.sendTemplate({ template: 'auth.magic_link', to: recipientEmail, - ...this.emailLocaleArg(), + ...this.emailLocaleArg(ctx), data: { magicLinkUrl: url, token, @@ -4324,20 +4422,23 @@ export class AuthManager { * ⛔ No undo/rollback link is passed, and the template declares no hole for * one: a one-click revert is a separate flow and a separate decision. * - * The deployment-default locale IS named now (#8195), via - * {@link setDefaultEmailLocale} — so the three non-`en-US` rows this template - * has shipped since #8019 are finally selectable through the platform's own - * send path, instead of waiting on a caller or a tenant overlay. With nothing - * pushed, the argument is omitted entirely and `EmailService`'s ladder - * resolves its documented `en-US` default exactly as before. + * The locale is named through the #14319 ladder: the caller's own + * `Accept-Language` first ({@link authEmailLocaleFromRequest}), then the + * deployment default (#8195, {@link setDefaultEmailLocale}). The request rung + * is legitimate here precisely because the recipient IS the caller — this + * notice goes to the account's CURRENT address, i.e. to the person who just + * asked to change it. With neither rung answering, the argument is omitted + * entirely and `EmailService`'s ladder resolves its documented `en-US` + * default exactly as before. * - * Still NOT a per-recipient preference: `sys_user` carries no locale column - * and the 2026-08-13 ruling defers one until there is measured pull. This is - * the deployment's language, not the reader's. + * Still NOT a per-recipient stored preference: `sys_user` carries no locale + * column and the 2026-09-02 ruling continues to defer one. What is read is + * the language this request expressed, not a profile. */ private async sendChangeEmailNotice( from: { email: string; name?: string; id?: string }, newEmail: string, + requestSource?: unknown, ): Promise { try { const email = this.getEmailService(); @@ -4351,7 +4452,7 @@ export class AuthManager { await email.sendTemplate({ template: 'auth.email_change_notice', to: { address: from.email, ...(from.name ? { name: from.name } : {}) }, - ...this.emailLocaleArg(), + ...this.emailLocaleArg(requestSource), data: { user: { name: from.name || from.email, email: from.email, ...(from.id ? { id: from.id } : {}) }, newEmail: target, @@ -4551,27 +4652,42 @@ export class AuthManager { * #8195 — the deployment-default locale named on every auth **email**, so the * localized `sys_email_template` rows can be selected at all. * - * Maintainer ruling 2026-08-13: the recipient locale is the **deployment - * default**, resolved at the plugin layer; `Accept-Language` is rejected - * (auth mail is frequently sent outside the triggering request — - * invitations, admin-initiated resets — and a per-device header is the wrong - * authority for it). AuthPlugin pushes the value on `kernel:ready`, exactly - * as it pushes {@link setDefaultSmsLocale}. - * - * #14319 — that "deployment default" is the workspace's declared language, - * `localization.locale` (ADR-0053), whenever the operator has explicitly set - * one; `II18nService.getDefaultLocale()` (the app artifact's build-time - * `i18n.defaultLocale`) stands underneath it. Email read only the build-time - * half before, so a workspace that switched to Chinese in Setup received - * Chinese auth SMS and English auth mail. The precedence lives in - * `AuthPlugin`; this setter stays a plain sink. - * - * Unset ⇒ nothing is named and `EmailService`'s ladder resolves its - * documented `en-US` default, i.e. today's behaviour. - * - * Per-user locale is deliberately NOT resolved: the same ruling defers a - * `sys_user.locale` column until there is measured pull for it. When one - * arrives it layers on top of this as an override, so nothing here is wasted. + * This is the SECOND rung of a two-rung ladder, not the whole of it. The + * request's own `Accept-Language` outranks it — see + * {@link authEmailLocaleFromRequest}, which carries the operative ruling. + * What lands here is the deployment's declaration, used when the request + * asked for nothing this platform ships a row for, or when there is no + * request at all (invitations, scheduled and admin-initiated mail). + * + * The deployment's declaration has two producers of its own (#14591): the + * workspace's `localization.locale` (ADR-0053) whenever the operator has + * explicitly set one, and `II18nService.getDefaultLocale()` — the app + * artifact's build-time `i18n.defaultLocale` — standing underneath it. Email + * read only the build-time half before, so a workspace that switched to + * Chinese in Setup received Chinese auth SMS and English auth mail. That + * precedence is resolved in `AuthPlugin`; this setter stays a plain sink for + * whichever of the two won, and the request rung is applied above it at send + * time. + * + * AuthPlugin pushes the value on `kernel:ready`, exactly as it pushes + * {@link setDefaultSmsLocale}. Unset ⇒ nothing is named and `EmailService`'s + * ladder resolves its documented `en-US` default. + * + * ⚠️ Ruling history, because this rung used to be the ONLY one. The + * 2026-08-13 ruling made the deployment default the whole answer and + * REJECTED `Accept-Language` outright, reasoning that auth mail is + * frequently sent outside the triggering request — invitations, + * admin-initiated resets — so a per-device header was the wrong authority + * for it. **That ruling was superseded on 2026-09-02** (#14319): cloud + * self-service signup has no workspace yet, so no deployment default can + * represent that user at all, and English mail to a Chinese signup was the + * measured result. The 2026-08-13 reasoning did not simply lose — it is why + * the request rung applies only where the requester IS the recipient, and + * why the invitation send below still reads this rung. + * + * Per-user locale is STILL deferred by the 2026-09-02 ruling — `sys_user` + * carries no locale column and none is added here. When one arrives it + * layers on top as a third rung, so nothing here is wasted. */ setDefaultEmailLocale(locale: string | undefined): void { this.emailLocale = normalizeAuthEmailLocale(locale); @@ -4586,8 +4702,12 @@ export class AuthManager { * what the ladder's "no locale means the DOCUMENTED default" contract is * written against. */ - private emailLocaleArg(): { locale?: string } { - return this.emailLocale ? { locale: this.emailLocale } : {}; + private emailLocaleArg(requestSource?: unknown): { locale?: string } { + // #14319 — request rung first, deployment rung underneath. Callers that + // have no request (or whose recipient is not the requester) pass nothing + // and get exactly the pre-#14319 behaviour. + const locale = authEmailLocaleFromRequest(requestSource) ?? this.emailLocale; + return locale ? { locale } : {}; } /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 7826ea6fe0..7416a5e3cf 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -781,11 +781,19 @@ export class AuthPlugin implements Plugin { // localized `sys_email_template` rows are reachable through the // platform's own send path instead of sitting dormant. // - // Maintainer ruling 2026-08-13: the recipient locale is the - // **deployment default**, resolved here at the plugin layer. - // `Accept-Language` is rejected — auth mail is routinely sent - // outside the triggering request (invitations, admin-initiated - // resets), so a per-device request header is the wrong authority. + // This binds the DEPLOYMENT rung, resolved here at the plugin + // layer. Since the 2026-09-02 ruling (#14319) it is the SECOND rung: + // a request-triggered send whose recipient IS the requester takes + // that caller's own `Accept-Language` first, resolved at send time in + // `AuthManager` (`authEmailLocaleFromRequest`, which carries the + // ruling text). What is bound here answers when the request named no + // locale this platform ships a row for, or when there is no request + // at all — invitations, scheduled and admin-initiated mail. + // + // ⚠️ The superseded 2026-08-13 ruling made this rung the WHOLE answer + // and rejected `Accept-Language` outright. Do not restore that + // reading here; the single place the history is recorded is + // `AuthManager.setDefaultEmailLocale`. // // #14319 measured that "the deployment default" has TWO producers, // and that auth email was reading the weaker one: