Skip to content

Commit e0dc077

Browse files
fix(plugin-email): a templated email to a named locale folds it against the template's locales and tries the stack's declared fallback before en-US (#28917)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X8dNy8r76WjFsVdvaSJbXXR)_ Fixes #28880 Clause-②: no Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches the email template locale ladder. ## 维护者速读 - 问题:模板邮件按收件人 locale 精确匹配,失败只再试 `en-US`,所以 `en-GB` 收件人拿不到 `en` 行,`zh-TW` 拿不到 `zh-CN` 行,栈声明的 `i18n.fallbackLocale` 也从不读取,这些投递都以 `TEMPLATE_NOT_FOUND` 死信。 - 现在:指定了 locale 的调用依次走:按平台 BCP-47 规则(`resolveBundleLocale`:精确、大小写、基础语言、同语言变体,变体取升序第一个)折叠到模板已有的 locale;再按同样规则试声明的 fallback;再 `en-US`;最后仍是永久失败的 `TEMPLATE_NOT_FOUND`。 - 不变:精确命中照旧;不指定 locale 的调用照旧(`en-US`,再取最小 tag),不读声明的 fallback;替换的 `TemplateLoader` 保持原来的精确规则。折叠是内置 loader 的能力,通过模块私有的 symbol 提供,不导出、不加在 `TemplateLoader` 上,公开 d.ts 没有变化。 - spec 里 `SendTemplateInput` 和 `email-template.zod.ts` 的说明文字因此过时,已由 #28896 跟进;本 PR 不改 `packages/spec/**`。 ## What changes All in `packages/plugins/plugin-email/src/`. - **`template-loader.ts`**: `createRegistryEmailTemplateLoader`'s loader also answers, under a module-private symbol (`TEMPLATE_BUNDLE_LOCALES`, declared in `email-service.ts`, not re-exported from the package index and not a member of `TemplateLoader`), the locale tags the name's bundle carries, de-duplicated and sorted ascending. Its `load` is unchanged and stays exact. - **`email-service.ts`** (`resolveAndRenderTemplate`): for a call that names a locale over a loader carrying that symbol, it folds the named tag with `resolveBundleLocale` (imported from `@objectstack/spec/system`, no third copy of the ladder), then the declared fallback the same way, loading each resolved tag exactly and skipping a tag already tried; then the existing `en-US` rung. A loader without the symbol, and every no-locale call, take exactly the base path. `EmailService.setTemplateFallbackLocale(read)` stores the fallback reader (the class is not exported, so this is not a published member). The `TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` messages, the variables check and the envelope columns are untouched. - **`email-plugin.ts`**: the composition point. `EmailServicePlugin.init` hands the service a reader that resolves the `i18n` service at each send and answers its `getFallbackLocale()` (`i18n.fallbackLocale`, else `defaultLocale`); a host with no i18n service declares none. The fold is a **built-in-loader capability**: a replacement `TemplateLoader` keeps today's exact ladder, declared fallback included (pinned). ## Tests - `template-locale-fold.test.ts` (13, the real service over the real registry loader): `en-GB` → `en`; `zh-TW` → `zh-CN`; `zh` → `zh-CN`; `zh-cn` → `zh-CN`; variant expansion takes the first base-language match in ascending tag order in both list orders (`zh-MO` → `zh-CN`); an exact hit wins over every fold; `fallbackLocale: 'zh-CN'` with no match → `zh-CN` before `en-US`; the fallback is folded (`zh` → `zh-CN`); a fallback with no row → `en-US`; no fallback → `en-US`; nothing at all → `TEMPLATE_NOT_FOUND: welcome …`; a no-locale call answers as today and never reads the fallback; a loader without the symbol answers exactly as on base, with a declared fallback (`['en-GB', 'en-US']`, then `TEMPLATE_NOT_FOUND`). - `template-locale-fold.integration.test.ts` (2, booted `ObjectKernel` with `ObjectQLPlugin`, an app registering the duly shape `en` + `zh-CN` bundle through the `manifest` service, and the real `EmailServicePlugin` with its built-in loader): `en-GB` sends the `en` row and `zh-TW` the `zh-CN` row; with an i18n service declaring `zh-CN`, a `ja` recipient sends the `zh-CN` row. - **Base vs head** (`origin/main` `b15b634c`): booted send, base throws `TEMPLATE_NOT_FOUND: lf_lead_time (locale=en-GB)` and `(locale=ja)`, head sends `[en]`, `[zh-CN]`, `[zh-CN]`. Unit file on base (with only a no-op `setTemplateFallbackLocale` shimmed in so the file loads): 7 red, the 6 guards green. - Ablations, each landed on disk then restored: | rung removed | red | |---|---| | fold (exact tag only) | 7 (every fold case, the folded fallback, the booted send) | | declared fallback | 3 (both fallback cases, the booted fallback send) | | `en-US` | 3 (fallback-without-row, no-fallback, replacement loader) | | ascending sort of the loader's tags | 1 (variant order) | | the loader's symbol | 9 | | the plugin's i18n wiring | 1 (booted fallback send) | - `@objectstack/plugin-email` suite: 47 files, 764/764, including the #7731 file that pins the loader's own exact `load` and the service ladder over an exact loader, unchanged. ## Acceptance notes - **No published type changes.** `TemplateLoader`, `EmailServiceOptions` and `createRegistryEmailTemplateLoader`'s signature are as before; the built `dist/index.d.ts` is identical to base. - **No-locale ladder unchanged (ruling 2).** The published `SendTemplateInput.locale` doc says a call that omits the locale starts at `en-US`, so the declared fallback stays out of that ladder. - **Spec docs now stale**: `SendTemplateInput.template` / `.locale` (`packages/spec/src/contracts/email-service.ts`) say "no language-subtag folding", and `email-template.zod.ts` says `en-US` is the only rung retried. Follow-up #28896 (domain:spec); not touched here. - `NotificationTemplateStore`'s own ladder (named, primary subtag, `en`) in `service-messaging` is unchanged (out of scope). ## Verification Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands on the diff; all 60 exit 0, and `--ran` reconciles 60 derived, 60 run, 0 NOT-MEASURED. `pnpm --filter @objectstack/spec check:error-code-provenance`: exit 0. `pnpm --filter @objectstack/plugin-email typecheck` (runs `check:test-typecheck`): first exit 2 (TS2353, the symbol key on the loader's object literal typed `TemplateLoader`), fixed by typing the local as `TemplateLoader & { [key]: … }`, then exit 0. Scoped eslint over the five changed `.ts` files: exit 0. `pnpm build` of the package: the emitted `dist/index.d.ts` matches base. After the final merge of `main` (`9a9823d9`, nothing under `packages/plugins/plugin-email` or `packages/spec`): the three `template-locale*` files green (38 tests). The booted send in `template-locale-fold.integration.test.ts` is the order's real `sendTemplate` through the email service with the built-in loader; the duly app itself was not run. Full dogfood not owed (no `!`, no narrowing arm). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kV6ptpTnU6Hjb2mE4)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bb2417 commit e0dc077

6 files changed

Lines changed: 347 additions & 5 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
---
4+
5+
fix(plugin-email): a templated email to a named locale folds it against the template's locales and tries the stack's declared fallback before en-US (#28880)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
Skills-docs: none — no file under `skills/**` teaches the email template locale ladder.
10+
11+
`sendTemplate` and `renderTemplate` used to try a named recipient locale exactly, then `en-US`, then fail with `TEMPLATE_NOT_FOUND`. A recipient whose tag differed from a template row only by region (`en-GB` against an `en` row, `zh-TW` against `zh-CN`) or by case got nothing, and the stack's `i18n.fallbackLocale` was never read, so those deliveries dead-lettered.
12+
13+
With the built-in template loader, a call that names a locale now resolves, in order:
14+
15+
1. that locale against the template's own locales by the platform's BCP-47 rules (`resolveBundleLocale`): exact, case-insensitive, base language (`en-GB` → `en`), then variant expansion (`zh-TW` → `zh-CN`), taking the first match in ascending tag order;
16+
2. the stack's declared fallback (`i18n.fallbackLocale`, else `defaultLocale`, from the i18n service), folded the same way;
17+
3. `en-US`;
18+
4. `TEMPLATE_NOT_FOUND`, still a permanent failure.
19+
20+
An exact match answers as before. A call that names no locale keeps its ladder (`en-US`, then the lowest tag) and does not read the declared fallback. A replacement `TemplateLoader` keeps the exact ladder: the fold is a capability of the built-in loader, and no exported type changes.

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
4-
import type { IDataEngine } from '@objectstack/spec/contracts';
4+
import type { IDataEngine, II18nService } from '@objectstack/spec/contracts';
55
import type {
66
IEmailTransport,
77
EmailAddress,
@@ -507,6 +507,16 @@ export class EmailServicePlugin implements Plugin {
507507
logger: ctx.logger,
508508
});
509509

510+
// [#28880] The stack's declared fallback locale joins a named-locale
511+
// template ladder (after the recipient's own tag, before en-US). Read
512+
// from the i18n service at each send, so one registered after this
513+
// plugin is seen; a host with no i18n service declares none.
514+
this.service.setTemplateFallbackLocale(() => {
515+
let i18n: II18nService | undefined;
516+
try { i18n = ctx.getService<II18nService>('i18n'); } catch { return undefined; }
517+
return i18n?.getFallbackLocale?.();
518+
});
519+
510520
// [#23946] Persistence + the template loader are wired HERE, before the
511521
// service is handed out, and not at this plugin's `kernel:ready`. A
512522
// plugin that STARTS before this one runs its own `kernel:ready` hooks

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
TENANT_EMAIL_OBJECT,
4242
type EmailLogObject,
4343
} from './email-log-objects.js';
44+
import { resolveBundleLocale } from '@objectstack/spec/system';
4445

4546
/**
4647
* Queue topic durable email delivery is published to and consumed from
@@ -522,6 +523,20 @@ export interface TemplateLoader {
522523
load(name: string, locale: string | undefined): Promise<EmailTemplateRow | null>;
523524
}
524525

526+
/**
527+
* [#28880] The built-in registry loader's own capability, kept off
528+
* {@link TemplateLoader} and out of the package's exports: under this key it
529+
* answers the locale tags a template name's bundle carries, sorted ascending.
530+
* With it, {@link EmailService}'s ladder folds a named locale against that
531+
* list (`resolveBundleLocale`) before loading the resolved tag exactly, and
532+
* the stack's declared fallback joins the ladder. A loader without it — any
533+
* replacement loader — answers the exact ladder it always did.
534+
*/
535+
export const TEMPLATE_BUNDLE_LOCALES = Symbol('@objectstack/plugin-email:template-bundle-locales');
536+
537+
/** What the built-in loader answers under {@link TEMPLATE_BUNDLE_LOCALES}. */
538+
export type TemplateBundleLocales = (name: string) => Promise<string[]>;
539+
525540
/**
526541
* What the loader answers: the render input of one resolved `email_template`
527542
* definition, spelled as the `sys_email_template` columns were — the shape
@@ -609,6 +624,9 @@ export class EmailService implements IEmailService {
609624
*/
610625
private readonly managedRowIds = new Set<string>();
611626

627+
/** [#28880] The stack's declared fallback locale, read at each resolve. Unset ⇒ none declared. */
628+
private templateFallbackLocale?: () => string | undefined;
629+
612630
/**
613631
* Set once queue delivery has been reported as unavailable, so the
614632
* degradation is stated at the FIRST send and not on every one
@@ -639,6 +657,24 @@ export class EmailService implements IEmailService {
639657
this.options.templateLoader = loader;
640658
}
641659

660+
/**
661+
* [#28880] Wire the stack's declared fallback locale (`i18n.fallbackLocale`,
662+
* else `defaultLocale`, as the i18n service's `getFallbackLocale` answers
663+
* it). Read at each resolve, so a later-registered i18n service is seen.
664+
*/
665+
setTemplateFallbackLocale(read: () => string | undefined): void {
666+
this.templateFallbackLocale = read;
667+
}
668+
669+
private declaredFallbackLocale(): string | undefined {
670+
try {
671+
const declared = this.templateFallbackLocale?.();
672+
return typeof declared === 'string' && declared.trim() ? declared.trim() : undefined;
673+
} catch {
674+
return undefined;
675+
}
676+
}
677+
642678
/** Wire (or replace) persistence after construction. */
643679
setPersistence(persistence: EmailPersistence | undefined): void {
644680
this.options.persistence = persistence;
@@ -1404,8 +1440,33 @@ export class EmailService implements IEmailService {
14041440
// locale means the DOCUMENTED default, so ask for it by name.
14051441
const preferred = input.locale && String(input.locale).trim();
14061442
const wanted = preferred || DEFAULT_TEMPLATE_LOCALE;
1407-
let row = await loader.load(input.template, wanted);
1408-
if (!row && wanted !== DEFAULT_TEMPLATE_LOCALE) {
1443+
// [#28880] A named locale, over the built-in loader, is folded against the
1444+
// bundle's own tags by the platform's one BCP-47 ladder (exact →
1445+
// case-insensitive → base language → variant expansion, the first match
1446+
// in ascending tag order), then the stack's declared fallback is folded
1447+
// the same way, before the `en-US` rung below. Each rung loads the tag it
1448+
// resolved to, exactly. A no-locale call keeps its ladder: en-US, then the
1449+
// loader's own answer.
1450+
const bundleLocales = (loader as { [TEMPLATE_BUNDLE_LOCALES]?: TemplateBundleLocales })[TEMPLATE_BUNDLE_LOCALES];
1451+
const locales = preferred && typeof bundleLocales === 'function'
1452+
? await bundleLocales.call(loader, input.template)
1453+
: undefined;
1454+
const tried = new Set<string>();
1455+
let row: EmailTemplateRow | null = null;
1456+
if (locales) {
1457+
const bundle: Record<string, true> = Object.fromEntries(locales.map((tag) => [tag, true] as const));
1458+
for (const requested of [wanted, this.declaredFallbackLocale()]) {
1459+
const tag = requested === undefined ? undefined : resolveBundleLocale(bundle, requested);
1460+
if (tag === undefined || tried.has(tag)) continue;
1461+
tried.add(tag);
1462+
row = await loader.load(input.template, tag);
1463+
if (row) break;
1464+
}
1465+
} else {
1466+
tried.add(wanted);
1467+
row = await loader.load(input.template, wanted);
1468+
}
1469+
if (!row && !tried.has(DEFAULT_TEMPLATE_LOCALE)) {
14091470
row = await loader.load(input.template, DEFAULT_TEMPLATE_LOCALE);
14101471
}
14111472
if (!row && !preferred) {

‎packages/plugins/plugin-email/src/template-loader.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
import { stripReadDecorations } from '@objectstack/spec/kernel';
3434
import { EmailTemplateDefinitionSchema, type EmailTemplateDefinitionParsed } from '@objectstack/spec/system';
35-
import { DEFAULT_TEMPLATE_LOCALE, type EmailTemplateRow, type TemplateLoader } from './email-service.js';
35+
import { DEFAULT_TEMPLATE_LOCALE, TEMPLATE_BUNDLE_LOCALES, type EmailTemplateRow, type TemplateLoader, type TemplateBundleLocales } from './email-service.js';
3636

3737
/**
3838
* The package the built-in auth templates are code-declared under: the email
@@ -171,7 +171,14 @@ export function createRegistryEmailTemplateLoader(
171171
return toRenderInput(parsed.data);
172172
};
173173

174-
return {
174+
// [#28880] The bundle's tags, for the service's fold (module-private key,
175+
// not on `TemplateLoader`): sorted, so variant expansion's "first match" is
176+
// the lowest tag, whatever order the list carries.
177+
const bundleLocales: TemplateBundleLocales = async (name) =>
178+
[...new Set((await readBundle(name)).map(itemLocale))].sort();
179+
180+
const loader: TemplateLoader & { [TEMPLATE_BUNDLE_LOCALES]: TemplateBundleLocales } = {
181+
[TEMPLATE_BUNDLE_LOCALES]: bundleLocales,
175182
async load(name, locale) {
176183
const bundle = await readBundle(name);
177184
if (locale) return render(pick(bundle, locale));
@@ -182,4 +189,5 @@ export function createRegistryEmailTemplateLoader(
182189
return lowest === undefined ? null : render(pick(bundle, lowest));
183190
},
184191
};
192+
return loader;
185193
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#28880] The duly shape, booted: an app ships an `en` + `zh-CN` bundle (no
4+
// `en-US` row), and the real `EmailServicePlugin` sends it through the
5+
// built-in registry loader it wires. Measured on `origin/main` (`b15b634c`):
6+
// `en-GB` and `zh-TW` recipients both threw `TEMPLATE_NOT_FOUND` (the ladder
7+
// tried the named tag, then `en-US`), so each delivery dead-lettered. On this
8+
// head `en-GB` reaches the `en` row and `zh-TW` the `zh-CN` row, and a
9+
// recipient whose language the bundle lacks reaches the stack's declared
10+
// fallback (the i18n service's `getFallbackLocale`) before `en-US`.
11+
12+
import { describe, it, expect, afterEach } from 'vitest';
13+
import { ObjectKernel } from '@objectstack/core';
14+
import type { Plugin, PluginContext } from '@objectstack/core';
15+
import { ObjectQLPlugin } from '@objectstack/objectql';
16+
import type { IEmailService, IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts';
17+
import { EmailServicePlugin } from './email-plugin.js';
18+
19+
const PACKAGE_ID = 'com.test.mail-locale-fold';
20+
21+
const bundle = (locale: string) => ({
22+
name: 'lf_lead_time',
23+
label: 'Lead time',
24+
locale,
25+
subject: `[${locale}] {{title}} is due`,
26+
bodyHtml: `<p>[${locale}] {{title}}</p>`,
27+
});
28+
29+
/** A driver that stores nothing: the template is metadata, not a row. */
30+
const emptyDriver = {
31+
name: 'mail-locale-fold-driver',
32+
version: '1.0.0',
33+
supports: {},
34+
connect: async () => {},
35+
disconnect: async () => {},
36+
find: async () => [],
37+
findOne: async () => null,
38+
count: async () => 0,
39+
create: async (_o: string, d: any) => d,
40+
update: async (_o: string, _i: any, d: any) => d,
41+
delete: async () => true,
42+
syncSchema: async () => {},
43+
};
44+
45+
/** Registers the driver, the app body through the `manifest` service, and an i18n service declaring `fallback`. */
46+
class AppFixture implements Plugin {
47+
name = 'test.mail-locale-fold.app';
48+
type = 'standard' as const;
49+
version = '1.0.0';
50+
constructor(private readonly fallback: string | undefined) {}
51+
async init(ctx: PluginContext) {
52+
ctx.registerService('driver.mail-locale-fold', emptyDriver);
53+
if (this.fallback !== undefined) {
54+
const fallback = this.fallback;
55+
ctx.registerService('i18n', { getFallbackLocale: () => fallback });
56+
}
57+
const manifest = { id: PACKAGE_ID, name: 'mail_locale_fold', type: 'app', version: '1.0.0' };
58+
ctx.getService<{ register(m: unknown): void }>('manifest').register({
59+
...manifest, manifest, emailTemplates: [bundle('en'), bundle('zh-CN')],
60+
});
61+
}
62+
}
63+
64+
class CaptureTransport implements IEmailTransport {
65+
readonly subjects: string[] = [];
66+
async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
67+
this.subjects.push(message.subject);
68+
return { messageId: `<fold-${this.subjects.length}@test>` };
69+
}
70+
}
71+
72+
describe('[#28880] the booted email service folds a recipient locale over the built-in loader', () => {
73+
let kernel: ObjectKernel | undefined;
74+
75+
afterEach(async () => {
76+
if (kernel?.getState() === 'running') await kernel.shutdown();
77+
kernel = undefined;
78+
});
79+
80+
async function boot(fallback?: string) {
81+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
82+
const transport = new CaptureTransport();
83+
await kernel.use(new ObjectQLPlugin());
84+
await kernel.use(new AppFixture(fallback));
85+
await kernel.use(new EmailServicePlugin({ persist: false, transport, defaultFrom: { address: 'no-reply@example.com' } }));
86+
await kernel.bootstrap();
87+
const email = kernel.getService<IEmailService>('email');
88+
return async (locale: string) => {
89+
const res = await email.sendTemplate({ template: 'lf_lead_time', to: 'ann@example.com', locale, data: { title: 'Visa' } });
90+
expect(res.status).toBe('sent');
91+
return transport.subjects[transport.subjects.length - 1];
92+
};
93+
}
94+
95+
it('en-GB reaches the en row and zh-TW the zh-CN row', async () => {
96+
const send = await boot();
97+
expect(await send('en-GB')).toBe('[en] Visa is due');
98+
expect(await send('zh-TW')).toBe('[zh-CN] Visa is due');
99+
});
100+
101+
it('a recipient language the bundle lacks reaches the declared fallback', async () => {
102+
const send = await boot('zh-CN');
103+
expect(await send('ja')).toBe('[zh-CN] Visa is due');
104+
});
105+
});

0 commit comments

Comments
 (0)