Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/sendtemplate-resolved-row-locale.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 19 additions & 2 deletions packages/plugins/plugin-email/src/email-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
127 changes: 127 additions & 0 deletions packages/plugins/plugin-email/src/template-locale-resolution.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: `<p>{{ amt | number:2 }}</p>`,
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');

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', () => {
Expand Down
Loading