Repository navigation
Commit e0dc077
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
- .changeset
- packages/plugins/plugin-email/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
507 | 507 | | |
508 | 508 | | |
509 | 509 | | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
510 | 520 | | |
511 | 521 | | |
512 | 522 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| 44 | + | |
44 | 45 | | |
45 | 46 | | |
46 | 47 | | |
| |||
522 | 523 | | |
523 | 524 | | |
524 | 525 | | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
525 | 540 | | |
526 | 541 | | |
527 | 542 | | |
| |||
609 | 624 | | |
610 | 625 | | |
611 | 626 | | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
612 | 630 | | |
613 | 631 | | |
614 | 632 | | |
| |||
639 | 657 | | |
640 | 658 | | |
641 | 659 | | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
642 | 678 | | |
643 | 679 | | |
644 | 680 | | |
| |||
1404 | 1440 | | |
1405 | 1441 | | |
1406 | 1442 | | |
1407 | | - | |
1408 | | - | |
| 1443 | + | |
| 1444 | + | |
| 1445 | + | |
| 1446 | + | |
| 1447 | + | |
| 1448 | + | |
| 1449 | + | |
| 1450 | + | |
| 1451 | + | |
| 1452 | + | |
| 1453 | + | |
| 1454 | + | |
| 1455 | + | |
| 1456 | + | |
| 1457 | + | |
| 1458 | + | |
| 1459 | + | |
| 1460 | + | |
| 1461 | + | |
| 1462 | + | |
| 1463 | + | |
| 1464 | + | |
| 1465 | + | |
| 1466 | + | |
| 1467 | + | |
| 1468 | + | |
| 1469 | + | |
1409 | 1470 | | |
1410 | 1471 | | |
1411 | 1472 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| |||
171 | 171 | | |
172 | 172 | | |
173 | 173 | | |
174 | | - | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
175 | 182 | | |
176 | 183 | | |
177 | 184 | | |
| |||
182 | 189 | | |
183 | 190 | | |
184 | 191 | | |
| 192 | + | |
185 | 193 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
0 commit comments