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
46 changes: 46 additions & 0 deletions .changeset/18499-email-locale-docblocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/platform-objects': patch
'@objectstack/plugin-email': patch
'@objectstack/service-messaging': patch
---

docs(email): the shipped carriers said "best-matching locale"; the resolver matches `(name, locale)` exactly (#18499)

Clause-②: no — no accept set moves and no published payload key changes; the
corrected prose ships as JSDoc in each package's `dist/*.d.ts` (and, for
`@objectstack/service-messaging`, inside the bundled `dist/index.js`), which is
why this is a changeset rather than `skip-changeset`.

`packages/plugins/plugin-email/src/template-loader.ts` already enumerates
"the EmailService picks the best-matching locale" as a FALSE declaration, and
three shipped carriers still stated it. Measured against the code at this
branch's base rather than against the card's transcription:

- `createSysEmailTemplateLoader.load` — `locale` given ⇒ exact `{ name, locale }`
match ordered by `id`, or `null`; `locale` absent ⇒ `{ name, locale: 'en-US' }`
first, and only if that misses `{ name }` ordered by `locale` ascending;
- `EmailService.resolveAndRenderTemplate` — `wanted = input.locale?.trim() ||
'en-US'`, then exactly one retry at the literal `'en-US'` when the call NAMED a
locale, then `TEMPLATE_NOT_FOUND`; the unpinned rung is reachable only for a
call that named no locale.

No language-subtag folding anywhere on that path, and nothing that could be
called a "best match". Corrected:

- `sys_email_template`'s object doc (`@objectstack/platform-objects`) now states
the exact match, the single `en-US` rung and the no-locale last resort;
- `sys_notification_template.locale`'s sibling-declaration comment
(`@objectstack/service-messaging`) said "both resolve a template by
best-matching locale", which was false in a second way: the two resolvers do
not agree. `NotificationTemplateStore.load` walks `(topic, channel, locale)`
through a candidate list — the named tag, its primary subtag, then
`DEFAULT_LOCALE` (`'en'`) — so it DOES fold a subtag, where
`sys_email_template` does not. Only the shared 16-char BCP-47 bound is shared;
the resolution is not, and the comment now says so;
- `template-loader.ts`'s own "What was wrong" block quoted two sentences it can
no longer quote — one was already stale at this base (the
`EmailTemplateDefinitionSchema.locale` text it reproduces has zero occurrences
in `packages/spec` today) and the other is corrected above. Both bullets are
now cited rather than quoted, so a later rewording cannot strand them again.

