From 196aab14d81b694c6440eb15db048c6ab274115f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:23:27 +0000 Subject: [PATCH] feat(auth): notify the previous address on change-email, without gating on it (#8019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-email verified only the NEW address, so a hijacked session could move the account identity end to end while the original owner's mailbox received nothing. `POST /change-email` now mails an `auth.email_change_notice` to the address the account is being moved away from — stating what was requested, the new address, and who to contact. Per the maintainer ruling of 2026-08-12 the notice must NOT become a gate. That is enforced structurally, not promised: it is sent from the global after-hook, once better-auth has already produced its response, and `sendChangeEmailNotice` swallows every failure it can hit, so an unseeded template or a dead mailbox cannot convert a notification into a blocking step. `user.changeEmail.sendChangeEmailConfirmation` stays OFF. Measured against the installed better-auth 1.7.0-rc.2, that option is not a notifier: `update-user.mjs` returns immediately after invoking it and the new address is never mailed until the old one clicks, so enabling it would add exactly the approval gate the ruling refuses. `@better-auth/core`'s `user.changeEmail` declares only `enabled`, `sendChangeEmailConfirmation` and `updateEmailWithoutVerification` — there is no notify-only hook, which is why the notice is sent by the framework. The template ships in all four supported locales and carries no undo/rollback link (a revert is a separate flow and a separate decision). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .changeset/change-email-old-address-notice.md | 35 ++++ .../plugins/plugin-auth/src/auth-manager.ts | 158 +++++++++++++++ .../plugin-auth/src/auth-route-ledger.ts | 2 +- .../change-email-delete-user-wiring.test.ts | 187 +++++++++++++++++- .../src/auth-email-change-notice.test.ts | 134 +++++++++++++ packages/plugins/plugin-email/src/index.ts | 5 + .../src/templates/auth-templates.ts | 186 ++++++++++++++++- 7 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 .changeset/change-email-old-address-notice.md create mode 100644 packages/plugins/plugin-email/src/auth-email-change-notice.test.ts diff --git a/.changeset/change-email-old-address-notice.md b/.changeset/change-email-old-address-notice.md new file mode 100644 index 0000000000..4ccb650b2c --- /dev/null +++ b/.changeset/change-email-old-address-notice.md @@ -0,0 +1,35 @@ +--- +"@objectstack/plugin-auth": minor +"@objectstack/plugin-email": minor +--- + +feat(auth): change-email now notifies the PREVIOUS address — without gating on it (#8019) + +Self-service email change verified only the **new** address, so an attacker +holding a live session (stolen cookie, unattended device, a session not yet +revoked) could move the account identity end to end while the original owner's +mailbox received **nothing** — and the account-recovery path moved with it. +Password knowledge was never required, because the session already +authenticated the request. + +`POST /change-email` now sends an `auth.email_change_notice` mail to the address +the account is being moved away from, stating what was requested, the new +address, and who to contact. The notice ships in all four supported locales +(`en-US`, `zh-CN`, `ja-JP`, `es-ES`). + +**The change itself is unchanged.** It still completes on the new address's +verification alone — no approval step, no second click, no new gate. That is +enforced structurally rather than promised: the notice is sent from the +after-hook, once better-auth has already produced its response, and every +failure it can hit (no transport, unseeded template, dead mailbox) is swallowed. +A notification that took the flow down with it would be the exact failure this +change exists to avoid. + +better-auth's own `user.changeEmail.sendChangeEmailConfirmation` stays **off**. +Measured against the installed 1.7.0-rc.2, that option is not a notifier: the +endpoint returns immediately after invoking it and the new address is never +mailed until the old one clicks, so enabling it would add the approval gate this +change deliberately does not introduce. + +⛔ The notice carries no undo/rollback link. Reverting a completed change is a +separate flow and a separate decision. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 151852bafa..9a278aa9ca 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -959,6 +959,31 @@ export class AuthManager { // template; 「策略按 better-auth 常规」 is the single-step default, // so the two-step variant stays a deliberate future design. // + // ── #8019 — the OLD address is NOTIFIED, and still not gated ─────── + // Maintainer ruling 2026-08-12 (later the same day, and deliberately + // narrower than the one above): 「notify the OLD address — do not gate + // on it」. #7735's ruling keeps governing the CONFIRMATION option; this + // one adds only the notice. So `sendChangeEmailConfirmation` stays + // absent above — and that is a measured decision, not an omission: + // + // `user.changeEmail` in better-auth 1.7.0-rc.2 declares EXACTLY three + // members (`@better-auth/core/src/types/init-options.ts:946-971`): + // `enabled`, `sendChangeEmailConfirmation`, and + // `updateEmailWithoutVerification`. There is NO notify-only hook, and + // `sendChangeEmailConfirmation` is not one: in `update-user.mjs:457` + // it becomes `canSendConfirmation`, and the branch at :495 RETURNS + // right after invoking it — the new address is never mailed until the + // old one clicks. Setting it is therefore structurally the approval + // gate the ruling refuses, not a way to notify. + // + // The notice is consequently sent by the framework, from the global + // `after` hook on `/change-email` (search `__osChangeEmailFrom`), using + // the `auth.email_change_notice` template. It is failure-isolated by + // construction: it runs after better-auth has already produced the + // response, and its own errors are swallowed, so a dead mailbox cannot + // turn a notification into a block. That is the "do not gate" half, + // enforced mechanically rather than promised in prose. + // // No email transport wired ⇒ the `emailVerification` block below is // absent ⇒ better-auth answers 400 "Verification email isn't enabled". // That is the honest answer for a deployment with no mailbox, and a @@ -1286,6 +1311,38 @@ export class AuthManager { // fall through to the path's own handling below } + // ── #8019: capture the address a change-email would move AWAY from ── + // The notice goes out from the after-hook, but the OLD address only + // exists BEFORE the endpoint runs — and `ctx.context.session` is not + // it: better-auth's own `sensitiveSessionMiddleware` populates that, + // and endpoint middleware runs AFTER this global before-hook, so the + // field is still empty here. Resolve the session explicitly, the same + // hook-order-independent way `/oauth2/authorize` below has to. + // + // Stashed, never sent from here: at this point the request has not + // been accepted yet (it can still fail on `newEmail` equal to the + // current address, on rate limiting, or on a missing transport), and + // a notice for a request better-auth then refuses would be a false + // alarm about an identity move that never happened. + if (ctx?.path === '/change-email') { + try { + const { getSessionFromCtx } = await import('better-auth/api'); + const s: any = await getSessionFromCtx(ctx as any).catch(() => null); + const from = s?.user?.email; + if (typeof from === 'string' && from) { + ctx.context.__osChangeEmailFrom = { + email: from, + ...(typeof s?.user?.name === 'string' && s.user.name ? { name: s.user.name } : {}), + ...(typeof s?.user?.id === 'string' && s.user.id ? { id: s.user.id } : {}), + }; + } + } catch { + // Unresolvable session → no stash → no notice. better-auth's own + // session middleware still rejects the request with 401, so this + // never silently drops a notice for a change that proceeds. + } + } + // ── ADR-0024: admin-gate self-service SSO provider registration ── // `@better-auth/sso`'s POST /sso/register only checks org-admin when // `body.organizationId` is present (index.mjs: `if (ctx.body @@ -1522,6 +1579,47 @@ export class AuthManager { return; } + // ── #8019: tell the OLD address, without gating on it ────────────── + // Maintainer ruling 2026-08-12 — 「notify the OLD address — do not + // gate on it」. See the `changeEmail` config site for why this cannot + // be `sendChangeEmailConfirmation` (that option IS the gate). + // + // Why HERE is the whole design: + // • after-hook ⇒ better-auth has already produced its response, so + // nothing this code does can change the endpoint's outcome. The + // "does not gate" property is structural, not a promise; + // • gated on success ⇒ a refused request sends nothing; + // • `sendChangeEmailNotice` swallows its own failures ⇒ a dead + // mailbox, an unseeded template or a missing transport cannot + // turn the notification into a block. That inversion — a security + // notice that takes the flow down with it — is precisely the + // failure mode the ruling exists to avoid. + // + // Sent at REQUEST time, which is also the strongest moment available: + // the owner learns while the attacker still has to prove control of + // the new mailbox, so the warning arrives inside the window where it + // can still be acted on. It is also the only moment that exists + // exactly once per attempt — the apply step (`GET /verify-email`) + // does not carry the old address, and better-auth mints a fresh + // session there when the link is opened without one, so an + // apply-time notice would silently miss that path. + if (ctx?.path === '/change-email') { + const from = ctx?.context?.__osChangeEmailFrom; + delete ctx.context.__osChangeEmailFrom; + let succeeded: boolean; + try { + const { isAPIError } = await import('better-auth/api'); + succeeded = !isAPIError(ctx?.context?.returned); + } catch { + succeeded = !(ctx?.context?.returned instanceof Error); + } + const newEmail = typeof ctx?.body?.newEmail === 'string' ? ctx.body.newEmail : ''; + if (succeeded && from?.email && newEmail) { + await this.sendChangeEmailNotice(from, newEmail); + } + return; + } + if (ctx?.path !== '/sign-up/email') return; // ADR-0069 D1 — stamp password_changed_at for a newly-created local // user (expiry clock starts at sign-up). Best-effort. @@ -2871,6 +2969,66 @@ export class AuthManager { return this.config.emailService; } + /** + * #8019 — mail the address a change-email request would move the account + * AWAY from. Maintainer ruling 2026-08-12: 「notify the OLD address — do not + * gate on it」. + * + * ⛔ **This method must never throw, and must never be awaited for its + * effect on the flow.** It is the "do not gate" half of the ruling, and the + * enforcement lives right here: every failure — no transport wired, template + * not seeded, SMTP down, a `status: 'failed'` delivery — is swallowed. The + * sibling auth callbacks above do the opposite ON PURPOSE (a verification + * mail that cannot be sent must surface, or the user is stuck on a verify + * screen forever); this one is a notification about a change that proceeds + * either way, so letting it raise would convert the notice into exactly the + * blocking step the ruling refuses. + * + * ⛔ 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. + * + * No `locale` is named, so `EmailService`'s ladder resolves the `en-US` row + * (`email-service.ts` — "no locale means the DOCUMENTED default"). The other + * three locale rows ship with it and are selected the moment a caller or a + * tenant overlay names a locale; the platform has no per-recipient locale to + * pass here yet (`sys_user` carries no locale column, and every other auth + * template is likewise en-US-only), so naming one would be inventing a + * preference rather than honouring one. + */ + private async sendChangeEmailNotice( + from: { email: string; name?: string; id?: string }, + newEmail: string, + ): Promise { + try { + const email = this.getEmailService(); + if (!email) return; + // The endpoint lower-cases `newEmail` before it does anything with it + // (`update-user.mjs`: `ctx.body.newEmail.toLowerCase()`), so the raw body + // spelling is not what would become the account identity. Report the + // address that would actually land. + const target = newEmail.trim().toLowerCase(); + if (!target) return; + await email.sendTemplate({ + template: 'auth.email_change_notice', + to: { address: from.email, ...(from.name ? { name: from.name } : {}) }, + data: { + user: { name: from.name || from.email, email: from.email, ...(from.id ? { id: from.id } : {}) }, + newEmail: target, + appName: this.getAppName(), + }, + relatedObject: 'sys_user', + ...(from.id ? { relatedId: from.id } : {}), + }); + } catch (err) { + // Best-effort by design — see the ⛔ above. Logged so an operator can see + // a notice was lost; never rethrown, so the change-email flow cannot be + // taken down by its own security notification. + this.config.logger?.warn?.('AuthManager: change-email notice to the previous address failed', { + error: (err as Error)?.message, + }); + } + } + /** * Inject (or replace) the outbound SMS service used by the phone-number * OTP callbacks and the SMS invite path (#2780). Mirrors diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index ac21f4737e..e68203570a 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -143,7 +143,7 @@ export interface AuthRouteLedgerEntry { } export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ - { route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail', note: 'live since #7735: auth-manager.ts sets user.changeEmail.enabled, and the confirmation link rides emailVerification.sendVerificationEmail to the NEW address' }, + { route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail', note: 'live since #7735: auth-manager.ts sets user.changeEmail.enabled, and the confirmation link rides emailVerification.sendVerificationEmail to the NEW address; since #8019 the OLD address also gets an auth.email_change_notice (notification only — sendChangeEmailConfirmation stays off, it is a gate not a notifier)' }, { route: 'POST /api/v1/auth/change-password', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changePassword' }, // #7735 — self-service account deletion is NOT wired, and this row says so // rather than booking it as a live SDK surface. Maintainer ruling diff --git a/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts index ef691eb351..dc47dc7986 100644 --- a/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts +++ b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts @@ -130,8 +130,14 @@ function createMemoryEngine() { type MemoryEngine = ReturnType; -/** Recording email transport — every `sendTemplate` call, in order. */ -function createRecordingEmailService() { +/** + * Recording email transport — every `sendTemplate` call, in order. + * + * `failOn` makes a chosen template name throw, which is how the #8019 tests + * below prove the old-address notice is not load-bearing: a transport that + * refuses exactly that one template must leave the change-email flow intact. + */ +function createRecordingEmailService(failOn?: string) { const sent: SendTemplateInput[] = []; const service: IEmailService = { async send(): Promise { @@ -139,6 +145,9 @@ function createRecordingEmailService() { }, async sendTemplate(input: SendTemplateInput): Promise { sent.push(input); + if (failOn && input.template === failOn) { + throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=en-US)`); + } return { id: `email_${sent.length}`, status: 'sent' }; }, }; @@ -227,9 +236,16 @@ describe('#7735 — POST /change-email is wired, and confirmed by email', () => // Confirmation goes to the NEW address, through the same verification // callback sign-up uses — better-auth's single-step default, which is the // 「策略按 better-auth 常规」 the ruling names. - expect(email.sent).toHaveLength(1); - const [confirmation] = email.sent; - expect(confirmation.template).toBe('auth.verify_email'); + // + // Selected by TEMPLATE, not by being the only mail sent: #8019 added a + // second, independent mail on this same request (the notice to the OLD + // address). The claim this test makes has always been "the confirmation is + // an auth.verify_email addressed to the new address" — a count of 1 was + // only ever a proxy for it, and the proxy is what expired. Still pinned to + // exactly one CONFIRMATION, so a second verification mail would fail here. + const confirmations = email.sent.filter((s) => s.template === 'auth.verify_email'); + expect(confirmations).toHaveLength(1); + const [confirmation] = confirmations; expect(confirmation.to).toMatchObject({ address: 'after@example.com' }); // …and NOTHING has changed yet. A request nobody confirms must not move the @@ -253,8 +269,12 @@ describe('#7735 — POST /change-email is wired, and confirmed by email', () => await post(manager, '/change-email', cookie, { newEmail: 'new@example.com' }); - const lastSent = email.sent[email.sent.length - 1]; - const verificationUrl = (lastSent?.data as { verificationUrl?: string } | undefined)?.verificationUrl; + // By template, not by arrival order: since #8019 the LAST mail on this + // request is the old-address notice, which carries no verification link by + // design (⛔ no undo/rollback affordance), so "the last one sent" now names + // the wrong mail. + const verification = email.sent.find((s) => s.template === 'auth.verify_email'); + const verificationUrl = (verification?.data as { verificationUrl?: string } | undefined)?.verificationUrl; expect(typeof verificationUrl, 'the change-email mail must carry a verification link').toBe('string'); const applied = await manager.handleRequest(new Request(verificationUrl!, { headers: { cookie } })); @@ -342,3 +362,156 @@ describe('#7735 — POST /delete-user stays unwired, and the ledger says so', () expect(userRows(engine).map((r) => r.email)).toEqual(['callback@example.com']); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +/** + * #8019 — the OLD address is told, and is still not a gate. + * + * Maintainer ruling 2026-08-12: 「notify the OLD address — do not gate on it」. + * BOTH halves are asserted here on purpose, because each one alone passes over + * the other's failure: a suite that only checks "the notice was sent" stays + * green while the notification quietly becomes a blocking step, which is the + * exact regression the ruling was written to prevent. + * + * The template NAME is spelled as a literal in every assertion below rather + * than imported from `@objectstack/plugin-email`. Importing it would make the + * expectation and the implementation read the same constant, and a rename + * would then travel through both sides at once and fail nothing. + */ +const NOTICE_TEMPLATE = 'auth.email_change_notice'; + +/** Every recorded send of the old-address notice. */ +const notices = (sent: SendTemplateInput[]): SendTemplateInput[] => + sent.filter((s) => s.template === NOTICE_TEMPLATE); + +describe('#8019 — change-email notifies the previous address without gating on it', () => { + it('mails the OLD address, mails the NEW one, and the change still completes', async () => { + const engine = createMemoryEngine(); + const email = createRecordingEmailService(); + const manager = makeManager(engine, email.service); + + const cookie = cookieFrom(await signUp(manager, 'old@example.com')); + email.sent.length = 0; + + const response = await post(manager, '/change-email', cookie, { newEmail: 'new@example.com' }); + expect(response.status, await response.clone().text()).toBe(200); + + // ── Half one: the previous address is told what is happening. ────────── + expect(notices(email.sent), 'exactly one notice, to the address being left').toHaveLength(1); + const [notice] = notices(email.sent); + expect(notice.to).toMatchObject({ address: 'old@example.com' }); + // The notice must NAME the new address — a notice that says only "your + // email is changing" leaves the reader unable to tell hijack from typo. + expect(notice.data).toMatchObject({ newEmail: 'new@example.com' }); + expect((notice.data as { user?: { email?: string } }).user?.email).toBe('old@example.com'); + + // ⛔ Ruling edge 3: no undo/rollback link. Assert over the whole rendered + // payload, not just a named hole, so a link smuggled in through any other + // variable is caught too. + expect(JSON.stringify(notice.data)).not.toMatch(/undo|revert|rollback|cancel-change/i); + + // ── Half two: nothing about the notice altered the flow. ─────────────── + // The verification still goes to the NEW address, through the unchanged + // single-step path #7735 established. + const verifications = email.sent.filter((s) => s.template === 'auth.verify_email'); + expect(verifications).toHaveLength(1); + expect(verifications[0].to).toMatchObject({ address: 'new@example.com' }); + + // …and following it still applies the change, unblocked. If the notice had + // become a gate, better-auth would be waiting on the old address here and + // this address would still read `old@example.com`. + const verificationUrl = (verifications[0].data as { verificationUrl?: string }).verificationUrl; + const applied = await manager.handleRequest(new Request(verificationUrl!, { headers: { cookie } })); + expect([200, 302]).toContain(applied.status); + expect(userRows(engine)[0]!.email).toBe('new@example.com'); + expect(userRows(engine)[0]!.email_verified).toBeTruthy(); + }); + + it('a notice that CANNOT be delivered still does not block the change', async () => { + // The failure mode the ruling names, driven directly: the transport refuses + // the notice template (unseeded template, dead mailbox, SMTP outage). The + // change-email flow must be indistinguishable from the happy path. + const engine = createMemoryEngine(); + const email = createRecordingEmailService(NOTICE_TEMPLATE); + const manager = makeManager(engine, email.service); + + const cookie = cookieFrom(await signUp(manager, 'stuck@example.com')); + email.sent.length = 0; + + const response = await post(manager, '/change-email', cookie, { newEmail: 'moved@example.com' }); + expect(response.status, 'a failed notice must not surface as a failed change').toBe(200); + expect(await response.json()).toEqual({ status: true }); + + // It was attempted (so this test cannot pass by the notice being skipped)… + expect(notices(email.sent)).toHaveLength(1); + // …and the flow ran to completion regardless. + const verification = email.sent.find((s) => s.template === 'auth.verify_email'); + const url = (verification?.data as { verificationUrl?: string } | undefined)?.verificationUrl; + expect(typeof url).toBe('string'); + const applied = await manager.handleRequest(new Request(url!, { headers: { cookie } })); + expect([200, 302]).toContain(applied.status); + expect(userRows(engine)[0]!.email).toBe('moved@example.com'); + }); + + it('sends no notice when the request is REFUSED', async () => { + // A false alarm is a real cost on a security notice: it trains the reader + // to ignore the next one. better-auth refuses `newEmail === current` with + // 400 before anything is minted, so nothing may go out. + const engine = createMemoryEngine(); + const email = createRecordingEmailService(); + const manager = makeManager(engine, email.service); + + const cookie = cookieFrom(await signUp(manager, 'same@example.com')); + email.sent.length = 0; + + const response = await post(manager, '/change-email', cookie, { newEmail: 'same@example.com' }); + expect(response.status).toBe(400); + expect(notices(email.sent)).toHaveLength(0); + }); + + it('sends no notice for an unauthenticated attempt', async () => { + const engine = createMemoryEngine(); + const email = createRecordingEmailService(); + const manager = makeManager(engine, email.service); + await signUp(manager, 'bystander@example.com'); + email.sent.length = 0; + + const response = await manager.handleRequest( + new Request(`${AUTH}/change-email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ newEmail: 'attacker@example.com' }), + }), + ); + expect(response.status).toBe(401); + expect(notices(email.sent)).toHaveLength(0); + }); + + it('does not fire on sign-up verification — only a real identity move notifies', async () => { + const engine = createMemoryEngine(); + const email = createRecordingEmailService(); + const manager = makeManager(engine, email.service); + + await signUp(manager, 'fresh@example.com'); + expect(notices(email.sent)).toHaveLength(0); + }); + + it('keeps `sendChangeEmailConfirmation` OFF — the notice is not the gate in disguise', async () => { + // ⛔ Ruling edge 1: #7735's 「策略按 better-auth 常规」 still governs the + // CONFIRMATION option, and in better-auth 1.7.0-rc.2 that option is not a + // notifier — `update-user.mjs` returns immediately after invoking it, so + // the NEW address is never mailed until the OLD one clicks. Setting it + // would silently convert this card's notification into the approval gate + // the ruling refuses, and every assertion above would still pass. Read off + // the options object better-auth actually runs on. + const manager = makeManager(createMemoryEngine(), createRecordingEmailService().service); + const auth = (await manager.getAuthInstance()) as unknown as { + options: { user?: { changeEmail?: Record } }; + }; + const changeEmail = auth.options.user?.changeEmail; + + expect(changeEmail?.enabled, 'the capability itself stays on (#7735)').toBe(true); + expect(changeEmail?.sendChangeEmailConfirmation).toBeUndefined(); + expect(changeEmail?.updateEmailWithoutVerification).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-email/src/auth-email-change-notice.test.ts b/packages/plugins/plugin-email/src/auth-email-change-notice.test.ts new file mode 100644 index 0000000000..11c9aa4123 --- /dev/null +++ b/packages/plugins/plugin-email/src/auth-email-change-notice.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8019 — the change-email notice template, in every supported locale. + * + * Maintainer ruling 2026-08-12: 「notify the OLD address — do not gate on it」, + * with the scope naming a new template plus its keys in the four supported + * locales, and ⛔ **no undo/rollback link** (a revert is a separate flow and a + * separate decision). + * + * `plugin-auth` owns the SENDING half (that it goes out, to the old address, + * without gating — `change-email-delete-user-wiring.test.ts`). This file owns + * the AUTHORING half: that the row exists in each locale, that it is + * seeded/resolvable rather than merely exported, and that the prose obeys the + * ruling's two content constraints. + * + * Every expectation is written as a literal. Deriving the locale set or the + * variable list from the same constants the templates are built from would + * make this file agree with any edit, including deleting three locales. + */ + +import { describe, it, expect } from 'vitest'; +import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; +import { + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES, + BUILTIN_AUTH_TEMPLATES, +} from './templates/auth-templates.js'; + +/** The four locales this platform supports, spelled out — not imported. */ +const SUPPORTED_LOCALES = ['en-US', 'zh-CN', 'ja-JP', 'es-ES'] as const; + +const TEMPLATE_NAME = 'auth.email_change_notice'; + +const rows = AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES; +const byLocale = (locale: string) => rows.find((t) => t.locale === locale); + +/** Subject + both bodies, i.e. everything a recipient can actually read. */ +const proseOf = (t: (typeof rows)[number]): string => + [t.subject, t.bodyHtml, t.bodyText].filter(Boolean).join('\n'); + +describe('#8019 — auth.email_change_notice ships in all four locales', () => { + it('has exactly one row per supported locale, all under the same template name', () => { + expect(rows.map((t) => t.locale).sort()).toEqual([...SUPPORTED_LOCALES].sort()); + expect(new Set(rows.map((t) => t.name))).toEqual(new Set([TEMPLATE_NAME])); + }); + + it('is seeded with the built-in auth templates, so the send path can resolve it', () => { + // Exported-but-unseeded would pass every other assertion here and still + // throw TEMPLATE_NOT_FOUND on the first real change-email request. + const seeded = BUILTIN_AUTH_TEMPLATES.filter((t) => t.name === TEMPLATE_NAME); + expect(seeded.map((t) => t.locale).sort()).toEqual([...SUPPORTED_LOCALES].sort()); + }); + + it('carries an en-US row — the locale the platform send path actually asks for', () => { + // `EmailService.sendTemplate` with no `locale` resolves DEFAULT_TEMPLATE_LOCALE + // ('en-US'). Without this row the notice would fall through the ladder's + // last resort and pick a row by lowest locale tag — 'es-ES' — so a + // deployment would mail Spanish to everyone. + expect(byLocale('en-US')).toBeDefined(); + }); + + for (const locale of SUPPORTED_LOCALES) { + describe(locale, () => { + it('parses against EmailTemplateDefinitionSchema', () => { + const parsed = EmailTemplateDefinitionSchema.safeParse(byLocale(locale)); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [], null, 2)).toBe(true); + }); + + it('declares the holes the sender fills, and requires the two that carry the meaning', () => { + const t = byLocale(locale)!; + const names = (t.variables ?? []).map((v) => v.name).sort(); + expect(names).toEqual(['appName', 'newEmail', 'user.email', 'user.name']); + const required = (t.variables ?? []).filter((v) => v.required).map((v) => v.name).sort(); + // Ruling: the notice must state the new address; `user.email` is the + // old one it is addressed to. Neither may silently render empty. + expect(required).toEqual(['newEmail', 'user.email']); + }); + + it('names what changed and the address it is moving to', () => { + const prose = proseOf(byLocale(locale)!); + expect(prose).toContain('{{newEmail}}'); + expect(prose).toContain('{{user.email}}'); + }); + + it('offers a support path', () => { + // "who to contact" in the locale's own words — asserted as the + // presence of a contact instruction, since the wording differs per + // locale by design. + const prose = proseOf(byLocale(locale)!); + const support: Record = { + 'en-US': /administrator or support/i, + 'zh-CN': /管理员或支持团队/, + 'ja-JP': /管理者またはサポート/, + 'es-ES': /administrador o el equipo de soporte/i, + }; + expect(prose).toMatch(support[locale]); + }); + + it('⛔ embeds no undo/rollback affordance', () => { + // Ruling edge 3. Checked as (a) no revert-shaped wording, and (b) no + // link hole at all other than none — this template deliberately has no + // URL variable, so ANY `href` pointing at a placeholder is a smuggled + // action. + const t = byLocale(locale)!; + const prose = proseOf(t); + expect(prose).not.toMatch(/undo|revert|rollback|restore this address|cancel the change/i); + expect(prose).not.toMatch(/撤销|撤消|回滚|取消变更/); + expect(prose).not.toMatch(/元に戻す|取り消/); + expect(prose).not.toMatch(/deshacer|revertir|cancelar el cambio/i); + expect(t.bodyHtml ?? '').not.toMatch(/href="\{\{/); + expect((t.variables ?? []).map((v) => v.type)).not.toContain('url'); + }); + + it('does not tell the reader to ignore it', () => { + // The shared footer ends "you can safely ignore this message", which is + // the opposite of true here — this row overrides it. A future refactor + // that drops the override would silently neuter the notice. + const prose = proseOf(byLocale(locale)!); + expect(prose).not.toMatch(/safely ignore/i); + expect(prose).not.toMatch(/可以忽略|请忽略/); + expect(prose).not.toMatch(/無視して/); + expect(prose).not.toMatch(/puedes ignorar/i); + }); + }); + } + + it('says the change is PENDING, never that it already happened', () => { + // The notice is sent when the request is accepted, not when it is applied + // — the only non-gating seam better-auth 1.7.0-rc.2 offers. Past-tense + // wording would be false for every request nobody ever confirms. + expect(proseOf(byLocale('en-US')!)).toMatch(/requested to change|takes effect once/i); + expect(proseOf(byLocale('en-US')!)).not.toMatch(/your email (address )?(has been|was) changed/i); + }); +}); diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 9bc36bd835..a4a8dc6ddf 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -126,5 +126,10 @@ export { AUTH_MAGIC_LINK_TEMPLATE, AUTH_INVITATION_TEMPLATE, AUTH_TWO_FACTOR_OTP_TEMPLATE, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_ZH_CN, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_JA_JP, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_ES_ES, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES, BUILTIN_AUTH_TEMPLATES, } from './templates/auth-templates.js'; diff --git a/packages/plugins/plugin-email/src/templates/auth-templates.ts b/packages/plugins/plugin-email/src/templates/auth-templates.ts index b8a9d6b4fa..a68554bfc3 100644 --- a/packages/plugins/plugin-email/src/templates/auth-templates.ts +++ b/packages/plugins/plugin-email/src/templates/auth-templates.ts @@ -22,14 +22,24 @@ const baseStyles = 'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sa const buttonStyles = 'display:inline-block;padding:12px 24px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600'; const footerStyles = 'margin-top:32px;padding-top:16px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:12px'; -function wrap(title: string, bodyHtml: string): string { +const DEFAULT_FOOTER = `You received this email because of activity on your {{appName}} account.
+If this wasn't you, you can safely ignore this message.`; + +/** + * `footerHtml` overrides the default footer. Two templates need that: any + * non-`en-US` row (the default footer is English prose, and a localized body + * under an English footer is the mixed-language artefact `email-service.ts`'s + * locale ladder exists to prevent), and the change-email notice — whose whole + * point is that ignoring it is NOT safe, so "you can safely ignore this + * message" would contradict the body it sits under. + */ +function wrap(title: string, bodyHtml: string, footerHtml: string = DEFAULT_FOOTER): string { return `

${title}

${bodyHtml}
-You received this email because of activity on your {{appName}} account.
-If this wasn't you, you can safely ignore this message. +${footerHtml}
`; } @@ -168,10 +178,180 @@ export const AUTH_TWO_FACTOR_OTP_TEMPLATE: EmailTemplate = { description: 'Time-based OTP delivered for two-factor / email-OTP login.', }; +// ─────────────────────────────────────────────────────────────────────────── +// auth.email_change_notice — the OLD address's notice (#8019) +// ─────────────────────────────────────────────────────────────────────────── +/** + * Sent to the address an account is being moved AWAY from, the moment + * `POST /change-email` is accepted. Maintainer ruling 2026-08-12: **notify the + * old address, do not gate on it** — the change still completes on the NEW + * address's verification alone, so this mail is a notification and never a + * step in the flow. + * + * Three constraints the wording is bound by, all from that ruling: + * + * - ⛔ **No undo / rollback link.** A one-click revert is a new flow and a new + * decision; the notice states the change and hands the reader a support + * path, nothing more. The support path is deliberately a *person* + * ("contact your administrator"), not a `{{supportUrl}}` hole: no + * platform-level support URL exists to fill it, and the template engine has + * no conditionals (`template-engine.ts` — no loops, no conditionals, no + * partials), so an unfilled optional URL would render as a dangling empty + * line in every deployment that never configured one. + * - It describes the change as **requested and pending**, because that is + * when this mail is sent. The only non-gating seam better-auth 1.7.0-rc.2 + * offers is request time (see the `changeEmail` config site in + * `auth-manager.ts` for the measurement); wording it "your email WAS + * changed" would be false for every request nobody ever confirms. + * - The footer is overridden: the default one ends "you can safely ignore + * this message", which is the opposite of true here. + * + * `{{user.email}}` is the RECIPIENT — the old address, still the account's + * email at send time — and `{{newEmail}}` the address it would move to. + */ +const EMAIL_CHANGE_NOTICE_VARIABLES: EmailTemplate['variables'] = [ + { name: 'user.name', type: 'string', required: false, description: 'Recipient display name' }, + { name: 'user.email', type: 'string', required: true, description: 'The CURRENT (old) account email — the recipient' }, + { name: 'newEmail', type: 'string', required: true, description: 'The address the account would move to' }, + { name: 'appName', type: 'string', required: false, description: 'Product/app name (brand override)' }, +]; + +export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE: EmailTemplate = { + name: 'auth.email_change_notice', + label: 'Email Change Notice (previous address)', + category: 'auth', + locale: 'en-US', + subject: 'Security notice: the email address on your {{appName}} account is being changed', + bodyHtml: wrap( + 'Your account email is being changed', + ` +

