Skip to content

Commit 531fb31

Browse files
huangyiireneclaude
andauthored
fix(plugin-email): sendTemplate binds renderOpts.locale to the resolved template row (#7801) (#8064)
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 Claude-Session: https://claude.ai/code/session_01LEZfvePJ4bpEBmvBEBEKpa Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d01f0e commit 531fb31

3 files changed

Lines changed: 175 additions & 2 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
fix(plugin-email): `sendTemplate` renders format filters in the RESOLVED template row's locale (#7801)
6+
7+
A `sendTemplate` call that named no `locale` resolved a concrete template row
8+
(#7731) but left `renderOpts.locale` **unset**, so the locale-sensitive format
9+
filters — `{{ ts | datetime }}`, `{{ amt | number:2 }}`, `currency`, `percent`,
10+
`date` — did not follow the row they were rendering into. The template row is
11+
now the **single locale authority**: mixed-locale output (a row's body text in
12+
one locale, its dates and numbers in another) is a defect, not a feature.
13+
14+
What changes in practice:
15+
16+
- A no-locale send that resolves a **zh-CN** row — an i18n bundle with no en-US
17+
row at all, the locale ladder's last rung — now formats its dates and numbers
18+
**zh-CN**. It previously rendered `3/5/26, 2:30 PM` inside zh-CN body text,
19+
because the filters fell through to `formatValue`'s own `?? 'en-US'` default.
20+
- A no-locale send that resolves the **en-US** row is unchanged; that case only
21+
ever looked correct because the row's locale and the filter default happened
22+
to coincide.
23+
- An explicit `input.locale` **still wins** over the resolved row, including
24+
when it has no row of its own and the ladder falls back to en-US: asking for
25+
`fr-FR` renders the en-US body with fr-FR dates, exactly as before.
26+
- Also fixed in passing: an `input.locale` with surrounding whitespace
27+
(`' de-DE '`) resolved the `de-DE` row and then threw
28+
`RangeError: Incorrect locale information provided` out of `Intl`, failing the
29+
whole send. The render now binds the same trimmed tag the row lookup used.

packages/plugins/plugin-email/src/email-service.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,10 +1179,27 @@ export class EmailService implements IEmailService {
11791179
}
11801180
}
11811181

1182-
// Render holes with the recipient's locale + reference timezone so
1182+
// Render holes with the RESOLVED ROW's locale + reference timezone so
11831183
// `{{ ts | datetime }}` shows the right wall-clock (ADR-0053 Phase 2).
1184+
//
1185+
// The row is the single locale authority (#7801). Leaving this unset when
1186+
// the caller named no locale handed the format filters to `formatValue`'s
1187+
// own `?? 'en-US'` default, so a no-locale send landing on a non-en-US row
1188+
// — a bundle with no en-US row at all, the ladder's last rung above — put
1189+
// en-US dates and numbers inside zh-CN body text. One artefact, one locale.
1190+
// (The card reported the mirror image, "filters follow the RUNTIME locale";
1191+
// they never did — `formatValue` hard-defaults to en-US — which is why the
1192+
// split was invisible whenever the row itself happened to be en-US.)
1193+
//
1194+
// An explicit `input.locale` still WINS: the row is the authority only when
1195+
// the caller named nobody, so `locale: 'fr-FR'` falling back to the en-US
1196+
// row still formats fr-FR. `preferred`, not `input.locale`, because it is
1197+
// the trimmed spelling the ladder actually resolved on — a padded
1198+
// `' zh-CN '` must not reach `Intl`, which throws a RangeError on it and
1199+
// took the whole send down.
1200+
const locale = preferred || row.locale;
11841201
const renderOpts = {
1185-
...(input.locale ? { locale: input.locale } : {}),
1202+
...(locale ? { locale } : {}),
11861203
...(input.timezone ? { timeZone: input.timezone } : {}),
11871204
};
11881205
const subject = renderTemplate(row.subject, data, renderOpts);

packages/plugins/plugin-email/src/template-locale-resolution.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,133 @@ describe('sendTemplate over the real loader (the #7731 reproduction)', () => {
256256
});
257257
});
258258

