From 1df3e4a87c1e74f2a5e16a0fd4704e4f5753d3e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:39:50 +0000 Subject: [PATCH 1/2] fix(plugin-email): a no-locale sendTemplate renders the en-US default, not an arbitrary row (#7731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With an i18n bundle in `sys_email_template` (`en-US` + `zh-CN` rows under one name), a `sendTemplate` call that named no `locale` rendered zh-CN — on two consecutive fresh boots. Three declarations say en-US is the answer there (`SendTemplateInput.locale`, `EmailTemplateDefinitionSchema.locale`, and `sys_email_template`'s own object doc); the chain asked the driver instead, in two places that each had to be right for the default to hold: - `EmailServicePlugin`'s inline loader built `where = { name }` and only added `locale` when one was passed, then ran `limit: 1` with NO ordering. "First row of an unordered set" is whatever the driver yields. - `EmailService.sendTemplate`'s en-US fallback ran only when a locale HAD been named, so the no-locale path never reached it. The loader moves to its own module, `createSysEmailTemplateLoader`, whose every branch pins the row it wants in the `where` clause and carries an `orderBy`, so neither locale selection nor duplicate-row tie-breaking depends on storage order. `sendTemplate` asks for `DEFAULT_TEMPLATE_LOCALE` by name when the caller named none. A bundle with no en-US row at all keeps rendering — a single-locale tenant had exactly one row to pick arbitrarily from before, and hard-failing it now would swap one bug for an outage — but it resolves to the bundle's lowest locale tag, ordered rather than arbitrary. Explicit locales are unchanged: exact match, then en-US. Language-only prefix matching (`zh` → `zh-CN`) is still not performed; no contract declares it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BsLtjETXfWzP78bMxkyXmB --- .changeset/email-template-default-locale.md | 28 ++ .../plugins/plugin-email/src/email-plugin.ts | 20 +- .../plugins/plugin-email/src/email-service.ts | 43 ++- packages/plugins/plugin-email/src/index.ts | 13 +- .../plugin-email/src/template-loader.ts | 119 +++++++ .../src/template-locale-resolution.test.ts | 291 ++++++++++++++++++ 6 files changed, 494 insertions(+), 20 deletions(-) create mode 100644 .changeset/email-template-default-locale.md create mode 100644 packages/plugins/plugin-email/src/template-loader.ts create mode 100644 packages/plugins/plugin-email/src/template-locale-resolution.test.ts diff --git a/.changeset/email-template-default-locale.md b/.changeset/email-template-default-locale.md new file mode 100644 index 0000000000..930c6331c6 --- /dev/null +++ b/.changeset/email-template-default-locale.md @@ -0,0 +1,28 @@ +--- +"@objectstack/plugin-email": patch +--- + +fix(plugin-email): a `sendTemplate` with no locale renders the documented en-US default, not an arbitrary row (#7731) + +With an i18n bundle in `sys_email_template` — `en-US` and `zh-CN` rows under one +name — a `sendTemplate` call that named no `locale` rendered **zh-CN**, on two +consecutive fresh boots. Three declarations say en-US is the answer there +(`SendTemplateInput.locale`, `EmailTemplateDefinitionSchema.locale`, and +`sys_email_template`'s own object doc); the code asked the driver instead. + +Two seams, both now answering from the contract: + +- The `sys_email_template` loader moved out of `EmailServicePlugin` into + `createSysEmailTemplateLoader`. Its no-locale branch queries + `(name, 'en-US')` by name rather than `{ name }` unordered with `limit: 1`, + so no driver's row order can change the answer. Every query it issues carries + an `orderBy`, so duplicate rows for one locale resolve the same way on every + boot too. +- `EmailService.sendTemplate`'s ladder asks for `DEFAULT_TEMPLATE_LOCALE` when + the caller named no locale — the en-US fallback used to run only when a + locale *had* been named, so the no-locale path never reached it. + +A bundle with no en-US row at all (a single-locale tenant) keeps rendering: +the lowest locale tag in the bundle is used, ordered rather than arbitrary. +Explicit locales are unchanged — exact match, then en-US. Language-only prefix +matching (`zh` → `zh-CN`) is still not performed; no contract declares it. diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 4e8f619846..905ee90913 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -17,8 +17,8 @@ import { type EmailPersistence, type EmailQueueDelivery, type TemplateLoader, - type EmailTemplateRow, } from './email-service.js'; +import { createSysEmailTemplateLoader } from './template-loader.js'; import { makeTransport, SmtpTransport, @@ -544,19 +544,11 @@ export class EmailServicePlugin implements Plugin { }, }; - const templateLoader: TemplateLoader = { - async load(name, locale) { - const where: Record = { name }; - if (locale) where.locale = locale; - const rows = await (engine as any).find('sys_email_template', { - where, - limit: 1, - context: SYSTEM_CTX, - }); - const row = Array.isArray(rows) ? rows[0] : (rows as any)?.data?.[0]; - return (row as EmailTemplateRow) || null; - }, - }; + // Locale resolution lives in its own module (#7731) — the inline version + // here queried `{ name }` unordered with `limit: 1` whenever no locale + // was passed, which answered an i18n bundle with whatever row the driver + // yielded first instead of the documented en-US default. + const templateLoader: TemplateLoader = createSysEmailTemplateLoader(engine as any); // Mutate the existing service instance so consumers that already // captured a reference (e.g. AuthManager) see the upgrade. diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index b6513b3b4e..cc22107516 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -378,10 +378,28 @@ function newId(): string { return `${hex(8)}-${hex(4)}-4${hex(3)}-a${hex(3)}-${hex(12)}`; } +/** + * The locale a template resolves to when the caller names none. + * + * Declared, not inferred: `SendTemplateInput.locale` (spec contract), + * `EmailTemplateDefinitionSchema.locale` and the `sys_email_template` object + * doc all say the service falls back to `en-US`. #7731 is what happened while + * one seam of the chain answered "whichever row the driver yields first" + * instead — a no-locale send rendered zh-CN out of an en-US + zh-CN bundle. + */ +export const DEFAULT_TEMPLATE_LOCALE = 'en-US'; + /** * Loader for sys_email_template rows. Injected by EmailServicePlugin * on `kernel:ready`. Returns the best-matching row for `(name, locale)` * or `null` when none exists / inactive. + * + * `locale` set → an EXACT match for that locale, or `null`; the en-US fallback + * lives in {@link EmailService.sendTemplate}'s ladder rather than in the + * loader, so a replacement loader cannot relocate the documented default. + * `locale` undefined → the loader's own deterministic best answer for the name + * (see `createSysEmailTemplateLoader`), which `sendTemplate` consults only as a + * last resort, when the bundle carries no en-US row at all. */ export interface TemplateLoader { load(name: string, locale: string | undefined): Promise; @@ -1108,7 +1126,8 @@ export class EmailService implements IEmailService { /** * Render a named template from sys_email_template and deliver via - * send(). Looks up `(name, locale)` then falls back to `(name, 'en-US')`. + * send(). Looks up `(name, locale)` then falls back to + * `(name, {@link DEFAULT_TEMPLATE_LOCALE})`. */ async sendTemplate(input: SendTemplateInput): Promise { if (!input?.template) { @@ -1118,13 +1137,27 @@ export class EmailService implements IEmailService { if (!loader) { throw new Error('TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService'); } + // Locale ladder (#7731). The old first rung passed `undefined` to the + // loader whenever the caller named no locale, which is not a request for + // en-US — it is a request for *any* row, and that is exactly what came + // back (zh-CN, out of an en-US + zh-CN bundle, on two fresh boots). No + // locale means the DOCUMENTED default, so ask for it by name. const preferred = input.locale && String(input.locale).trim(); - let row = await loader.load(input.template, preferred || undefined); - if (!row && preferred && preferred !== 'en-US') { - row = await loader.load(input.template, 'en-US'); + 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); + } + if (!row && !preferred) { + // A bundle with no en-US row at all. A caller that never named a locale + // did get *some* row here before (a zh-CN-only tenant is the realistic + // case) and hard-failing it now would swap one bug for an outage — so + // fall through to the loader's own no-locale answer, which is + // deterministic by contract rather than driver row order. + row = await loader.load(input.template, undefined); } if (!row) { - throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${preferred || 'en-US'})`); + throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${wanted})`); } if (row.active === false) { throw new Error(`TEMPLATE_INACTIVE: ${input.template}`); diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 28df12b7bc..9bc36bd835 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -16,7 +16,18 @@ export { EmailServicePlugin, resolveDurableQueue, resolveAttachmentStore } from './email-plugin.js'; export type { EmailServicePluginOptions } from './email-plugin.js'; -export { LogTransport, normalizeMessage, formatAddress, EMAIL_SEND_QUEUE } from './email-service.js'; +export { + LogTransport, + normalizeMessage, + formatAddress, + EMAIL_SEND_QUEUE, + DEFAULT_TEMPLATE_LOCALE, +} from './email-service.js'; +export { + createSysEmailTemplateLoader, + type TemplateLoaderEngine, + type CreateTemplateLoaderOptions, +} from './template-loader.js'; export type { EmailServiceOptions, TemplateLoader, diff --git a/packages/plugins/plugin-email/src/template-loader.ts b/packages/plugins/plugin-email/src/template-loader.ts new file mode 100644 index 0000000000..b34a67cbde --- /dev/null +++ b/packages/plugins/plugin-email/src/template-loader.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `sys_email_template` {@link TemplateLoader} — locale resolution that + * answers from the DECLARED contract instead of from driver row order (#7731). + * + * ## What was wrong + * + * The loader used to be four lines inline in `EmailServicePlugin`: build + * `where = { name }`, add `where.locale` only when a locale was passed, then + * `find(..., { limit: 1 })` with **no ordering**. "First row of an unordered + * set" is whatever the driver happens to yield — so an i18n bundle with `en-US` + * and `zh-CN` rows under one name rendered `zh-CN` for a caller that named no + * locale at all, on two consecutive fresh boots. Nothing in the system declares + * that; three separate places declare the opposite: + * + * - `SendTemplateInput.locale` (spec contract): *"Falls back to `'en-US'`"*. + * - `EmailTemplateDefinitionSchema.locale`: *"the service picks the best match + * for the recipient's locale, falling back to `en-US`"*. + * - `sys_email_template`'s own object doc: *"Resolved by `(name, locale)`; the + * EmailService picks the best-matching locale for the recipient, falling + * back to `en-US`"*. + * + * ## The rule this implements + * + * Every branch below pins the row it wants in the `where` clause; none of them + * asks the store to pick a locale for us: + * + * 1. **A locale was named** → exact `(name, locale)` match, or `null`. The + * en-US fallback stays where it is documented — in `sendTemplate`'s ladder + * — so a replacement loader cannot silently relocate it. + * 2. **No locale** → `(name, 'en-US')`, the documented default. This is the + * #7731 fix: the *query* names en-US, so even a driver that honours no + * ordering at all cannot answer with a different locale. + * 3. **No locale and no en-US row** → the bundle's lowest locale tag, ordered. + * A store holding only `zh-CN` rows worked before this change (there was + * exactly one row to pick arbitrarily from), and refusing it now would + * trade one bug for an outage. Ordered rather than arbitrary, so the answer + * is the same on every boot. + * + * Language-only prefix matching (`zh` → `zh-CN`) is deliberately NOT here: no + * contract declares it, and inventing it would put a resolution rule in the + * code that no spec, doc or form describes. + * + * Ordering is spelled `orderBy` — the canonical engine key. `sort` is the wire + * spelling and `find()` rejects it outright, so a tie-break has to be spelled + * this way to exist at all. + */ + +import { DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; +import { EMAIL_TEMPLATE_OBJECT } from './bootstrap-declared-email-templates.js'; + +/** System read context — template resolution is not an end-user query. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** + * The slice of the ObjectQL engine this loader calls. Declared here rather than + * imported as `IDataEngine` so the seam a test has to fake is one method. + */ +export interface TemplateLoaderEngine { + find(object: string, query: Record): Promise; +} + +/** Sort node shape as the engine spells it (`orderBy`, never the wire `sort`). */ +interface TemplateSort { field: string; order: 'asc' | 'desc' } + +/** + * Tie-break for a query that already pins `(name, locale)`: duplicates of one + * locale (an org overlay row landing beside the platform one) must still + * resolve the same way on every boot. + */ +const BY_ID: readonly TemplateSort[] = [{ field: 'id', order: 'asc' }]; + +/** Deterministic order for the unpinned last resort — lowest locale tag wins. */ +const BY_LOCALE: readonly TemplateSort[] = [ + { field: 'locale', order: 'asc' }, + { field: 'id', order: 'asc' }, +]; + +/** Options for {@link createSysEmailTemplateLoader}. */ +export interface CreateTemplateLoaderOptions { + /** Backing object name; overridable for tests. */ + object?: string; +} + +/** + * Build the `sys_email_template` loader `EmailService` resolves templates + * through. See the module doc for the resolution rule it implements. + */ +export function createSysEmailTemplateLoader( + engine: TemplateLoaderEngine, + options: CreateTemplateLoaderOptions = {}, +): TemplateLoader { + const object = options.object ?? EMAIL_TEMPLATE_OBJECT; + + const first = async ( + where: Record, + orderBy: readonly TemplateSort[], + ): Promise => { + const rows = await engine.find(object, { + where, + orderBy: orderBy.map((s) => ({ ...s })), + limit: 1, + context: SYSTEM_CTX, + }); + const row = Array.isArray(rows) ? rows[0] : (rows as any)?.data?.[0]; + return (row as EmailTemplateRow) || null; + }; + + return { + async load(name, locale) { + if (locale) return first({ name, locale }, BY_ID); + const preferred = await first({ name, locale: DEFAULT_TEMPLATE_LOCALE }, BY_ID); + if (preferred) return preferred; + // No en-US row in this bundle — rule 3 in the module doc. + return first({ name }, BY_LOCALE); + }, + }; +} diff --git a/packages/plugins/plugin-email/src/template-locale-resolution.test.ts b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts new file mode 100644 index 0000000000..a4b6ccfd09 --- /dev/null +++ b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Locale resolution for `sendTemplate` (#7731). +// +// The bug these pin: with an i18n bundle (`en-US` + `zh-CN` rows under one +// name) a send that named NO locale rendered zh-CN — on two consecutive fresh +// boots — because the loader's no-locale query was an unordered `limit: 1` and +// the service's en-US fallback only ran when a locale HAD been named. Both +// seams are covered here, and the determinism assertions run the same bundle +// through drivers that disagree about row order — including one that honours +// no ordering at all — because "returns en-US on this machine today" is the +// property the old code also had. + +import { describe, it, expect } from 'vitest'; +import { EmailService, DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js'; +import { createSysEmailTemplateLoader, type TemplateLoaderEngine } from './template-loader.js'; +import { EMAIL_TEMPLATE_OBJECT } from './bootstrap-declared-email-templates.js'; +import { EmailServicePlugin } from './email-plugin.js'; +import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; + +// ── harness ──────────────────────────────────────────────────────────────── + +class CaptureTransport implements IEmailTransport { + public sent: NormalizedEmailMessage[] = []; + async send(message: NormalizedEmailMessage): Promise { + this.sent.push(message); + return { messageId: `msg-${this.sent.length}` }; + } +} + +type Row = EmailTemplateRow & { id: string }; + +/** One `sys_email_template` row per locale of the `welcome` bundle. */ +function row(locale: string, id = `row-${locale}`): Row { + return { + id, + name: 'welcome', + locale, + subject: `[${locale}] Hello {{name}}`, + body_html: `

[${locale}] Hi {{name}}

`, + active: true, + }; +} + +const EN = row('en-US'); +const ZH = row('zh-CN'); +const FR = row('fr-FR'); + +interface FakeEngineOptions { + /** Emulate a driver that ignores `orderBy` and answers in storage order. */ + ignoreOrderBy?: boolean; +} + +/** + * A driver-ish engine: filters by `where`, applies `orderBy` when it honours + * it, then `limit`. Rows are answered in the order they were handed in, so a + * test can make "storage order" whatever it likes. + */ +function fakeEngine(rows: Row[], options: FakeEngineOptions = {}) { + const queries: Array> = []; + const engine: TemplateLoaderEngine & { queries: typeof queries } = { + queries, + async find(object: string, query: Record) { + expect(object).toBe(EMAIL_TEMPLATE_OBJECT); + queries.push(query); + const where = (query.where ?? {}) as Record; + let out = rows.filter((r) => Object.entries(where).every(([k, v]) => (r as any)[k] === v)); + if (!options.ignoreOrderBy && Array.isArray(query.orderBy)) { + out = [...out].sort((a, b) => { + for (const { field, order } of query.orderBy as Array<{ field: string; order: string }>) { + const av = String((a as any)[field] ?? ''); + const bv = String((b as any)[field] ?? ''); + if (av !== bv) return (av < bv ? -1 : 1) * (order === 'desc' ? -1 : 1); + } + return 0; + }); + } + return typeof query.limit === 'number' ? out.slice(0, query.limit) : out; + }, + }; + return engine; +} + +/** A loader that ONLY does exact matches — no fallback of its own. */ +function exactLoader(rows: Row[], calls: Array = []): TemplateLoader & { calls: typeof calls } { + return { + calls, + async load(name, locale) { + calls.push(locale); + return rows.find((r) => r.name === name && r.locale === locale) ?? null; + }, + }; +} + +function serviceWith(loader: TemplateLoader) { + const transport = new CaptureTransport(); + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.test' }, + templateLoader: loader, + }); + return { svc, transport }; +} + +// ── the loader seam (email-plugin.ts ~:548) ──────────────────────────────── + +describe('createSysEmailTemplateLoader — no locale means en-US, not "first row"', () => { + // The regression, both ways round: which row the store happens to hold + // first is exactly the input the old code was reading. + it.each([ + ['en-US stored first', [EN, ZH]], + ['zh-CN stored first', [ZH, EN]], + ])('resolves the en-US row when no locale is passed (%s)', async (_label, rows) => { + const loader = createSysEmailTemplateLoader(fakeEngine(rows as Row[])); + const found = await loader.load('welcome', undefined); + expect(found?.locale).toBe('en-US'); + }); + + it('pins en-US in the WHERE clause, so a driver that ignores orderBy still answers en-US', async () => { + const engine = fakeEngine([ZH, EN], { ignoreOrderBy: true }); + const loader = createSysEmailTemplateLoader(engine); + + expect((await loader.load('welcome', undefined))?.locale).toBe('en-US'); + expect(engine.queries[0].where).toEqual({ name: 'welcome', locale: DEFAULT_TEMPLATE_LOCALE }); + // …and it never asks an unordered question. + for (const q of engine.queries) expect(q.orderBy).toBeTruthy(); + }); + + it('never sends the wire spelling `sort` — find() rejects it, so the tie-break must be orderBy', async () => { + const engine = fakeEngine([EN, ZH]); + await createSysEmailTemplateLoader(engine).load('welcome', undefined); + for (const q of engine.queries) expect(q.sort).toBeUndefined(); + }); + + it('an explicit locale is an EXACT match — no fallback of the loader\'s own', async () => { + const loader = createSysEmailTemplateLoader(fakeEngine([EN, ZH])); + expect((await loader.load('welcome', 'zh-CN'))?.locale).toBe('zh-CN'); + // fr-FR has no row: the loader answers null and leaves the en-US fallback + // to sendTemplate's ladder, where it is documented. + expect(await loader.load('welcome', 'fr-FR')).toBeNull(); + }); + + it('resolves the same row on repeat calls, with duplicate rows for one locale', async () => { + const dupes = [ + { ...row('en-US', 'row-b'), subject: 'B' }, + { ...row('en-US', 'row-a'), subject: 'A' }, + ]; + const loader = createSysEmailTemplateLoader(fakeEngine(dupes)); + const first = await loader.load('welcome', undefined); + const second = await loader.load('welcome', 'en-US'); + expect(first?.subject).toBe('A'); + expect(second?.subject).toBe('A'); + }); + + describe('a bundle with no en-US row at all', () => { + it.each([ + ['fr-FR stored first', [FR, ZH]], + ['zh-CN stored first', [ZH, FR]], + ])('falls back to the lowest locale tag, identically in both storage orders (%s)', async (_l, rows) => { + const loader = createSysEmailTemplateLoader(fakeEngine(rows as Row[])); + expect((await loader.load('welcome', undefined))?.locale).toBe('fr-FR'); + }); + + it('keeps a single-locale tenant working rather than resolving to nothing', async () => { + const loader = createSysEmailTemplateLoader(fakeEngine([ZH])); + expect((await loader.load('welcome', undefined))?.locale).toBe('zh-CN'); + }); + }); +}); + +// ── the service seam (email-service.ts ~:1121) ───────────────────────────── + +describe('EmailService.sendTemplate — locale ladder', () => { + it('asks for en-US by name when the caller named no locale', async () => { + const loader = exactLoader([EN, ZH]); + const { svc, transport } = serviceWith(loader); + + await svc.sendTemplate({ template: 'welcome', to: 'a@x.test', data: { name: 'Ada' } }); + + expect(loader.calls).toEqual(['en-US']); + expect(transport.sent[0].subject).toBe('[en-US] Hello Ada'); + }); + + it('an explicit exact match is unaffected — one lookup, that locale', async () => { + const loader = exactLoader([EN, ZH]); + const { svc, transport } = serviceWith(loader); + + await svc.sendTemplate({ template: 'welcome', to: 'a@x.test', locale: 'zh-CN', data: { name: 'Ada' } }); + + expect(loader.calls).toEqual(['zh-CN']); + expect(transport.sent[0].subject).toBe('[zh-CN] Hello Ada'); + }); + + it('an explicit locale with no row still falls back to en-US', async () => { + const loader = exactLoader([EN, ZH]); + const { svc, transport } = serviceWith(loader); + + await svc.sendTemplate({ template: 'welcome', to: 'a@x.test', locale: 'fr-FR', data: { name: 'Ada' } }); + + expect(loader.calls).toEqual(['fr-FR', 'en-US']); + expect(transport.sent[0].subject).toBe('[en-US] Hello Ada'); + }); + + it('a no-locale send consults the loader\'s own answer only when en-US is missing', async () => { + const loader = exactLoader([ZH]); + const { svc } = serviceWith(loader); + + // The exact loader has no answer for `undefined`, so this send fails — + // what it pins is the ORDER: en-US first, the loader's own answer last. + await expect(svc.sendTemplate({ template: 'welcome', to: 'a@x.test', data: { name: 'Ada' } })) + .rejects.toThrow('TEMPLATE_NOT_FOUND: welcome (locale=en-US)'); + expect(loader.calls).toEqual(['en-US', undefined]); + }); + + it('an explicit locale is NOT widened to "any row" when neither it nor en-US exists', async () => { + const loader = exactLoader([ZH]); + const { svc } = serviceWith(loader); + + await expect(svc.sendTemplate({ template: 'welcome', to: 'a@x.test', locale: 'fr-FR', data: { name: 'Ada' } })) + .rejects.toThrow('TEMPLATE_NOT_FOUND: welcome (locale=fr-FR)'); + expect(loader.calls).toEqual(['fr-FR', 'en-US']); + }); +}); + +// ── end to end: the reported reproduction ────────────────────────────────── + +describe('sendTemplate over the real loader (the #7731 reproduction)', () => { + it.each([ + ['en-US inserted first', [EN, ZH]], + ['zh-CN inserted first', [ZH, EN]], + ])('renders en-US for a no-locale send on a fresh boot (%s)', async (_l, rows) => { + const transport = new CaptureTransport(); + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.test' }, + templateLoader: createSysEmailTemplateLoader(fakeEngine(rows as Row[])), + }); + + await svc.sendTemplate({ template: 'welcome', to: 'a@x.test', data: { name: 'Ada' } }); + + expect(transport.sent[0].subject).toBe('[en-US] Hello Ada'); + expect(transport.sent[0].html).toContain('[en-US] Hi Ada'); + }); + + it('still renders zh-CN when the caller asks for it', async () => { + const transport = new CaptureTransport(); + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.test' }, + templateLoader: createSysEmailTemplateLoader(fakeEngine([EN, ZH])), + }); + + await svc.sendTemplate({ template: 'welcome', to: 'a@x.test', locale: 'zh-CN', data: { name: 'Ada' } }); + + expect(transport.sent[0].subject).toBe('[zh-CN] Hello Ada'); + }); +}); + +// ── the wiring: the plugin must install THIS loader ──────────────────────── + +describe('EmailServicePlugin wiring', () => { + it('installs the deterministic loader on kernel:ready', async () => { + const rows = [ZH, EN]; + const engine = fakeEngine(rows); + const hooks: Record Promise | void>> = {}; + const services: Record = { + manifest: { register: () => {} }, + objectql: engine, + }; + const ctx = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`service '${name}' not registered`); + return services[name] as T; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + hook: (name: string, fn: () => Promise | void) => { (hooks[name] ??= []).push(fn); }, + }; + + const plugin = new EmailServicePlugin({ seedTemplates: false, persist: false }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + for (const fn of hooks['kernel:ready'] ?? []) await fn(); + + const service = services.email as EmailService; + const loader = service.options.templateLoader; + expect(loader).toBeTruthy(); + expect((await loader!.load('welcome', undefined))?.locale).toBe('en-US'); + expect(engine.queries[engine.queries.length - 1]?.where).toEqual({ name: 'welcome', locale: 'en-US' }); + }); +}); From dc1798c9462062a127dcaf8d300aa5005cd74789 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:43:42 +0000 Subject: [PATCH 2/2] docs(spec): declare sendTemplate's locale ladder where the contract lives (#7731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SendTemplateInput.locale` said "Falls back to `'en-US'`" and the `TEMPLATE_NOT_FOUND` bullet said "no row matches `(name, locale|en-US)`" — true, but silent about the two rungs a caller actually depends on: that a call omitting `locale` STARTS at en-US rather than at an arbitrary row, and what happens to a bundle that has no en-US row at all. Behaviour a caller can rely on has to be declared where the contract is, not only where it is implemented. Doc-comment only; no schema, no shape, no behaviour change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BsLtjETXfWzP78bMxkyXmB --- .changeset/email-template-default-locale.md | 5 +++++ packages/spec/src/contracts/email-service.ts | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.changeset/email-template-default-locale.md b/.changeset/email-template-default-locale.md index 930c6331c6..dc64bf912b 100644 --- a/.changeset/email-template-default-locale.md +++ b/.changeset/email-template-default-locale.md @@ -1,5 +1,6 @@ --- "@objectstack/plugin-email": patch +"@objectstack/spec": patch --- fix(plugin-email): a `sendTemplate` with no locale renders the documented en-US default, not an arbitrary row (#7731) @@ -26,3 +27,7 @@ A bundle with no en-US row at all (a single-locale tenant) keeps rendering: the lowest locale tag in the bundle is used, ordered rather than arbitrary. Explicit locales are unchanged — exact match, then en-US. Language-only prefix matching (`zh` → `zh-CN`) is still not performed; no contract declares it. + +`SendTemplateInput.locale` (spec, doc-comment only) now spells the whole ladder +out, including that last rung — behaviour a caller can rely on has to be +declared where the contract is, not only where it is implemented. diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 46a318edae..3babddaa17 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -140,7 +140,20 @@ export interface SendTemplateInput { to: EmailAddress | EmailAddress[]; /** Render context — placeholders in subject/body are resolved against this object. */ data?: Record; - /** Preferred BCP-47 locale (e.g. user's locale). Falls back to `'en-US'`. */ + /** + * Preferred BCP-47 locale (e.g. user's locale). Falls back to `'en-US'`. + * + * Resolution is exact, then default, then deterministic — never "whichever + * row the store yields first" (#7731): + * + * 1. this locale, matched exactly (no language-only prefix matching: `zh` + * does not resolve `zh-CN`); + * 2. `'en-US'` — which is also where a call that omits `locale` STARTS, so + * "no locale" means the default rather than an arbitrary row; + * 3. only for a call that named no locale, and only when the bundle has no + * `en-US` row at all: its lowest locale tag, so a single-locale tenant + * keeps rendering and does so identically on every boot. + */ locale?: string; /** * Reference timezone (IANA name, e.g. `America/New_York`) for rendering @@ -187,8 +200,11 @@ export interface IEmailService { * Resolve a named template from `sys_email_template`, render its * subject/body against `input.data`, then deliver via `send()`. * + * Locale resolution is the ladder on {@link SendTemplateInput.locale}. + * * Errors: - * - `TEMPLATE_NOT_FOUND` — no row matches `(name, locale|en-US)`. + * - `TEMPLATE_NOT_FOUND` — no row matches `(name, locale|en-US)`, and (for a + * call that named no locale) the name carries no rows at all. * - `TEMPLATE_INACTIVE` — row exists but `active=false`. * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. */