Hi {{user.name}},

+

A signed-in session on your {{appName}} account requested to change the account email address from {{user.email}} to {{newEmail}}.

+

The change takes effect once {{newEmail}} is verified. You do not need to do anything to allow it — this message is a notification, not an approval request.

+

If you did not request this, your account may be compromised. Contact your {{appName}} administrator or support team immediately, and change your password to end other sessions.

+`, + 'You received this email because the address on your {{appName}} account is being changed.
This notice was sent to the previous address on the account.', + ), + bodyText: `Hi {{user.name}}, + +A signed-in session on your {{appName}} account requested to change the account +email address from {{user.email}} to {{newEmail}}. + +The change takes effect once {{newEmail}} is verified. You do not need to do +anything to allow it — this message is a notification, not an approval request. + +If you did not request this, your account may be compromised. Contact your +{{appName}} administrator or support team immediately, and change your password +to end other sessions.`, + variables: EMAIL_CHANGE_NOTICE_VARIABLES, + active: true, + isSystem: true, + description: 'Sent to the PREVIOUS address when a change-email request is accepted (#8019). Notification only — never gates the change.', +}; + +export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_ZH_CN: EmailTemplate = { + ...AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE, + locale: 'zh-CN', + label: '邮箱变更通知(原地址)', + subject: '安全通知:您的 {{appName}} 账号邮箱正在被变更', + bodyHtml: wrap( + '您的账号邮箱正在被变更', + ` +