259+
// ── the format filters' locale (#7801) ─────────────────────────────────────
260+
//
261+
// #7731 (above) made the no-locale send resolve the right ROW. It left the
262+
// render pass' `renderOpts.locale` unset, so the locale-sensitive format
263+
// filters (`{{ ts | datetime }}`, `{{ amt | number:2 }}`) fell through to
264+
// `formatValue`'s own `?? 'en-US'` default instead of following the row. Two
265+
// independent locale sources in one message; the maintainer ruled the row is
266+
// the single authority and the split is a defect.
267+
//
268+
// NOTE for anyone re-reading the card: its stated symptom — "format filters
269+
// render under the RUNTIME locale" — does not hold. `formatValue` hard-defaults
270+
// to `en-US`, never to the host's locale, so the split is invisible while the
271+
// row happens to BE en-US. It bites the other way round: a bundle with no en-US
272+
// row resolves (say) zh-CN and renders en-US dates inside zh-CN body text.
273+
// That is why the pin below that fails without the fix is the zh-CN one.
274+
275+
/** A row whose subject/body are made of locale-sensitive format filters. */
276+
function fmtRow(locale: string): Row {
277+
return {
278+
id: `fmt-${locale}`,
279+
name: 'receipt',
280+
locale,
281+
subject: `[${locale}] {{ ts | datetime }}`,
282+
body_html: `<p>{{ amt | number:2 }}</p>`,
283+
active: true,
284+
};
285+
}
286+
287+
const TS = '2026-03-05T14:30:00Z';
288+
const AMT = 1234.5;
289+
const FMT_DATA = { ts: TS, amt: AMT };
290+
291+
/** What `{{ ts | datetime }}` / `{{ amt | number:2 }}` render as under `locale`. */
292+
function expected(locale: string) {
293+
return {
294+
when: new Intl.DateTimeFormat(locale, {
295+
dateStyle: 'short', timeStyle: 'short', timeZone: 'UTC',
296+
}).format(new Date(TS)),
297+
amount: new Intl.NumberFormat(locale, {
298+
minimumFractionDigits: 2, maximumFractionDigits: 2,
299+
}).format(AMT),
300+
};
301+
}
302+
303+
/** Mirror of the template engine's escaper — the rendered output is escaped. */
304+
const esc = (s: string) => s
305+
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
306+
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
307+
308+
describe('sendTemplate — format filters follow the RESOLVED ROW\'s locale (#7801)', () => {
309+
// Pin (a) of the ruling. Passes on `main` too, vacuously: with the locale
310+
// unset the formatters' own default is also en-US. Kept because it is the
311+
// half of the ruling a future "just drop the locale again" change would
312+
// silently break once that default ever moves.
313+
it('a no-locale send resolving the en-US row formats en-US', async () => {
314+
const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US'), fmtRow('zh-CN')]));
315+
316+
await svc.sendTemplate({ template: 'receipt', to: 'a@x.test', timezone: 'UTC', data: FMT_DATA });
317+
318+
const en = expected('en-US');
319+
expect(transport.sent[0].subject).toBe(`[en-US] ${esc(en.when)}`);
320+
expect(transport.sent[0].html).toContain(esc(en.amount));
321+
});
322+
323+
// Pin (a), the direction that actually fails without the fix: the ladder's
324+
// last rung resolves a non-en-US row, and the body text and the numbers in
325+
// it must agree about which locale they are in.
326+
it('a no-locale send resolving a zh-CN row formats zh-CN, not en-US', async () => {
327+
const transport = new CaptureTransport();
328+
const svc = new EmailService({
329+
transport,
330+
defaultFrom: { address: 'no-reply@x.test' },
331+
// zh-CN-only bundle: no en-US row exists, so the ladder falls through to
332+
// the loader's own no-locale answer and lands on zh-CN.
333+
templateLoader: createSysEmailTemplateLoader(fakeEngine([fmtRow('zh-CN')])),
334+
});
335+
336+
await svc.sendTemplate({ template: 'receipt', to: 'a@x.test', timezone: 'UTC', data: FMT_DATA });
337+
338+
const zh = expected('zh-CN');
339+
expect(transport.sent[0].subject).toBe(`[zh-CN] ${esc(zh.when)}`);
340+
expect(transport.sent[0].subject).not.toContain(esc(expected('en-US').when));
341+
});
342+
343+
// Pin (b): the row is the authority only when the caller named NOBODY.
344+
it('an explicit input.locale still wins over the resolved row', async () => {
345+
const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US'), fmtRow('de-DE')]));
346+
347+
await svc.sendTemplate({
348+
template: 'receipt', to: 'a@x.test', locale: 'de-DE', timezone: 'UTC', data: FMT_DATA,
349+
});
350+
351+
const de = expected('de-DE');
352+
expect(transport.sent[0].subject).toBe(`[de-DE] ${esc(de.when)}`);
353+
expect(transport.sent[0].html).toContain(esc(de.amount));
354+
});
355+
356+
// Pin (b), the sharp edge: the caller's locale has no row, so the ladder
357+
// renders the en-US ROW — and the caller's locale must still drive the
358+
// filters. This is the assertion that stops the fix from over-reaching into
359+
// "the row always wins".
360+
it('an explicit locale with no row still formats in THAT locale, on the en-US row', async () => {
361+
const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US')]));
362+
363+
await svc.sendTemplate({
364+
template: 'receipt', to: 'a@x.test', locale: 'de-DE', timezone: 'UTC', data: FMT_DATA,
365+
});
366+
367+
const de = expected('de-DE');
368+
expect(transport.sent[0].subject).toBe(`[en-US] ${esc(de.when)}`);
369+
expect(transport.sent[0].html).toContain(esc(de.amount));
370+
});
371+
372+
// Falls out of binding to `preferred` (the trimmed spelling the ladder
373+
// resolved on) rather than to the raw `input.locale`: `Intl` throws a
374+
// RangeError on a padded tag, which would have taken the whole send down.
375+
it('a padded explicit locale renders rather than throwing out of Intl', async () => {
376+
const { svc, transport } = serviceWith(exactLoader([fmtRow('en-US'), fmtRow('de-DE')]));
377+
378+
await svc.sendTemplate({
379+
template: 'receipt', to: 'a@x.test', locale: ' de-DE ', timezone: 'UTC', data: FMT_DATA,
380+
});
381+
382+
expect(transport.sent[0].subject).toBe(`[de-DE] ${esc(expected('de-DE').when)}`);
383+
});
384+
});
385+
259386
// ── the wiring: the plugin must install THIS loader ────────────────────────
260387

261388
describe('EmailServicePlugin wiring', () => {

0 commit comments

Comments
 (0)