From e4116323db86f1242c66e026b897cc43d9fe01ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 14:08:59 +0000 Subject: [PATCH] fix(plugin-email): sendTemplate binds renderOpts.locale to the resolved template row (#7801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `sendTemplate` call that named no `locale` resolved a concrete template row (#7731) but left `renderOpts.locale` unset, so the locale-sensitive format filters fell through to `formatValue`'s own `?? 'en-US'` default instead of following the row they were rendering into. Per the maintainer's ruling on #7801 the template row is the SINGLE locale authority; mixed-locale output — a row's body text in one locale, its dates and numbers in another — is a defect, not a feature. The seam is `email-service.ts`'s `sendTemplate` renderOpts construction: it now binds `preferred || row.locale` rather than the raw `input.locale`. Note on the card's framing: it reported the filters rendering "under the RUNTIME locale". They never did — `formatValue` hard-defaults to en-US — which is why the split stayed invisible whenever the resolved row happened to BE en-US. The observable defect is the mirror image: a bundle with no en-US row resolves e.g. zh-CN and renders en-US dates inside zh-CN body text. The ruling is unaffected; only the direction of the symptom is. Binding to `preferred` (the trimmed spelling the locale ladder actually resolved on) also fixes a second, previously unreported defect: an `input.locale` carrying whitespace resolved its row and then threw `RangeError: Incorrect locale information provided` out of `Intl`, taking the whole send down. Pins in `template-locale-resolution.test.ts` cover both halves of the ruling — the resolved row drives the filters when the caller named none, and an explicit `input.locale` still wins, including when it falls back to the en-US row. Fixes #7801 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LEZfvePJ4bpEBmvBEBEKpa --- .../sendtemplate-resolved-row-locale.md | 29 ++++ .../plugins/plugin-email/src/email-service.ts | 21 ++- .../src/template-locale-resolution.test.ts | 127 ++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 .changeset/sendtemplate-resolved-row-locale.md diff --git a/.changeset/sendtemplate-resolved-row-locale.md b/.changeset/sendtemplate-resolved-row-locale.md new file mode 100644 index 0000000000..385013bc67 --- /dev/null +++ b/.changeset/sendtemplate-resolved-row-locale.md @@ -0,0 +1,29 @@ +--- +"@objectstack/plugin-email": patch +--- + +fix(plugin-email): `sendTemplate` renders format filters in the RESOLVED template row's locale (#7801) + +A `sendTemplate` call that named no `locale` resolved a concrete template row +(#7731) but left `renderOpts.locale` **unset**, so the locale-sensitive format +filters — `{{ ts | datetime }}`, `{{ amt | number:2 }}`, `currency`, `percent`, +`date` — did not follow the row they were rendering into. The template row is +now the **single locale authority**: mixed-locale output (a row's body text in +one locale, its dates and numbers in another) is a defect, not a feature. + +What changes in practice: + +- A no-locale send that resolves a **zh-CN** row — an i18n bundle with no en-US + row at all, the locale ladder's last rung — now formats its dates and numbers + **zh-CN**. It previously rendered `3/5/26, 2:30 PM` inside zh-CN body text, + because the filters fell through to `formatValue`'s own `?? 'en-US'` default. +- A no-locale send that resolves the **en-US** row is unchanged; that case only + ever looked correct because the row's locale and the filter default happened + to coincide. +- An explicit `input.locale` **still wins** over the resolved row, including + when it has no row of its own and the ladder falls back to en-US: asking for + `fr-FR` renders the en-US body with fr-FR dates, exactly as before. +- Also fixed in passing: an `input.locale` with surrounding whitespace + (`' de-DE '`) resolved the `de-DE` row and then threw + `RangeError: Incorrect locale information provided` out of `Intl`, failing the + whole send. The render now binds the same trimmed tag the row lookup used. diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index cc22107516..7255aa1c10 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -1179,10 +1179,27 @@ export class EmailService implements IEmailService { } } - // Render holes with the recipient's locale + reference timezone so + // Render holes with the RESOLVED ROW's locale + reference timezone so // `{{ ts | datetime }}` shows the right wall-clock (ADR-0053 Phase 2). + // + // The row is the single locale authority (#7801). Leaving this unset when + // the caller named no locale handed the format filters to `formatValue`'s + // own `?? 'en-US'` default, so a no-locale send landing on a non-en-US row + // — a bundle with no en-US row at all, the ladder's last rung above — put + // en-US dates and numbers inside zh-CN body text. One artefact, one locale. + // (The card reported the mirror image, "filters follow the RUNTIME locale"; + // they never did — `formatValue` hard-defaults to en-US — which is why the + // split was invisible whenever the row itself happened to be en-US.) + // + // An explicit `input.locale` still WINS: the row is the authority only when + // the caller named nobody, so `locale: 'fr-FR'` falling back to the en-US + // row still formats fr-FR. `preferred`, not `input.locale`, because it is + // the trimmed spelling the ladder actually resolved on — a padded + // `' zh-CN '` must not reach `Intl`, which throws a RangeError on it and + // took the whole send down. + const locale = preferred || row.locale; const renderOpts = { - ...(input.locale ? { locale: input.locale } : {}), + ...(locale ? { locale } : {}), ...(input.timezone ? { timeZone: input.timezone } : {}), }; const subject = renderTemplate(row.subject, data, renderOpts); diff --git a/packages/plugins/plugin-email/src/template-locale-resolution.test.ts b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts index a4b6ccfd09..2727451e5c 100644 --- a/packages/plugins/plugin-email/src/template-locale-resolution.test.ts +++ b/packages/plugins/plugin-email/src/template-locale-resolution.test.ts @@ -256,6 +256,133 @@ describe('sendTemplate over the real loader (the #7731 reproduction)', () => { }); }); +// ── the format filters' locale (#7801) ───────────────────────────────────── +// +// #7731 (above) made the no-locale send resolve the right ROW. It left the +// render pass' `renderOpts.locale` unset, so the locale-sensitive format +// filters (`{{ ts | datetime }}`, `{{ amt | number:2 }}`) fell through to +// `formatValue`'s own `?? 'en-US'` default instead of following the row. Two +// independent locale sources in one message; the maintainer ruled the row is +// the single authority and the split is a defect. +// +// NOTE for anyone re-reading the card: its stated symptom — "format filters +// render under the RUNTIME locale" — does not hold. `formatValue` hard-defaults +// to `en-US`, never to the host's locale, so the split is invisible while the +// row happens to BE en-US. It bites the other way round: a bundle with no en-US +// row resolves (say) zh-CN and renders en-US dates inside zh-CN body text. +// That is why the pin below that fails without the fix is the zh-CN one. + +/** A row whose subject/body are made of locale-sensitive format filters. */ +function fmtRow(locale: string): Row { + return { + id: `fmt-${locale}`, + name: 'receipt', + locale, + subject: `[${locale}] {{ ts | datetime }}`, + body_html: `

{{ amt | number:2 }}

`, + active: true, + }; +} + +const TS = '2026-03-05T14:30:00Z'; +const AMT = 1234.5; +const FMT_DATA = { ts: TS, amt: AMT }; + +/** What `{{ ts | datetime }}` / `{{ amt | number:2 }}` render as under `locale`. */ +function expected(locale: string) { + return { + when: new Intl.DateTimeFormat(locale, { + dateStyle: 'short', timeStyle: 'short', timeZone: 'UTC', + }).format(new Date(TS)), + amount: new Intl.NumberFormat(locale, { + minimumFractionDigits: 2, maximumFractionDigits: 2, + }).format(AMT), + }; +} + +/** Mirror of the template engine's escaper — the rendered output is escaped. */ +const esc = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + +describe('sendTemplate — format filters follow the RESOLVED ROW\'s locale (#7801)', () => { + // Pin (a) of the ruling. Passes on `main` too, vacuously: with the locale + // unset the formatters' own default is also en-US. Kept because it is the + // half of the ruling a future "just drop the locale again" change would + // silently break once that default ever moves. + it('a no-locale send resolving the en-US row formats en-US', async () => { + const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US'), fmtRow('zh-CN')])); + + await svc.sendTemplate({ template: 'receipt', to: 'a@x.test', timezone: 'UTC', data: FMT_DATA }); + + const en = expected('en-US'); + expect(transport.sent[0].subject).toBe(`[en-US] ${esc(en.when)}`); + expect(transport.sent[0].html).toContain(esc(en.amount)); + }); + + // Pin (a), the direction that actually fails without the fix: the ladder's + // last rung resolves a non-en-US row, and the body text and the numbers in + // it must agree about which locale they are in. + it('a no-locale send resolving a zh-CN row formats zh-CN, not en-US', async () => { + const transport = new CaptureTransport(); + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.test' }, + // zh-CN-only bundle: no en-US row exists, so the ladder falls through to + // the loader's own no-locale answer and lands on zh-CN. + templateLoader: createSysEmailTemplateLoader(fakeEngine([fmtRow('zh-CN')])), + }); + + await svc.sendTemplate({ template: 'receipt', to: 'a@x.test', timezone: 'UTC', data: FMT_DATA }); + + const zh = expected('zh-CN'); + expect(transport.sent[0].subject).toBe(`[zh-CN] ${esc(zh.when)}`); + expect(transport.sent[0].subject).not.toContain(esc(expected('en-US').when)); + }); + + // Pin (b): the row is the authority only when the caller named NOBODY. + it('an explicit input.locale still wins over the resolved row', async () => { + const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US'), fmtRow('de-DE')])); + + await svc.sendTemplate({ + template: 'receipt', to: 'a@x.test', locale: 'de-DE', timezone: 'UTC', data: FMT_DATA, + }); + + const de = expected('de-DE'); + expect(transport.sent[0].subject).toBe(`[de-DE] ${esc(de.when)}`); + expect(transport.sent[0].html).toContain(esc(de.amount)); + }); + + // Pin (b), the sharp edge: the caller's locale has no row, so the ladder + // renders the en-US ROW — and the caller's locale must still drive the + // filters. This is the assertion that stops the fix from over-reaching into + // "the row always wins". + it('an explicit locale with no row still formats in THAT locale, on the en-US row', async () => { + const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US')])); + + await svc.sendTemplate({ + template: 'receipt', to: 'a@x.test', locale: 'de-DE', timezone: 'UTC', data: FMT_DATA, + }); + + const de = expected('de-DE'); + expect(transport.sent[0].subject).toBe(`[en-US] ${esc(de.when)}`); + expect(transport.sent[0].html).toContain(esc(de.amount)); + }); + + // Falls out of binding to `preferred` (the trimmed spelling the ladder + // resolved on) rather than to the raw `input.locale`: `Intl` throws a + // RangeError on a padded tag, which would have taken the whole send down. + it('a padded explicit locale renders rather than throwing out of Intl', async () => { + const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US'), fmtRow('de-DE')])); + + await svc.sendTemplate({ + template: 'receipt', to: 'a@x.test', locale: ' de-DE ', timezone: 'UTC', data: FMT_DATA, + }); + + expect(transport.sent[0].subject).toBe(`[de-DE] ${esc(expected('de-DE').when)}`); + }); +}); + // ── the wiring: the plugin must install THIS loader ──────────────────────── describe('EmailServicePlugin wiring', () => {