{{user.name}} 您好:

+

您的 {{appName}} 账号上有一个已登录会话请求将账号邮箱从 {{user.email}} 变更为 {{newEmail}}

+

该变更将在 {{newEmail}} 完成验证后生效。您无需做任何操作即可放行——本邮件仅为通知,不是审批请求。

+

如果这不是您本人的操作,您的账号可能已被入侵。请立即联系您的 {{appName}} 管理员或支持团队,并修改密码以结束其他会话。

+`, + '您收到本邮件,是因为您的 {{appName}} 账号邮箱正在被变更。
本通知已发送至账号变更前的邮箱地址。', + ), + bodyText: `{{user.name}} 您好: + +您的 {{appName}} 账号上有一个已登录会话请求将账号邮箱从 {{user.email}} +变更为 {{newEmail}}。 + +该变更将在 {{newEmail}} 完成验证后生效。您无需做任何操作即可放行——本邮件仅为 +通知,不是审批请求。 + +如果这不是您本人的操作,您的账号可能已被入侵。请立即联系您的 {{appName}} 管理员 +或支持团队,并修改密码以结束其他会话。`, + description: '在接受变更邮箱请求时发送至原邮箱地址(#8019)。仅为通知,绝不阻断变更流程。', +}; + +export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_JA_JP: EmailTemplate = { + ...AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE, + locale: 'ja-JP', + label: 'メールアドレス変更のお知らせ(変更前のアドレス)', + subject: 'セキュリティ通知: {{appName}} アカウントのメールアドレスが変更されようとしています', + bodyHtml: wrap( + 'アカウントのメールアドレスが変更されようとしています', + ` +