No resolution behaviour changes: every edit in this changeset is prose.
12 changes: 9 additions & 3 deletions docs/qa/platform-checklist/areas/integration-system.json
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@
"title": "Email templates materialize to sys_email_template, resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, refuses anonymous, and 400s malformed input",
"since": "v15",
"status": "active",
"revision": 4,
"revision": 5,
"priority": "P2",
"surface": "mixed",
"fixtures": {
Expand Down Expand Up @@ -815,9 +815,9 @@
"evidence": "the error"
},
{
"clause": "(name, locale) resolution picks the best locale row and falls back to en-US — two rows with one name are an i18n bundle, both reachable by recipient locale",
"clause": "(name, locale) resolution is an EXACT match with no language-subtag folding — two rows with one name are an i18n bundle, each reachable ONLY from its own exact tag; a call naming a locale with no row retries the single literal rung en-US and then raises TEMPLATE_NOT_FOUND, and a call naming no locale starts at en-US",
"oracle": "log",
"verify": "the zh-CN recipient gets the zh-CN render; the unmatched recipient gets the fallback",
"verify": "the zh-CN recipient gets the zh-CN render; a recipient whose locale has no row (e.g. zh) gets the en-US row, NOT the zh-CN one",
"evidence": "the two renders"
},
{
Expand Down Expand Up @@ -891,6 +891,12 @@
"date": "2026-08-11",
"change": "CORRECTION from run #7690: the raw-send step named a { to, subject, bodyHtml } message body, but those are the TEMPLATE authoring fields — the wire keys are `html`/`text` (SendEmailInput, email-service.ts), and the literal shape the step named is refused 400 'at least one of text or html is required'. Corrected the step and the clause, added the wrong-vocabulary probe that pins the two vocabularies apart, added a NOT-a-FAIL negative so the refusal is not filed as a defect next run, and cited the contract + normalizeMessage in source",
"ref": "#7745"
},
{
"revision": 5,
"date": "2026-09-21",
"change": "CORRECTION: the locale clause said resolution \"picks the best locale row\", a sentence packages/plugins/plugin-email/src/template-loader.ts enumerates as one of three false declarations. The resolver matches (name, locale) exactly, folds no language subtag, retries the single literal rung en-US for a call that named a locale and then raises TEMPLATE_NOT_FOUND. Corrected the clause and its verify line so the run cannot tick a best-match behaviour the code has never had",
"ref": "#18499"
}
]
},
Expand Down
11 changes: 9 additions & 2 deletions packages/platform-objects/src/audit/sys-email-template.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
*
* Backing persistence for the `email_template` metadata type. Each
* row is the runtime representation of an `EmailTemplate` Zod
* envelope. Resolved by `(name, locale)`; the EmailService picks the
* best-matching locale for the recipient, falling back to `en-US`.
* envelope. Resolved by an EXACT `(name, locale)` match — the
* EmailService does NO language-subtag folding, so `en-US` never
* reaches an `en` row and there is no "best match". A call that NAMES
* a locale retries exactly one rung, the literal `en-US`, and then
* raises `TEMPLATE_NOT_FOUND`; a call that names none starts AT
* `en-US` and, only when the bundle carries no `en-US` row at all,
* takes the bundle's lowest locale tag. See
* `@objectstack/plugin-email`'s `createSysEmailTemplateLoader` and
* `SendTemplateInput.locale` for the ladder of record.
*
* Authoring: built-in templates are seeded by `EmailServicePlugin`
* on `kernel:ready`; administrators may edit subject/body in Studio
Expand Down
18 changes: 13 additions & 5 deletions packages/plugins/plugin-email/src/template-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,19 @@
* that; three separate places declare the opposite:
*
* - `SendTemplateInput.locale` (spec contract): *"Falls back to `'en-US'`"*.
* - `EmailTemplateDefinitionSchema.locale`: *"the service picks the best match
* for the recipient's locale, falling back to `en-US`"*.
* - `sys_email_template`'s own object doc: *"Resolved by `(name, locale)`; the
* EmailService picks the best-matching locale for the recipient, falling
* back to `en-US`"*.
* - `EmailTemplateDefinitionSchema.locale`: the resolver *"matches
* `(name, locale)` **exactly**"* and, for a call that named a locale,
* retries exactly one rung — `EMAIL_TEMPLATE_FLOOR_LOCALE`.
* - `sys_email_template`'s own object doc: resolved by an exact
* `(name, locale)` match with that same single `en-US` rung.
*
* Those last two bullets are CITED, not quoted in full, and deliberately:
* both sentences once read "picks the best match for the recipient's locale"
* / "picks the best-matching locale for the recipient" — the claim the rule
* below says the resolver has never implemented. They were corrected to the
* ladder as built (#18499), together with `sys_notification_template.locale`
* and the platform checklist's locale clause; a quotation is what stranded
* them here in the first place.
*
* ## The rule this implements
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ export const NotificationTemplate = ObjectSchema.create({
defaultValue: 'en',
// [#12978] Sibling-declaration bound (#11374 route A): the same
// BCP-47 tag family `sys_email_template.locale` stores, bounded 16
// there; both resolve a template by best-matching locale.
// there. The BOUND is shared; the RESOLUTION is not, and neither
// side picks a "best-matching" locale. This object is loaded by
// `NotificationTemplateStore` on `(topic, channel, locale)` and
// walks a fixed candidate list — the named tag, then its primary
// subtag (`zh-CN` -> `zh`), then `DEFAULT_LOCALE` ('en').
// `sys_email_template` matches `(name, locale)` EXACTLY, folds no
// subtag at all, and retries the single literal rung `en-US`.
maxLength: 16,
description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'.",
}),
Expand Down
Loading