{{user.name}} 様

+

{{appName}} アカウントにログイン中のセッションから、アカウントのメールアドレスを {{user.email}} から {{newEmail}} へ変更する要求がありました。

+

この変更は {{newEmail}} の確認が完了した時点で有効になります。許可するために必要な操作はありません。本メールは通知であり、承認の依頼ではありません。

+

心当たりがない場合、アカウントが不正利用されている可能性があります。直ちに {{appName}} の管理者またはサポートへご連絡のうえ、パスワードを変更して他のセッションを終了してください。

+`, + '{{appName}} アカウントのメールアドレスが変更されようとしているため、本メールをお送りしています。
この通知は変更前のアドレス宛に送信されました。', + ), + bodyText: `{{user.name}} 様 + +{{appName}} アカウントにログイン中のセッションから、アカウントのメールアドレスを +{{user.email}} から {{newEmail}} へ変更する要求がありました。 + +この変更は {{newEmail}} の確認が完了した時点で有効になります。許可するために必要な +操作はありません。本メールは通知であり、承認の依頼ではありません。 + +心当たりがない場合、アカウントが不正利用されている可能性があります。直ちに +{{appName}} の管理者またはサポートへご連絡のうえ、パスワードを変更して他の +セッションを終了してください。`, + description: '変更メールの要求が受理された際に変更前のアドレスへ送信されます(#8019)。通知のみで、変更を妨げることはありません。', +}; + +export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_ES_ES: EmailTemplate = { + ...AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE, + locale: 'es-ES', + label: 'Aviso de cambio de correo (dirección anterior)', + subject: 'Aviso de seguridad: se está cambiando el correo de tu cuenta de {{appName}}', + bodyHtml: wrap( + 'Se está cambiando el correo de tu cuenta', + ` +

Hola {{user.name}}:

+

Una sesión iniciada en tu cuenta de {{appName}} ha solicitado cambiar la dirección de correo de la cuenta de {{user.email}} a {{newEmail}}.

+

El cambio se aplicará cuando se verifique {{newEmail}}. No necesitas hacer nada para permitirlo: este mensaje es una notificación, no una solicitud de aprobación.

+

Si no has solicitado este cambio, tu cuenta podría estar comprometida. Ponte en contacto de inmediato con el administrador o el equipo de soporte de {{appName}} y cambia tu contraseña para cerrar las demás sesiones.

+`, + 'Recibes este correo porque se está cambiando la dirección de tu cuenta de {{appName}}.
Este aviso se ha enviado a la dirección anterior de la cuenta.', + ), + bodyText: `Hola {{user.name}}: + +Una sesión iniciada en tu cuenta de {{appName}} ha solicitado cambiar la +dirección de correo de la cuenta de {{user.email}} a {{newEmail}}. + +El cambio se aplicará cuando se verifique {{newEmail}}. No necesitas hacer nada +para permitirlo: este mensaje es una notificación, no una solicitud de +aprobación. + +Si no has solicitado este cambio, tu cuenta podría estar comprometida. Ponte en +contacto de inmediato con el administrador o el equipo de soporte de {{appName}} +y cambia tu contraseña para cerrar las demás sesiones.`, + description: 'Se envía a la dirección ANTERIOR cuando se acepta una solicitud de cambio de correo (#8019). Solo notificación; nunca bloquea el cambio.', +}; + +/** + * Every locale row of the change-email notice, in one list so the + * `(name, locale)` set is enumerable rather than re-derived per call site. + */ +export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES: EmailTemplate[] = [ + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_ZH_CN, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_JA_JP, + AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_ES_ES, +]; + export const BUILTIN_AUTH_TEMPLATES: EmailTemplate[] = [ AUTH_PASSWORD_RESET_TEMPLATE, AUTH_VERIFY_EMAIL_TEMPLATE, AUTH_MAGIC_LINK_TEMPLATE, AUTH_INVITATION_TEMPLATE, AUTH_TWO_FACTOR_OTP_TEMPLATE, + ...AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES, ];