diff --git a/.changeset/notification-locale-per-recipient.md b/.changeset/notification-locale-per-recipient.md new file mode 100644 index 0000000000..5218607e77 --- /dev/null +++ b/.changeset/notification-locale-per-recipient.md @@ -0,0 +1,71 @@ +--- +"@objectstack/service-messaging": minor +"@objectstack/spec": minor +"@objectstack/service-automation": patch +--- + +feat(service-messaging): notification locale is resolved per recipient — `sys_user.locale`, then the deployment default (#13881) + +Maintainer ruling 2026-09-01, quoted verbatim and untranslated: + +> **解析点移到 fan-out 后按收件人**:`payload.locale` 不再是 fan-out 前单值,插进 `email-channel.ts` L86-99 自留的 seam +> **解析链 = 收件人 `locale` → 部署默认**(`II18nService.getDefaultLocale()`),缺失恒回退,⛔ 任何路径不得死信 + +Before, the delivery path resolved ONE locale per notification: `payload.locale` +if the producer set one (interpolated once, before fan-out), else the deployment +default. Every recipient of a notify node got the same `sys_email_template` row, +whatever language they read — the hotcrm measurement that lifted the 2026-08-13 +deferral. + +Now the locale is resolved PER RECIPIENT, at delivery time, through ONE read +point (`recipient-locale.ts`, `resolveRecipientLocale`): the recipient's own +`sys_user.locale` — email and SMS read it off the same row they already fetch +for the address, so it costs no second query there; the inbox channel, which +never read the row before, makes one read for it on the template path — else +the deployment default, probed +lazily so live `localization` changes are honoured. The same chain serves the +email channel's two arms (`sendTemplate` and `sys_notification_template`), the +inbox channel's template path, and the SMS channel, so one notification cannot +arrive in two languages across channels. + +**Never a dead letter from this seam.** A recipient value that is absent, empty, +whitespace, non-string, malformed, or the literal string `"undefined"` / +`"null"` (the exact shape hotcrm measured dead-lettering every user without a +preference row) falls back to the deployment default; nothing named anywhere +arrives at the downstream ladders as an absent key, which is their documented +`en-US` floor. A locale read that throws (a `userObject` override without the +column) is retried address-only and falls back — the delivery still goes out. + +**Behaviour change for producers:** a `payload.locale` set by a producer is no +longer consulted. It was never a declared key of the notify node (only the +generic `payload` passthrough carried it) and no in-repo producer writes it; +the ruling retired it as the pre-fan-out single value. A node that relied on +it now sends each recipient their own language, else the deployment default — +which is the ruled behaviour, not a regression. Nothing to migrate: remove the +key, or leave it, it is inert either way. + +Second behaviour change: on the `sys_notification_template` arm (email topic +path, SMS) the deployment default (`II18nService.getDefaultLocale()`) is now +the second rung; before, that arm fell straight from `payload.locale` to the +static `en` and never consulted it. A deployment whose `localization.locale` +is e.g. `zh-CN` with a topic bundle holding `en` and `zh` rows renders `zh` +there now for recipients without a column. SMS is newly handed the +deployment-default probe. + +`@objectstack/spec` ships the contract text: the `notify` node's `template` +description and its refusal messages now state the per-recipient chain and +name `payload.locale` as not consulted (`automation/io-node-config.zod.ts`), +mirrored on the runtime descriptor in `@objectstack/service-automation`. + +Interaction with the `TEMPLATE_*` permanent-failure class is unchanged in +kind: `sendTemplate`'s ladder for a NAMED locale still ends at `en-US`, so a +recipient locale can dead-letter a delivery only against a bundle that has +neither the requested row nor an `en-US` row — off the documented contract. +Two asymmetries against the old single value, both on such bundles: (a) the +bundle carries the deployment default's row but no `en-US` row — old delivered, +new fails for a recipient whose own tag is a third language; (b) there is NO +deployment default (i18n absent or `getDefaultLocale` unimplemented) — old +called `sendTemplate` with no locale and the ladder's any-row rung delivered, +new names the recipient's tag, the any-row rung is skipped, and a tag absent +from the bundle is `TEMPLATE_NOT_FOUND` (permanent). The fix in both is the +bundle (`en-US` is the ladder's floor), not a third rung. diff --git a/.changeset/sys-user-locale-column.md b/.changeset/sys-user-locale-column.md new file mode 100644 index 0000000000..728ae2b0b3 --- /dev/null +++ b/.changeset/sys-user-locale-column.md @@ -0,0 +1,37 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/plugin-auth": patch +--- + +feat(platform-objects): `sys_user.locale` — the user's own notification language as a first-class column (#13881) + +Maintainer ruling 2026-09-01, quoted verbatim and untranslated: + +> **A**:`sys_user` 加 `locale` 一等列(用户语言是主流平台的一等用户属性;B 的 preference 袋会把一等概念藏进键值对并孕育第二种拼法,排除) + +`sys_user` gains `locale` — a BCP-47 tag (`zh-CN`, `ja-JP`), optional, in the +Profile group. The 2026-08-13 ruling had deferred it "until measured pull"; +hotcrm measured the pull (4 published languages × 16 notify nodes × 0 +localizable, two independent lanes agreeing), so the deferral lifted on its own +terms. The preference-bag alternative (`sys_user_preference`) was rejected by +the ruling and nothing reads it as a fallback. + +The column is owned by objectql exactly like `ai_access`: better-auth is +oblivious to it, and it is deliberately NOT declared as a better-auth +`additionalFields` entry — better-auth SELECTs explicit columns, so declaring +it there would make `getSession` query a column an environment that has not +yet run schema-sync does not have. Boot schema-sync (additive) provisions it. +plugin-auth registers it in `MANAGED_EXTENSION_FIELDS.sys_user`, whose ADR-0105 +D7 collision guard proves better-auth's own user schema owns no `locale` at +the pinned version. + +Who may write it is unchanged: the column is `readonly` on the object (ADR-0092 +D4, so the standard edit form does not advertise a write the runtime refuses) +and is NOT added to `MANAGED_EXTENSION_EDITABLE_FIELDS` or to the ADR-0092 D2 +self-service whitelist (`{name, image}`). Widening that whitelist so a user can +set their own language is a security-boundary decision recorded on #13881, not +made here; until then the column is written only by system-context callers (no +admin surface writes it today). + +Who reads it: the messaging channels, per recipient, after fan-out — see the +`@objectstack/service-messaging` changeset in the same release. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 54ada24c87..e1c87e38d1 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -164,13 +164,17 @@ the localizable path: - `template` names the bundle. The delivery path resolves `(name, locale)` against `sys_email_template` at delivery time and renders subject/body from - that row. **The locale is one value for the whole notification, not one per - recipient**: `payload.locale` if the producer set one — interpolated once, - before fan-out — else the deployment default (`II18nService.getDefaultLocale()`). - The platform has no per-user locale (`sys_user` carries no locale column), so - recipients whose personal languages differ all receive the same row. A per-user - locale is deferred until measured pull (maintainer ruling, 2026-08-13); it - layers in as an override at that same seam when it lands. + that row. **The locale is resolved per recipient, after fan-out**: the + recipient's own `sys_user.locale` when set, else the deployment default + (`II18nService.getDefaultLocale()`) — so two recipients of one notification + whose `locale` columns differ receive different rows of the same bundle + (maintainer ruling, 2026-09-01). A recipient whose column is empty or unset + always falls back to the deployment default; no value ever dead-letters a + delivery. A `payload.locale` set by the producer — the pre-ruling single + value for the whole notification — is not consulted. The same chain serves + the inbox channel's template path and the `sys_notification_template` rows + the email and SMS channels render, so one notification cannot arrive in two + languages across channels. - Inline `title` / `message` are the **non-localizable** path: raw strings sent to every recipient verbatim. The two paths cannot be combined on one node — the schema refuses the ambiguous shape rather than letting a runtime diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index a3f8790148..40ba00514a 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -96,7 +96,7 @@ const result = HttpConfigSchema.parse(data); | **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run | | **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | | **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. | -| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | +| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | | **templateData** | `Record` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. | | **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) | | **topic** | `string` | optional | Event topic (default: "notify") | diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 71116a6ba8..f60488dc8d 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -91,6 +91,10 @@ export const enObjects: NonNullable = { image: { label: "Profile Image" }, + locale: { + label: "Locale", + help: "Preferred language for notifications, as a BCP-47 tag (e.g. zh-CN, ja-JP). Read per recipient at delivery time; when unset the deployment default applies. Owned by objectql (better-auth is oblivious to this column)." + }, manager_id: { label: "Manager", help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index a02d8c361e..d0d9e7105a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -91,6 +91,10 @@ export const esESObjects: NonNullable = { image: { label: "Imagen de perfil" }, + locale: { + label: "Idioma", + help: "Idioma preferido para las notificaciones, como etiqueta BCP-47 (p. ej. zh-CN, ja-JP). Se lee por destinatario en el momento de la entrega; si no está definido, se aplica el idioma predeterminado del despliegue. Propiedad de objectql (better-auth ignora esta columna)." + }, manager_id: { label: "Gerente", help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 26b1458d04..7f3c5d7a58 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -91,6 +91,10 @@ export const jaJPObjects: NonNullable = { image: { label: "プロフィール画像" }, + locale: { + label: "言語", + help: "通知に使用する優先言語(BCP-47 タグ、例: zh-CN、ja-JP)。配信時に受信者ごとに読み取られ、未設定の場合はデプロイのデフォルト言語が適用されます。objectql が所有します(better-auth はこの列を認識しません)。" + }, manager_id: { label: "マネージャー", help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 050e1ece56..19fb6bd7f6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -91,6 +91,10 @@ export const zhCNObjects: NonNullable = { image: { label: "头像" }, + locale: { + label: "语言", + help: "通知使用的首选语言,BCP-47 标签(如 zh-CN、ja-JP)。投递时按收件人读取;未设置时采用部署默认语言。归 objectql 所有(better-auth 不感知此列)。" + }, manager_id: { label: "经理", help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 32f06af212..5b3b001ea4 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -735,6 +735,42 @@ export const SysUser = ObjectSchema.create({ group: 'Profile', }), + // #13881 — the user's own notification language, a first-class column + // (maintainer ruling 2026-09-01: 「`sys_user` 加 `locale` 一等列」; the + // preference-bag shape was rejected, so nothing falls back to + // sys_user_preference). BCP-47 tag (`zh-CN`, `ja-JP`). Read PER RECIPIENT + // by the messaging channels after fan-out (`service-messaging/src/ + // recipient-locale.ts` — the one read point), with the deployment default + // (`II18nService.getDefaultLocale()`) underneath: absent / empty / + // malformed always falls back, never dead-letters a delivery. + // + // Owned by objectql, exactly like `ai_access`: better-auth is oblivious + // to this column, and it is DELIBERATELY not a better-auth + // `additionalFields` entry — better-auth SELECTs explicit columns, so + // declaring it there would make getSession query a column an env that has + // not yet run schema-sync does not have (the `ai_access` note in + // auth-manager.ts). Registered as an extension field in plugin-auth's + // MANAGED_EXTENSION_FIELDS, whose ADR-0105 D7 guard proves the name does + // not collide with better-auth's own user schema at the pinned version. + // + // `readonly` for the same reason as every non-whitelisted field above + // (ADR-0092 D4): the identity write guard's self-service whitelist is + // {name, image}, this column is not on it, so a form edit would be + // stripped server-side; rendering it editable would advertise a write the + // runtime refuses. Widening that whitelist is a security-boundary decision + // recorded as an open question on #13881, not made here. + locale: Field.text({ + label: 'Locale', + required: false, + readonly: true, + maxLength: 35, + group: 'Profile', + description: + 'Preferred language for notifications, as a BCP-47 tag (e.g. zh-CN, ja-JP). ' + + 'Read per recipient at delivery time; when unset the deployment default applies. ' + + 'Owned by objectql (better-auth is oblivious to this column).', + }), + // ── Organization ───────────────────────────────────────────── manager_id: Field.lookup('sys_user', { label: 'Manager', diff --git a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts index 39c937f456..52611b868f 100644 --- a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts +++ b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts @@ -8,8 +8,9 @@ * caller's own `Accept-Language` first (only when it names a locale in * `AUTH_EMAIL_TEMPLATE_LOCALES`), and the deployment default second. The * 2026-08-13 ruling had made the deployment default the whole answer and - * rejected `Accept-Language` outright. Still no `sys_user.locale` column — - * that half stayed deferred. The ruling text of record lives on + * rejected `Accept-Language` outright. `sys_user.locale` exists since #13881 + * (2026-09-01) but auth mail does not read it yet — that is #14641's rung, + * and this file asserts nothing about it. The ruling text of record lives on * `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the * request rung's own cases are the last describe block in this file. * diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index dd8dffd335..b0069a2cf9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3029,8 +3029,12 @@ export class AuthManager { // very card one seat over. The 2026-09-02 ruling enumerates // signup, sign-in and password reset — sends where the requester // IS the recipient — and the superseded 2026-08-13 ruling named - // invitations as its own counterexample. So an invitee gets the - // deployment default until a per-user language exists to read. + // invitations as its own counterexample. A per-user language + // now EXISTS to read — `sys_user.locale` (#13881, ruling + // 2026-09-01) — but an invitee has no `sys_user` row until they + // accept, so this send keeps the deployment rung; reading the + // invitee's column where one exists is #14641's rung, not this + // card's. ...this.emailLocaleArg(), data: { inviter: { @@ -4452,9 +4456,10 @@ export class AuthManager { * entirely and `EmailService`'s ladder resolves its documented `en-US` * default exactly as before. * - * Still NOT a per-recipient stored preference: `sys_user` carries no locale - * column and the 2026-09-02 ruling continues to defer one. What is read is - * the language this request expressed, not a profile. + * Still NOT a per-recipient stored preference: `sys_user.locale` exists + * since #13881 (ruling 2026-09-01) but auth mail does not read it yet + * (#14762 for this send; #14641 for invitations). What is read is the + * language this request expressed, not a profile. */ private async sendChangeEmailNotice( from: { email: string; name?: string; id?: string }, @@ -4661,8 +4666,9 @@ export class AuthManager { * `kernel:ready` and on every settings change (same pattern as * {@link setAppName}). Unset ⇒ the built-in English text. * - * Per-user locale is not resolved yet — `sys_user` carries no locale - * column; when it grows one, resolution should prefer it (#2815). + * Per-user locale is not resolved here yet — `sys_user.locale` exists since + * #13881 (ruling 2026-09-01) and the messaging channels read it per + * recipient; auth SMS adopting it is #14762 (supersedes the #2815 note). */ setDefaultSmsLocale(locale: string | undefined): void { this.smsLocale = locale?.trim() || undefined; @@ -4706,9 +4712,12 @@ export class AuthManager { * the request rung applies only where the requester IS the recipient, and * why the invitation send below still reads this rung. * - * Per-user locale is STILL deferred by the 2026-09-02 ruling — `sys_user` - * carries no locale column and none is added here. When one arrives it - * layers on top as a third rung, so nothing here is wasted. + * Per-user locale EXISTS since #13881 (maintainer ruling 2026-09-01): + * `sys_user.locale`, resolved per recipient by service-messaging for + * notification mail (`recipient-locale.ts`). Auth mail does NOT read it + * yet — this ladder stays request rung → deployment rung. Layering the + * user's own column on top as a third rung is #14641 (invitations) and + * its own card for the other sends; nothing here is wasted by that. */ setDefaultEmailLocale(locale: string | undefined): void { this.emailLocale = normalizeAuthEmailLocale(locale); diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts index dd2f28421b..6b643a8672 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -862,8 +862,12 @@ describe('managed extension fields (ADR-0105 D7)', () => { it('admin-surface-only sys_user fields are declared but NOT generically editable', () => { // `manager_id` / `ai_access` drive authorization and AI seating; - // `primary_business_unit_id` is a projection plugin-sharing maintains. - for (const field of ['manager_id', 'ai_access', 'primary_business_unit_id']) { + // `primary_business_unit_id` is a projection plugin-sharing maintains; + // `locale` (#13881) is declared as ours so the D7 guard judges it, and + // stays off the editable map until a ruling widens the ADR-0092 D2 + // profile whitelist — an unwidened whitelist is the recorded state, not + // an oversight. + for (const field of ['manager_id', 'ai_access', 'primary_business_unit_id', 'locale']) { expect(managedExtensionFields('sys_user')).toContain(field); expect(managedExtensionEditableFields('sys_user')).not.toContain(field); } diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts index 346fe46646..fdf2d5b142 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -70,6 +70,16 @@ export const MANAGED_EXTENSION_FIELDS: Readonly { ); }); - it('describes `template` with the locale the delivery path actually resolves, not a per-recipient one', () => { + it('describes `template` with the locale chain the delivery path actually resolves — per recipient (#13881)', () => { // This description is rendered in the Studio form an author fills in, so - // it is the shortest path from wording to an authoring mistake. It used - // to say the row is "resolved by (name, recipient locale) ... and - // rendered per recipient", which reads as "each recipient's own language - // selects the row". It does not: `payload.locale` is interpolated once - // BEFORE fan-out and the fallback is the deployment default, so one - // locale serves the whole notification (`sys_user` carries no locale - // column; a per-user locale is deferred by the 2026-08-13 ruling). - // Acting on the old promise is a net regression — TEMPLATE_* failures - // classify `permanent` and dead-letter — so the qualification is pinned - // here, and a bare "recipient locale" is refused, to keep a later edit - // from quietly restoring it. + // it is the shortest path from wording to an authoring mistake. Since + // the 2026-09-01 ruling the delivery path resolves the locale PER + // RECIPIENT, after fan-out: the recipient's own `sys_user.locale`, else + // the deployment default. Until then this pin refused exactly that + // promise, because the path did not keep it. Both rungs are pinned by + // name, the producer's retired `payload.locale` is named as inert, and + // the pre-ruling single-value wording is refused so a later edit cannot + // quietly tell authors that converting their nodes does not localize. const engine = new AutomationEngine(createTestLogger()); registerNotifyNode(engine, createCtx()); const schema = engine.getActionDescriptor('notify')?.configSchema as | { properties?: { template?: { description?: string } } } | undefined; const description = schema?.properties?.template?.description ?? ''; - expect(description).toMatch(/not one per recipient/); - expect(description).toMatch(/payload\.locale/); + expect(description).toMatch(/per recipient/); + expect(description).toMatch(/sys_user\.locale/); expect(description).toMatch(/deployment default/); - expect(description).not.toMatch(/recipient locale/); + expect(description).toMatch(/payload\.locale is not consulted/); + expect(description).not.toMatch(/not one per recipient/); + expect(description).not.toMatch(/ONE value for the whole notification/); }); describe('with a messaging service registered', () => { diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 3b7f60566e..78622bf2bb 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -194,7 +194,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // how requiredness is owned there rather than by the form. template: { type: 'string', - description: 'Email template name (sys_email_template.name) — the localizable content path: resolved by (name, locale) at delivery time, rendering subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: payload.locale if the producer set one, else the deployment default (II18nService.getDefaultLocale()). The platform has no per-user locale, so recipients whose personal languages differ all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline title/message.', + description: 'Email template name (sys_email_template.name) — the localizable content path: resolved by (name, locale) at delivery time, rendering subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own sys_user.locale when set, else the deployment default (II18nService.getDefaultLocale()) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set payload.locale is not consulted. Mutually exclusive with inline title/message.', }, templateData: { type: 'object', @@ -285,9 +285,9 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const actorId = toStr(interpolate(cfg.actorId, variables, context)); // With a `template` reference the content lives in the template - // bundle, resolved by `(name, locale)` at delivery — one locale for - // the whole notification (`payload.locale`, else the deployment - // default), never one per recipient — so there is no inline title to + // bundle, resolved by `(name, locale)` at delivery — per recipient, + // after fan-out (#13881: the recipient's own `sys_user.locale`, + // else the deployment default) — so there is no inline title to // demand (the Zod contract already refused a node carrying NEITHER, // and one carrying BOTH). if (!title && !template) return { success: false, error: 'notify: title is required' }; @@ -378,9 +378,10 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // which the outbox snapshots onto each delivery row, so the // template resolution happens at delivery time in the channel // (email-channel.ts reads payload.template). The locale it - // resolves with is one value for the whole notification — - // `payload.locale`, interpolated once BEFORE fan-out, else the - // deployment default — never each recipient's own language. + // resolves with is each recipient's own — `sys_user.locale`, + // read AFTER fan-out by the channel, else the deployment + // default (#13881). Nothing about the locale is decided + // here; a `payload.locale` set by a producer is not read. // On the template path no inline title/body keys are set: // channels without template support fall back to the topic, // which is the honest degraded rendering, not ''. diff --git a/packages/services/service-messaging/src/email-channel.test.ts b/packages/services/service-messaging/src/email-channel.test.ts index 284d7fa430..b2bbceffe2 100644 --- a/packages/services/service-messaging/src/email-channel.test.ts +++ b/packages/services/service-messaging/src/email-channel.test.ts @@ -27,17 +27,24 @@ function delivery(over: Partial = {}, recipient = 'use }; } -/** Fake data engine: user id → email, and template lookups. */ -function fakeData(opts: { users?: Record; templates?: any[] } = {}) { +/** A `sys_user` row as the channel reads it: address plus (#13881) the user's own locale. */ +type FakeUser = string | { email: string; locale?: unknown }; + +/** Fake data engine: user id → email (+ locale), and template lookups. */ +function fakeData(opts: { users?: Record; templates?: any[] } = {}) { const users = opts.users ?? { user_1: 'ada@example.com' }; const templates = opts.templates ?? []; + const findOnes: Array<{ object: string; query: any }> = []; return { + findOnes, async findOne(object: string, query: any) { assertEngineFindOnePredicate(object, query); + findOnes.push({ object, query }); const w = query?.where ?? {}; if (object === 'sys_user') { - const email = users[String(w.id)]; - return email ? { email } : null; + const user = users[String(w.id)]; + if (!user) return null; + return typeof user === 'string' ? { email: user } : { ...user }; } if (object === 'sys_notification_template') { return templates.find((t) => t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null; @@ -192,7 +199,11 @@ describe('email channel', () => { expect(email.sent).toHaveLength(0); }); - it('a producer-supplied payload.locale wins over the deployment default', async () => { + it('a producer-set payload.locale is NOT consulted — the recipient chain decides (#13881)', async () => { + // Until the 2026-09-01 ruling this pin read the other way: the + // producer's single pre-fan-out value won over the deployment + // default. The ruling retired that value; the recipient's own + // column and the deployment default are the whole chain now. const email = fakeTemplateEmail(); const data = fakeData({ users: { user_1: 'ada@example.com' } }); const ch = createEmailChannel({ @@ -202,7 +213,7 @@ describe('email channel', () => { getDefaultTemplateLocale: () => 'ja-JP', }); await ch.send(silentCtx(), templateDelivery({ template: 'crm.large_deal_won', locale: 'es-ES' })); - expect(email.templated[0].locale).toBe('es-ES'); + expect(email.templated[0].locale).toBe('ja-JP'); }); it('with no deployment default, no locale is passed — sendTemplate resolves its documented en-US default', async () => { @@ -280,6 +291,197 @@ describe('email channel', () => { }); }); + // ── #13881 — the recipient locale is resolved PER RECIPIENT, after + // fan-out. Maintainer ruling 2026-09-01: 「解析链 = 收件人 `locale` → 部署 + // 默认,缺失恒回退,⛔ 任何路径不得死信」. Every pin below asserts WHICH row + // was picked for WHICH recipient (identity, not "old assertion gone"), + // and the dead-letter pin refuses the literal "undefined" shape hotcrm + // measured. The chain itself lives in `recipient-locale.ts` and is pinned + // there; this block pins what the email channel does with it, on BOTH + // arms (the notify `template` path and the sys_notification_template path). + describe('per-recipient locale (#13881)', () => { + function templateEmail() { + const templated: any[] = []; + return { + templated, + service: { + async send() { return { id: 'email_row_1' }; }, + async sendTemplate(input: any) { templated.push(input); return { id: 'email_row_9', status: 'sent' }; }, + }, + }; + } + const templated = (recipient = 'user_1') => delivery({ + title: 'deal.won', + body: '', + payload: { template: 'crm.large_deal_won', templateData: { dealName: 'Acme' } }, + }, recipient); + function build(data: any, email: any, deploymentDefault?: string) { + return createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + ...(deploymentDefault !== undefined ? { getDefaultTemplateLocale: () => deploymentDefault } : {}), + }); + } + + it("rung 1: the recipient's own sys_user.locale picks the row, over the deployment default", async () => { + const email = templateEmail(); + const data = fakeData({ users: { user_1: { email: 'ada@example.com', locale: 'zh-CN' } } }); + const r = await build(data, email, 'ja-JP').send(silentCtx(), templated()); + expect(r.ok).toBe(true); + // Identity pin: the exact input the email service received. + expect(email.templated).toEqual([{ + template: 'crm.large_deal_won', + to: 'ada@example.com', + data: { dealName: 'Acme' }, + locale: 'zh-CN', + }]); + }); + + it('two recipients of ONE notification receive different rows — resolved after fan-out, not once for all', async () => { + // The hotcrm pull: 4 published languages, every recipient got the + // deployment default. Same notification, two deliveries, two rows. + const email = templateEmail(); + const data = fakeData({ users: { + ada: { email: 'ada@example.com', locale: 'zh-CN' }, + eve: { email: 'eve@example.com', locale: 'es-ES' }, + } }); + const ch = build(data, email, 'ja-JP'); + await ch.send(silentCtx(), templated('ada')); + await ch.send(silentCtx(), templated('eve')); + expect(email.templated.map((t) => [t.to, t.locale])).toEqual([ + ['ada@example.com', 'zh-CN'], + ['eve@example.com', 'es-ES'], + ]); + }); + + it('rung 2: a recipient without a locale falls to the deployment default', async () => { + const email = templateEmail(); + const data = fakeData({ users: { user_1: { email: 'ada@example.com' } } }); + await build(data, email, 'ja-JP').send(silentCtx(), templated()); + expect(email.templated[0].locale).toBe('ja-JP'); + }); + + it.each([ + ['NULL', null], + ['empty string', ''], + ['whitespace', ' '], + ['the literal "undefined"', 'undefined'], + ['the literal "null"', 'null'], + ['a non-string', 42], + ['a malformed tag', 'not a locale!!'], + ])('dead-letter pin: a recipient locale of %s resolves to the deployment default and never reaches the lookup', async (_label, raw) => { + // hotcrm's measured dead-letter: `${pref?.value}` → "undefined" → + // TEMPLATE_NOT_FOUND (permanent) for every user without a row. + // Any of these shapes in the column must fall back, not fail. + const email = templateEmail(); + const data = fakeData({ users: { user_1: { email: 'ada@example.com', locale: raw } } }); + const r = await build(data, email, 'ja-JP').send(silentCtx(), templated()); + expect(r.ok).toBe(true); + expect(email.templated).toHaveLength(1); + expect(email.templated[0].locale).toBe('ja-JP'); + expect(email.templated[0].locale).not.toBe('undefined'); + }); + + it('nothing named anywhere ⇒ no `locale` key at all — sendTemplate resolves its documented en-US default', async () => { + const email = templateEmail(); + const data = fakeData({ users: { user_1: { email: 'ada@example.com', locale: 'undefined' } } }); + const r = await build(data, email).send(silentCtx(), templated()); + expect(r.ok).toBe(true); + expect(Object.prototype.hasOwnProperty.call(email.templated[0], 'locale')).toBe(false); + }); + + it('a literal email address has no row to read — the deployment default is its whole chain', async () => { + const email = templateEmail(); + const data = fakeData({ users: {} }); + await build(data, email, 'ja-JP').send(silentCtx(), templated('bob@example.com')); + expect(email.templated[0]).toMatchObject({ to: 'bob@example.com', locale: 'ja-JP' }); + expect(data.findOnes.filter((q: { object: string }) => q.object === 'sys_user')).toHaveLength(0); + }); + + it('reads the locale off the SAME sys_user row as the address — one query per recipient, no second read point', async () => { + const email = templateEmail(); + const data = fakeData({ users: { user_1: { email: 'ada@example.com', locale: 'zh-CN' } } }); + await build(data, email, 'ja-JP').send(silentCtx(), templated()); + const userReads = data.findOnes.filter((q: { object: string }) => q.object === 'sys_user'); + expect(userReads).toHaveLength(1); + expect(userReads[0].query).toEqual({ where: { id: 'user_1' }, fields: ['email', 'locale'] }); + }); + + it('a locale read that fails costs the language, never the delivery (ruling item 3)', async () => { + // A `userObject` override without the column: the projection + // throws, the channel retries address-only and falls to the + // deployment default. ok:true, one send, no dead letter. + const email = templateEmail(); + const base = fakeData({ users: { user_1: { email: 'ada@example.com', locale: 'zh-CN' } } }); + const data = { + ...base, + async findOne(object: string, query: any) { + if (object === 'sys_user' && (query?.fields ?? []).includes('locale')) { + throw new Error("Unknown field 'locale' on object 'sys_user'"); + } + return base.findOne(object, query); + }, + }; + const warned: string[] = []; + const ctx = { logger: { info: () => {}, warn: (m: string) => { warned.push(m); }, error: () => {} } }; + const r = await build(data, email, 'ja-JP').send(ctx, templated()); + expect(r.ok).toBe(true); + expect(email.templated).toEqual([{ + template: 'crm.large_deal_won', + to: 'ada@example.com', + data: { dealName: 'Acme' }, + locale: 'ja-JP', + }]); + expect(warned.some((m) => /retrying address-only/.test(m))).toBe(true); + }); + + it('the sys_notification_template arm uses the SAME resolution: the recipient\'s row picks the (topic, email, locale) row', async () => { + const email = fakeEmail(); + const data = fakeData({ + users: { + ada: { email: 'ada@example.com', locale: 'zh-CN' }, + bob: { email: 'bob@example.com' }, + }, + templates: [ + { topic: 'deal.won', channel: 'email', locale: 'en', is_active: true, subject: 'Won {{ payload.title }}', body: 'en body', format: 'text' }, + { topic: 'deal.won', channel: 'email', locale: 'zh-CN', is_active: true, subject: '成交 {{ payload.title }}', body: 'zh body', format: 'text' }, + { topic: 'deal.won', channel: 'email', locale: 'ja-JP', is_active: true, subject: '成約 {{ payload.title }}', body: 'ja body', format: 'text' }, + ], + }); + const ch = build(data, email, 'ja-JP'); + await ch.send(silentCtx(), delivery({}, 'ada')); + await ch.send(silentCtx(), delivery({}, 'bob')); + expect(email.sent.map((s) => [s.to, s.subject])).toEqual([ + ['ada@example.com', '成交 Deal closed'], // her own locale + ['bob@example.com', '成約 Deal closed'], // no locale ⇒ deployment default + ]); + }); + + it('the sys_notification_template arm still lands on the store\'s `en` floor when nothing is named', async () => { + const email = fakeEmail(); + const data = fakeData({ + users: { user_1: { email: 'ada@example.com', locale: 'undefined' } }, + templates: [{ topic: 'deal.won', channel: 'email', locale: 'en', is_active: true, subject: 'Won {{ payload.title }}', body: 'en body', format: 'text' }], + }); + await build(data, email).send(silentCtx(), delivery()); + expect(email.sent[0].subject).toBe('Won Deal closed'); + }); + + it('a producer-set payload.locale does not select the sys_notification_template row either', async () => { + const email = fakeEmail(); + const data = fakeData({ + users: { user_1: { email: 'ada@example.com', locale: 'zh-CN' } }, + templates: [ + { topic: 'deal.won', channel: 'email', locale: 'zh-CN', is_active: true, subject: '成交', body: 'zh', format: 'text' }, + { topic: 'deal.won', channel: 'email', locale: 'es-ES', is_active: true, subject: 'Ganado', body: 'es', format: 'text' }, + ], + }); + await build(data, email).send(silentCtx(), delivery({ payload: { title: 'x', body: 'y', locale: 'es-ES' } })); + expect(email.sent[0].subject).toBe('成交'); + }); + }); + // ── #11741 — organization threading. This channel is the producer the // ruling names as HOLDING an organization (`delivery.notification // .organizationId`, the tenant stamp the outbox snapshots per delivery), diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts index 75ac110d88..bf68a6a5c7 100644 --- a/packages/services/service-messaging/src/email-channel.ts +++ b/packages/services/service-messaging/src/email-channel.ts @@ -13,9 +13,10 @@ import { renderNotification, DEFAULT_LOCALE, } from './template-renderer.js'; +import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js'; -/** The user identity object a recipient id is resolved to an address against. */ -export const USER_OBJECT = 'sys_user'; +/** The user identity object a recipient id is resolved to an address against (re-exported from the locale seam, #13881). */ +export { USER_OBJECT }; /** * Structural view of the email service (`@objectstack/plugin-email`'s @@ -84,17 +85,18 @@ export interface EmailChannelOptions { /** Locale used when the delivery carries none (default {@link DEFAULT_LOCALE}). */ defaultLocale?: string; /** - * The recipient locale for `sys_email_template` resolution (#9205) — - * probed lazily at delivery time so it tracks live settings changes. + * The DEPLOYMENT DEFAULT locale — `II18nService.getDefaultLocale()`, + * probed lazily at delivery time so it tracks live settings changes and + * a late-registered i18n service. * - * The measured source, and its limits, spelled out: the platform has no - * per-user locale today (`sys_user` carries no locale column; the - * 2026-08-13 ruling defers one until measured pull), and request-scoped - * locale (`Accept-Language` → `ExecutionContext.requestLocale`) does not - * exist at async delivery time. So "recipient locale" resolves to the - * deployment default — `II18nService.getDefaultLocale()`, the same ruled - * source the auth emails use (#8195) — and a per-user locale, when it - * lands, plugs in here. + * Since #13881 (maintainer ruling 2026-09-01) this is the SECOND rung of + * the recipient-locale chain, not the whole of it: the first is the + * recipient's own `sys_user.locale`, read off the same row the address + * comes from, and the two are composed in ONE place — + * `recipient-locale.ts` (`resolveRecipientLocale`). The 2026-08-13 + * deferral that made this the whole answer lifted when hotcrm measured + * the pull. Request-scoped locale (`Accept-Language`) still does not + * exist at async delivery time and is not a rung. */ getDefaultTemplateLocale?(): string | undefined; } @@ -122,9 +124,16 @@ const EMAIL_SHAPE = (s: string): boolean => { * A delivery whose payload carries a `template` reference (a `notify` node's * localizable path, #9205) takes precedence over both: it routes through * `IEmailService.sendTemplate({ template, locale, data })`, which resolves the - * `sys_email_template` bundle by `(name, recipient locale)` — the locale being - * `payload.locale` if the producer set one, else the deployment default from - * {@link EmailChannelOptions.getDefaultTemplateLocale}. + * `sys_email_template` bundle by `(name, recipient locale)`. + * + * The recipient locale is resolved HERE, per recipient, after fan-out + * (#13881, maintainer ruling 2026-09-01): the recipient's own + * `sys_user.locale` — read off the same row the address comes from — else the + * deployment default from {@link EmailChannelOptions.getDefaultTemplateLocale}; + * absent/empty/malformed always falls back and never dead-letters. Both arms + * of this channel (the `sendTemplate` path and the `sys_notification_template` + * path) use that one resolution. A producer-set `payload.locale` — the + * pre-ruling single value for the whole notification — is no longer consulted. * * Degrades like the inbox channel: no email service ⇒ logged no-op success * (capability not installed); a recipient with no resolvable address ⇒ a @@ -134,21 +143,63 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel const userObject = opts.userObject ?? USER_OBJECT; const defaultLocale = opts.defaultLocale ?? DEFAULT_LOCALE; - async function resolveAddress( + const deploymentLocale = (): string | undefined => opts.getDefaultTemplateLocale?.(); + + /** + * The recipient, resolved ONCE per delivery: the address to send to and + * the locale to render in (#13881 — one `sys_user` read yields both, so + * the per-recipient locale costs no second query). + */ + interface ResolvedRecipient { + address: string; + /** Already composed with the deployment default; `undefined` ⇒ the ladders' documented floor. */ + locale: string | undefined; + } + + async function resolveRecipient( ctx: MessagingChannelContext, data: IDataEngine | undefined, recipient: string, - ): Promise { - if (EMAIL_SHAPE(recipient)) return recipient; // already an address + ): Promise { + if (EMAIL_SHAPE(recipient)) { + // A literal address has no `sys_user` row to read a locale from — + // for it the deployment default is the whole chain. + return { address: recipient, locale: resolveRecipientLocale(undefined, deploymentLocale) }; + } if (!data) return undefined; + let user: Record | null | undefined; + // Set only when the row was read WITH the locale projection: after a + // retry the column was never asked for, so whatever the row carries + // under that key is not a read — rung 2 applies. + let localeRead = false; try { - const user = await data.findOne(userObject, { where: { id: recipient }, fields: ['email'] }); - const email = user?.email; - return typeof email === 'string' && EMAIL_SHAPE(email) ? email : undefined; + user = await data.findOne(userObject, { + where: { id: recipient }, + fields: ['email', RECIPIENT_LOCALE_FIELD], + }); + localeRead = true; } catch (err) { - ctx.logger.warn(`[email] address lookup for '${recipient}' failed (${(err as Error).message})`); - return undefined; + // Ruling item 3: NO path may dead-letter because of the locale + // read. A `userObject` override that lacks the column must still + // deliver — retry the address alone and fall to the deployment + // default. Said once per delivery, at `warn`, because the loss is + // functional (a language), not durability. + ctx.logger.warn( + `[email] recipient lookup for '${recipient}' with '${RECIPIENT_LOCALE_FIELD}' failed (${(err as Error).message}); retrying address-only`, + ); + try { + user = await data.findOne(userObject, { where: { id: recipient }, fields: ['email'] }); + } catch (retryErr) { + ctx.logger.warn(`[email] address lookup for '${recipient}' failed (${(retryErr as Error).message})`); + return undefined; + } } + const email = user?.email; + if (!(typeof email === 'string' && EMAIL_SHAPE(email))) return undefined; + return { + address: email, + locale: resolveRecipientLocale(localeRead ? user?.[RECIPIENT_LOCALE_FIELD] : undefined, deploymentLocale), + }; } return { @@ -162,10 +213,11 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel } const n = delivery.notification; - const address = await resolveAddress(ctx, opts.getData(), delivery.recipient); - if (!address) { + const resolved = await resolveRecipient(ctx, opts.getData(), delivery.recipient); + if (!resolved) { return { ok: false, error: `no email address for recipient '${delivery.recipient}'` }; } + const { address, locale: recipientLocale } = resolved; const payload = (n.payload ?? {}) as Record; @@ -175,7 +227,9 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel // happens HERE, per recipient, because this is the first moment a // single recipient exists: `sendTemplate` picks the // `(name, recipient locale)` row with its documented en-US ladder - // and renders `templateData` into the `{{var}}` holes. + // and renders `templateData` into the `{{var}}` holes. The + // recipient locale is the one resolved above (#13881): the + // recipient's own `sys_user.locale`, else the deployment default. const templateName = typeof payload.template === 'string' && payload.template.trim() ? payload.template.trim() @@ -189,9 +243,7 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel error: `TEMPLATE_UNSUPPORTED: notify template '${templateName}' needs an email service with sendTemplate(); the registered 'email' service does not provide it`, }; } - const templateLocale = typeof payload.locale === 'string' && payload.locale.trim() - ? payload.locale.trim() - : opts.getDefaultTemplateLocale?.(); + const templateLocale = recipientLocale; const data = (payload.templateData ?? undefined) as Record | undefined; try { const result = (await email.sendTemplate({ @@ -221,7 +273,10 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel } } - const locale = typeof payload.locale === 'string' ? payload.locale : defaultLocale; + // Same per-recipient resolution on the `sys_notification_template` + // arm (#13881); the store walks its own ladder (`zh-CN` → `zh` → + // DEFAULT_LOCALE) underneath, so a tag with no row still renders. + const locale = recipientLocale ?? defaultLocale; const template = await opts.store.load(n.topic ?? '', 'email', locale); const rendered = renderNotification(template, { topic: n.topic ?? '', diff --git a/packages/services/service-messaging/src/inbox-channel.test.ts b/packages/services/service-messaging/src/inbox-channel.test.ts index 7390e5dc12..2ba87a8ef3 100644 --- a/packages/services/service-messaging/src/inbox-channel.test.ts +++ b/packages/services/service-messaging/src/inbox-channel.test.ts @@ -196,7 +196,7 @@ describe('inbox channel', () => { }; } - const templateDelivery = (locale?: string) => delivery({ + const templateDelivery = () => delivery({ // The emit-time degraded fallback (title=topic, body='') that the // renderer must REPLACE. title: 'deal.won', @@ -204,16 +204,23 @@ describe('inbox channel', () => { payload: { template: 'deal.won_email', templateData: { deal: 'Acme' }, - ...(locale ? { locale } : {}), }, }); + /** + * #13881 — the recipient's own `sys_user.locale`, answered off the + * row the channel reads on the template path. `undefined` ⇒ the user + * row exists but carries no locale. + */ + const userWithLocale = (locale: unknown) => (obj: string, query: any) => + obj === 'sys_user' && query?.where?.id === 'user_1' ? { locale } : null; + it('renders title/body_md through renderTemplate: subject → title, text → body_md', async () => { - const data = fakeData(); + const data = fakeData(undefined, userWithLocale('zh-CN')); const r = fakeRenderer(); const ch = createInboxChannel({ getData: () => data.engine, getEmail: () => r.email }); - const result = await ch.send(silentCtx(), templateDelivery('zh-CN')); + const result = await ch.send(silentCtx(), templateDelivery()); expect(result.ok).toBe(true); expect(r.calls).toEqual([{ @@ -225,8 +232,8 @@ describe('inbox channel', () => { expect(data.inserts[0].row.body_md).toBe('text body'); }); - it('falls back to the deployment default locale when the payload names none', async () => { - const data = fakeData(); + it('falls back to the deployment default locale when the recipient has none (#13881 rung 2)', async () => { + const data = fakeData(undefined, userWithLocale(undefined)); const r = fakeRenderer(); const ch = createInboxChannel({ getData: () => data.engine, @@ -237,6 +244,84 @@ describe('inbox channel', () => { await ch.send(silentCtx(), templateDelivery()); expect(r.calls[0].locale).toBe('ja-JP'); + // The locale was read off the recipient's own row — the same + // chain the email channel resolves — and only on this path. + expect(data.findOnes.filter((q) => q.object === 'sys_user')).toEqual([ + { object: 'sys_user', query: { where: { id: 'user_1' }, fields: ['locale'] } }, + ]); + }); + + it("the recipient's own sys_user.locale outranks the deployment default (#13881 rung 1)", async () => { + const data = fakeData(undefined, userWithLocale('es-ES')); + const r = fakeRenderer(); + const ch = createInboxChannel({ + getData: () => data.engine, + getEmail: () => r.email, + getDefaultTemplateLocale: () => 'ja-JP', + }); + + const result = await ch.send(silentCtx(), templateDelivery()); + + expect(result.ok).toBe(true); + expect(r.calls[0].locale).toBe('es-ES'); + expect(data.inserts[0].row.title).toBe('[es-ES] subject for deal.won_email'); + }); + + it('a producer-set payload.locale is NOT consulted (#13881 retired the pre-fan-out single value)', async () => { + const data = fakeData(undefined, userWithLocale('zh-CN')); + const r = fakeRenderer(); + const ch = createInboxChannel({ getData: () => data.engine, getEmail: () => r.email }); + + await ch.send(silentCtx(), delivery({ + title: 'deal.won', + body: '', + payload: { template: 'deal.won_email', templateData: { deal: 'Acme' }, locale: 'es-ES' }, + })); + + expect(r.calls[0].locale).toBe('zh-CN'); + }); + + it('dead-letter pin: the literal "undefined" in the column falls to the deployment default, never through', async () => { + const data = fakeData(undefined, userWithLocale('undefined')); + const r = fakeRenderer(); + const ch = createInboxChannel({ + getData: () => data.engine, + getEmail: () => r.email, + getDefaultTemplateLocale: () => 'ja-JP', + }); + + const result = await ch.send(silentCtx(), templateDelivery()); + + expect(result.ok).toBe(true); + expect(r.calls[0].locale).toBe('ja-JP'); + }); + + it('a failing locale read costs the language, never the delivery (ruling item 3)', async () => { + const data = fakeData(undefined, (obj: string) => { + if (obj === 'sys_user') throw new Error("Unknown field 'locale' on object 'sys_user'"); + return null; + }); + const r = fakeRenderer(); + const ch = createInboxChannel({ + getData: () => data.engine, + getEmail: () => r.email, + getDefaultTemplateLocale: () => 'ja-JP', + }); + + const result = await ch.send(silentCtx(), templateDelivery()); + + expect(result.ok).toBe(true); + expect(r.calls[0].locale).toBe('ja-JP'); + expect(data.inserts).toHaveLength(1); + }); + + it('the inline (non-template) path never reads the recipient row — no localized row to pick', async () => { + const data = fakeData(undefined, userWithLocale('zh-CN')); + const ch = createInboxChannel({ getData: () => data.engine }); + + await ch.send(silentCtx(), delivery()); + + expect(data.findOnes.filter((q) => q.object === 'sys_user')).toHaveLength(0); }); it('fails LOUDLY (TEMPLATE_UNSUPPORTED) when no email service is registered', async () => { @@ -269,13 +354,13 @@ describe('inbox channel', () => { }); it('surfaces renderTemplate failure codes on the delivery row and grades them permanent', async () => { - const data = fakeData(); + const data = fakeData(undefined, userWithLocale('zh-CN')); const r = fakeRenderer(() => { throw new Error('TEMPLATE_NOT_FOUND: deal.won_email (locale=zh-CN)'); }); const ch = createInboxChannel({ getData: () => data.engine, getEmail: () => r.email }); - const result = await ch.send(silentCtx(), templateDelivery('zh-CN')); + const result = await ch.send(silentCtx(), templateDelivery()); expect(result.ok).toBe(false); expect(result.error).toMatch(/^TEMPLATE_NOT_FOUND:/); diff --git a/packages/services/service-messaging/src/inbox-channel.ts b/packages/services/service-messaging/src/inbox-channel.ts index 28ba18677e..eda0f60785 100644 --- a/packages/services/service-messaging/src/inbox-channel.ts +++ b/packages/services/service-messaging/src/inbox-channel.ts @@ -9,6 +9,7 @@ import type { SendResult, } from './channel.js'; import type { EmailSenderSurface } from './email-channel.js'; +import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js'; /** The object the inbox channel writes rows to. */ export const INBOX_OBJECT = 'sys_inbox_message'; @@ -43,12 +44,15 @@ export interface InboxChannelOptions { */ getEmail?(): EmailSenderSurface | undefined; /** - * The recipient locale for `sys_email_template` resolution on the - * template path — same lazily-probed deployment-default source as - * `EmailChannelOptions.getDefaultTemplateLocale` (#9205), consulted only - * when the delivery's payload carries no `locale`. + * The DEPLOYMENT DEFAULT locale for `sys_email_template` resolution on + * the template path — same lazily-probed source as + * `EmailChannelOptions.getDefaultTemplateLocale` (#9205). Since #13881 it + * is the second rung under the recipient's own `sys_user.locale`, composed + * in `recipient-locale.ts` exactly as the email channel composes it. */ getDefaultTemplateLocale?(): string | undefined; + /** User identity object the recipient's locale is read from (default `sys_user`). */ + userObject?: string; } /** @@ -66,6 +70,30 @@ export interface InboxChannelOptions { export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel { const objectName = opts.objectName ?? INBOX_OBJECT; const receiptObject = opts.receiptObject ?? RECEIPT_OBJECT; + const userObject = opts.userObject ?? USER_OBJECT; + const deploymentLocale = (): string | undefined => opts.getDefaultTemplateLocale?.(); + + /** + * #13881 — the recipient's own `sys_user.locale`, read only on the + * template path (the inline path renders no localized row). A failed read + * costs the language, never the delivery: warn once and fall to the + * deployment default. + */ + async function readRecipientLocale( + ctx: MessagingChannelContext, + data: IDataEngine, + userId: string, + ): Promise { + try { + const user = await data.findOne(userObject, { where: { id: userId }, fields: [RECIPIENT_LOCALE_FIELD] }); + return user?.[RECIPIENT_LOCALE_FIELD]; + } catch (err) { + ctx.logger.warn( + `[inbox] locale lookup for '${userId}' failed (${(err as Error).message}); using the deployment default`, + ); + return undefined; + } + } const now = opts.now ?? (() => new Date().toISOString()); /** @@ -140,9 +168,15 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel error: `TEMPLATE_UNSUPPORTED: notify template '${templateName}' needs an email service with renderTemplate(); ${email ? "the registered 'email' service does not provide it" : "no 'email' service is registered"}`, }; } - const templateLocale = typeof payload.locale === 'string' && payload.locale.trim() - ? payload.locale.trim() - : opts.getDefaultTemplateLocale?.(); + // Per-recipient locale (#13881): the recipient's own + // `sys_user.locale`, else the deployment default — the same + // chain the email channel resolves, so one notification cannot + // arrive in two languages across channels. `payload.locale` is + // no longer consulted. + const templateLocale = resolveRecipientLocale( + await readRecipientLocale(ctx, data, userId), + deploymentLocale, + ); const templateData = (payload.templateData ?? undefined) as Record | undefined; try { const rendered = await email.renderTemplate({ diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index f29c7852d5..6edfd49fef 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -62,6 +62,12 @@ export type { InboxChannelOptions } from './inbox-channel.js'; export { createEmailChannel, USER_OBJECT as EMAIL_USER_OBJECT } from './email-channel.js'; export type { EmailChannelOptions, EmailSenderSurface } from './email-channel.js'; export { createSmsChannel } from './sms-channel.js'; +// #13881 — the ONE per-recipient locale read point every channel resolves through. +export { + RECIPIENT_LOCALE_FIELD, + normalizeRecipientLocale, + resolveRecipientLocale, +} from './recipient-locale.js'; export type { SmsChannelOptions, SmsSenderSurface } from './sms-channel.js'; // Templates + renderer (ADR-0030 P3) diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 8d97a7a303..ef33fa83b1 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -135,16 +135,17 @@ export class MessagingServicePlugin implements Plugin { return undefined; } }; - // #9205 — the recipient locale for `sys_email_template` resolution - // on the notify template path. Probed lazily at delivery time (not - // captured at boot) so it tracks live `localization.locale` / - // stack-config changes; same ruled source as the auth emails - // (#8195: `II18nService.getDefaultLocale()`), because the platform - // has no per-user locale yet and no request exists at async - // delivery time. Both hops probed: `getService` throws for an - // unregistered service, and `getDefaultLocale` is optional on the - // contract — either missing leaves the locale unset, which lands - // the documented en-US default. + // #9205 / #13881 — the DEPLOYMENT DEFAULT locale for template + // resolution: the second rung of the per-recipient chain + // (`recipient-locale.ts`: the recipient's own `sys_user.locale`, else + // this). Probed lazily at delivery time (not captured at boot) so it + // tracks live `localization.locale` / stack-config changes; same + // ruled source as the auth emails' deployment rung (#8195: + // `II18nService.getDefaultLocale()`). No request exists at async + // delivery time, so `Accept-Language` is not a rung. Both hops + // probed: `getService` throws for an unregistered service, and + // `getDefaultLocale` is optional on the contract — either missing + // leaves the locale unset, which lands the documented en-US default. const getDefaultTemplateLocale = (): string | undefined => { try { const i18n = ctx.getService<{ getDefaultLocale?: () => string }>('i18n'); @@ -247,7 +248,7 @@ export class MessagingServicePlugin implements Plugin { ctx.hook('kernel:ready', async () => { if (getEmail()) { service.registerChannel(createEmailChannel({ getEmail, getData, store: templateStore, getDefaultTemplateLocale })); - ctx.logger.info('[messaging] email channel registered (renders sys_notification_template; notify `template` refs resolve sys_email_template by (name, locale) — one locale per notification: payload.locale, else the deployment default)'); + ctx.logger.info('[messaging] email channel registered (renders sys_notification_template; notify `template` refs resolve sys_email_template by (name, locale) — resolved per recipient: sys_user.locale, else the deployment default)'); } }); @@ -264,8 +265,8 @@ export class MessagingServicePlugin implements Plugin { }; ctx.hook('kernel:ready', async () => { if (getSms()) { - service.registerChannel(createSmsChannel({ getSms, getData, store: templateStore })); - ctx.logger.info('[messaging] sms channel registered (renders sys_notification_template)'); + service.registerChannel(createSmsChannel({ getSms, getData, store: templateStore, getDefaultTemplateLocale })); + ctx.logger.info('[messaging] sms channel registered (renders sys_notification_template — resolved per recipient: sys_user.locale, else the deployment default)'); } }); } diff --git a/packages/services/service-messaging/src/recipient-locale.test.ts b/packages/services/service-messaging/src/recipient-locale.test.ts new file mode 100644 index 0000000000..1f810a7acb --- /dev/null +++ b/packages/services/service-messaging/src/recipient-locale.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13881 — the per-recipient locale chain, pinned at its ONE read point. + * + * Maintainer ruling 2026-09-01: 「解析链 = 收件人 `locale` → 部署默认,缺失恒回退, + * ⛔ 任何路径不得死信」. The channel suites pin what each channel DOES with + * the resolved value; this file pins the resolution itself, so the two rungs + * and the dead-letter refusal cannot drift apart across channels. + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeRecipientLocale, resolveRecipientLocale, RECIPIENT_LOCALE_FIELD, USER_OBJECT } from './recipient-locale.js'; + +describe('#13881 — normalizeRecipientLocale', () => { + it('passes a BCP-47-shaped tag through, trimmed', () => { + expect(normalizeRecipientLocale('zh-CN')).toBe('zh-CN'); + expect(normalizeRecipientLocale(' ja-JP ')).toBe('ja-JP'); + expect(normalizeRecipientLocale('en')).toBe('en'); + expect(normalizeRecipientLocale('zh-Hans-CN')).toBe('zh-Hans-CN'); + expect(normalizeRecipientLocale('es-419')).toBe('es-419'); + }); + + it('refuses the dead-letter shape hotcrm measured: the literal string "undefined" (and "null")', () => { + // A missing preference interpolated to `${undefined}` dead-lettered + // every delivery for every user without a row. That literal must never + // reach a template lookup from this seam, whatever a lossy producer + // left in the column. + expect(normalizeRecipientLocale('undefined')).toBeUndefined(); + expect(normalizeRecipientLocale('null')).toBeUndefined(); + expect(normalizeRecipientLocale(' undefined ')).toBeUndefined(); + }); + + it('refuses absent, empty, whitespace and non-string values', () => { + for (const raw of [undefined, null, '', ' ', 0, 42, true, {}, [], Symbol('x')]) { + expect(normalizeRecipientLocale(raw), `${String(raw)} must not name a locale`).toBeUndefined(); + } + }); + + it('refuses anything not shaped like a locale tag', () => { + for (const raw of ['not a locale!!', 'zh_CN', '-zh', 'zh-', 'a', 'toolonglanguage', 'en-US;q=0.9', 'zh-CN,zh']) { + expect(normalizeRecipientLocale(raw), `${raw} must not name a locale`).toBeUndefined(); + } + }); +}); + +describe('#13881 — resolveRecipientLocale (the ruled chain)', () => { + it("rung 1: the recipient's own locale wins over the deployment default", () => { + expect(resolveRecipientLocale('zh-CN', () => 'ja-JP')).toBe('zh-CN'); + }); + + it('rung 2: a recipient without one falls to the deployment default', () => { + expect(resolveRecipientLocale(undefined, () => 'ja-JP')).toBe('ja-JP'); + expect(resolveRecipientLocale(null, () => 'ja-JP')).toBe('ja-JP'); + expect(resolveRecipientLocale('', () => 'ja-JP')).toBe('ja-JP'); + }); + + it('the dead-letter literal falls to the deployment default, never through', () => { + expect(resolveRecipientLocale('undefined', () => 'ja-JP')).toBe('ja-JP'); + expect(resolveRecipientLocale('undefined', undefined)).toBeUndefined(); + }); + + it('nothing named anywhere ⇒ undefined (the ladders\' documented floor), never ""', () => { + expect(resolveRecipientLocale(undefined, undefined)).toBeUndefined(); + expect(resolveRecipientLocale(undefined, () => undefined)).toBeUndefined(); + expect(resolveRecipientLocale(undefined, () => '')).toBeUndefined(); + expect(resolveRecipientLocale(undefined, () => ' ')).toBeUndefined(); + }); + + it('a throwing deployment-default probe costs the language, not the delivery', () => { + expect(() => resolveRecipientLocale(undefined, () => { throw new Error('i18n down'); })).not.toThrow(); + expect(resolveRecipientLocale(undefined, () => { throw new Error('i18n down'); })).toBeUndefined(); + // …and is not even consulted when rung 1 answers. + expect(resolveRecipientLocale('zh-CN', () => { throw new Error('i18n down'); })).toBe('zh-CN'); + }); + + it('is lazy: the deployment default is probed only when rung 1 is silent', () => { + let probed = 0; + resolveRecipientLocale('zh-CN', () => { probed += 1; return 'ja-JP'; }); + expect(probed).toBe(0); + resolveRecipientLocale(undefined, () => { probed += 1; return 'ja-JP'; }); + expect(probed).toBe(1); + }); + + it('names the column and object every channel reads', () => { + // The channels project this field off the recipient row; the object + // is the one `sys_user` declares the column on (platform-objects). + expect(RECIPIENT_LOCALE_FIELD).toBe('locale'); + expect(USER_OBJECT).toBe('sys_user'); + }); +}); diff --git a/packages/services/service-messaging/src/recipient-locale.ts b/packages/services/service-messaging/src/recipient-locale.ts new file mode 100644 index 0000000000..a361f6e6fd --- /dev/null +++ b/packages/services/service-messaging/src/recipient-locale.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13881 — the recipient locale, resolved PER RECIPIENT, after fan-out. + * + * Maintainer ruling 2026-09-01, quoted verbatim and untranslated: + * + * > **解析点移到 fan-out 后按收件人**:`payload.locale` 不再是 fan-out 前单值 + * > **解析链 = 收件人 `locale` → 部署默认**(`II18nService.getDefaultLocale()`),缺失恒回退,⛔ 任何路径不得死信 + * + * This module is the ONE read point of that chain. Every channel that picks a + * localized row for a recipient (`sys_email_template` on the notify template + * path, `sys_notification_template` on the topic path) resolves through + * {@link resolveRecipientLocale}, so "which language does this recipient get" + * has exactly one answer across email, inbox and SMS — a notification cannot + * arrive in Chinese by mail and in English in the inbox. + * + * ## The chain + * + * 1. the recipient's own `sys_user.locale` ({@link RECIPIENT_LOCALE_FIELD}). + * Email and SMS read it off the SAME `sys_user` row they already fetch + * for the address — one query per recipient, no second lookup there; the + * inbox channel, which never read the row before, makes one read for it + * on the template path only; + * 2. the deployment default — `II18nService.getDefaultLocale()`, probed + * lazily at delivery time by the plugin (`getDefaultTemplateLocale`). + * + * Absent, empty, whitespace, non-string, or malformed ⇒ rung 2. Nothing in + * rung 1 can ever produce a value the template lookup has not seen a locale + * tag shaped like. Rung 2 absent too ⇒ `undefined`, which is what the + * downstream ladders are written against: `IEmailService.sendTemplate` then + * resolves its documented `en-US` default, and `NotificationTemplateStore` + * walks to {@link DEFAULT_LOCALE}. + * + * ## What is deliberately NOT in the chain + * + * - `payload.locale` — the pre-ruling single value, interpolated once BEFORE + * fan-out so every recipient of a notify node got the same row. The ruling + * retired it (「不再是 fan-out 前单值」) and measured no in-repo producer; + * a value a producer still writes there is ignored, and the notify node's + * contract text says so. + * - `sys_user_preference` — the ruling rejected the preference-bag shape + * (option B) outright; there is no fallback read from it. + * - request-scoped locale (`Accept-Language`) — does not exist at async + * delivery time. + * + * ## The dead-letter pin + * + * hotcrm measured the failure shape this chain must never reproduce: a + * missing preference interpolated to the literal string `"undefined"` and + * handed to the template lookup, which then dead-lettered every delivery for + * every user without a preference row (`TEMPLATE_NOT_FOUND` classifies + * `permanent`). {@link normalizeRecipientLocale} refuses that literal — and + * anything else that is not a locale tag — so the only value that can reach a + * lookup from rung 1 is a tag-shaped string the recipient actually holds. + * + * ## Interaction with the `TEMPLATE_*` permanent-failure class + * + * The downstream ladders are unchanged: a named locale that has no row falls + * to `en-US` inside `sendTemplate`, and to `DEFAULT_LOCALE` inside the store. + * So a recipient locale can dead-letter a delivery ONLY where the deployment + * default would have too — a bundle with no `en-US` row and no row for the + * requested tag. The one asymmetry: such a bundle that carries the deployment + * default's row but not `en-US` delivered under the old single value and + * fails for a recipient whose own locale is a third tag. That bundle is off + * the documented contract (`en-US` is the ladder's floor), so the fix is the + * bundle, not a third rung here. + */ + +/** The `sys_user` column that carries a recipient's own notification language. */ +export const RECIPIENT_LOCALE_FIELD = 'locale'; + +/** The user identity object a recipient id is resolved against. */ +export const USER_OBJECT = 'sys_user'; + +/** + * BCP-47-shaped: a 2–8 letter language subtag, then any number of 1–8 + * alphanumeric subtags (`zh`, `zh-CN`, `zh-Hans-CN`, `es-419`). Shape only — + * membership is the template bundle's business, and the ladders below handle + * a shipped-nowhere tag by falling to their floor. + */ +const LOCALE_TAG_SHAPE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; + +/** + * The stringified-nothing literals a lossy producer can leave in a column. + * hotcrm's measured dead-letter shape was exactly `"undefined"`; `"null"` is + * the same accident one serializer over. + */ +const NOTHING_LITERALS: ReadonlySet = new Set(['undefined', 'null']); + +/** + * The recipient's own locale, or `undefined` when the value cannot name one. + * + * Pure and total: never throws, never returns a non-tag string. Trims + * whitespace; refuses empty, non-string, the stringified-nothing literals, and + * anything not shaped like a BCP-47 tag. + */ +export function normalizeRecipientLocale(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined; + const value = raw.trim(); + if (!value || NOTHING_LITERALS.has(value)) return undefined; + return LOCALE_TAG_SHAPE.test(value) ? value : undefined; +} + +/** + * The ruled chain, as one function: the recipient's own `sys_user.locale` + * (already read off the recipient row — pass the raw column value) → the + * deployment default (probed lazily, so live `localization` changes and a + * late-registered i18n service are both honoured) → `undefined`. + * + * `deploymentDefault` may itself return an empty or non-string value; that is + * normalized too, because "nothing named" must arrive at the ladders as an + * absent key, never as `''`. + */ +export function resolveRecipientLocale( + recipientLocale: unknown, + deploymentDefault: (() => string | undefined) | undefined, +): string | undefined { + const own = normalizeRecipientLocale(recipientLocale); + if (own) return own; + let fallback: unknown; + try { + fallback = deploymentDefault?.(); + } catch { + // The deployment default is a best-effort probe — a throwing i18n + // service must not cost the delivery. Nothing named ⇒ the ladders' + // documented floor. + fallback = undefined; + } + return normalizeRecipientLocale(fallback); +} diff --git a/packages/services/service-messaging/src/sms-channel.test.ts b/packages/services/service-messaging/src/sms-channel.test.ts index 67adb3f292..89c59ebb98 100644 --- a/packages/services/service-messaging/src/sms-channel.test.ts +++ b/packages/services/service-messaging/src/sms-channel.test.ts @@ -27,17 +27,24 @@ function delivery(over: Partial = {}, recipient = 'use }; } -/** Fake data engine: user id → phone_number, and template lookups. */ -function fakeData(opts: { users?: Record; templates?: any[] } = {}) { +/** A `sys_user` row as the channel reads it: number plus (#13881) the user's own locale. */ +type FakeUser = string | { phone_number: string; locale?: unknown }; + +/** Fake data engine: user id → phone_number (+ locale), and template lookups. */ +function fakeData(opts: { users?: Record; templates?: any[] } = {}) { const users = opts.users ?? { user_1: '+8613800000000' }; const templates = opts.templates ?? []; + const findOnes: Array<{ object: string; query: any }> = []; return { + findOnes, async findOne(object: string, query: any) { assertEngineFindOnePredicate(object, query); + findOnes.push({ object, query }); const w = query?.where ?? {}; if (object === 'sys_user') { - const phone = users[String(w.id)]; - return phone ? { phone_number: phone } : null; + const user = users[String(w.id)]; + if (!user) return null; + return typeof user === 'string' ? { phone_number: user } : { ...user }; } if (object === 'sys_notification_template') { return templates.find((t) => t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null; @@ -106,6 +113,69 @@ describe('sms channel', () => { expect(sms.sent[0].body).toBe('Won: Deal closed'); }); + // ── #13881 — per-recipient locale, the same chain the email channel + // resolves (`recipient-locale.ts`): the recipient's own `sys_user.locale`, + // else the deployment default, read off the SAME row as the number. + describe('per-recipient locale (#13881)', () => { + const rows = [ + { topic: 'deal.won', channel: 'sms', locale: 'en', is_active: true, subject: 'Won', body: 'Won: {{ payload.title }}', format: 'text' }, + { topic: 'deal.won', channel: 'sms', locale: 'zh', is_active: true, subject: '成交', body: '成交: {{ payload.title }}', format: 'text' }, + { topic: 'deal.won', channel: 'sms', locale: 'ja-JP', is_active: true, subject: '成約', body: '成約: {{ payload.title }}', format: 'text' }, + ]; + + it("rung 1: the recipient's own sys_user.locale picks the row (zh-CN walks the store ladder to `zh`)", async () => { + const sms = fakeSms(); + const data = fakeData({ users: { user_1: { phone_number: '+8613800000000', locale: 'zh-CN' } }, templates: rows }); + const ch = createSmsChannel({ getSms: () => sms.service, getData: () => data, store: new NotificationTemplateStore({ getData: () => data }), getDefaultTemplateLocale: () => 'ja-JP' }); + await ch.send(silentCtx(), delivery()); + expect(sms.sent[0].body).toBe('成交: Deal closed'); + // One row read, both columns — no second read point. + const userReads = data.findOnes.filter((q: { object: string }) => q.object === 'sys_user'); + expect(userReads).toHaveLength(1); + expect(userReads[0].query).toEqual({ where: { id: 'user_1' }, fields: ['phone_number', 'locale'] }); + }); + + it('rung 2: a recipient without one falls to the deployment default', async () => { + const sms = fakeSms(); + const data = fakeData({ users: { user_1: { phone_number: '+8613800000000' } }, templates: rows }); + const ch = createSmsChannel({ getSms: () => sms.service, getData: () => data, store: new NotificationTemplateStore({ getData: () => data }), getDefaultTemplateLocale: () => 'ja-JP' }); + await ch.send(silentCtx(), delivery()); + expect(sms.sent[0].body).toBe('成約: Deal closed'); + }); + + it('dead-letter pin: the literal "undefined" falls to the deployment default; nothing named lands on the `en` floor', async () => { + const sms = fakeSms(); + const data = fakeData({ users: { user_1: { phone_number: '+8613800000000', locale: 'undefined' } }, templates: rows }); + await createSmsChannel({ getSms: () => sms.service, getData: () => data, store: new NotificationTemplateStore({ getData: () => data }), getDefaultTemplateLocale: () => 'ja-JP' }).send(silentCtx(), delivery()); + expect(sms.sent[0].body).toBe('成約: Deal closed'); + await channel(() => sms.service, data).send(silentCtx(), delivery()); + expect(sms.sent[1].body).toBe('Won: Deal closed'); + }); + + it('a producer-set payload.locale is NOT consulted', async () => { + const sms = fakeSms(); + const data = fakeData({ users: { user_1: { phone_number: '+8613800000000', locale: 'zh-CN' } }, templates: rows }); + await channel(() => sms.service, data).send(silentCtx(), delivery({ payload: { title: 'Deal closed', body: 'Acme signed', locale: 'ja-JP' } })); + expect(sms.sent[0].body).toBe('成交: Deal closed'); + }); + + it('a failing locale read costs the language, never the delivery (ruling item 3)', async () => { + const sms = fakeSms(); + const base = fakeData({ users: { user_1: { phone_number: '+8613800000000', locale: 'zh-CN' } }, templates: rows }); + const data = { + ...base, + async findOne(object: string, query: any) { + if (object === 'sys_user' && (query?.fields ?? []).includes('locale')) throw new Error("Unknown field 'locale'"); + return base.findOne(object, query); + }, + }; + const r = await createSmsChannel({ getSms: () => sms.service, getData: () => data, store: new NotificationTemplateStore({ getData: () => data }), getDefaultTemplateLocale: () => 'ja-JP' }).send(silentCtx(), delivery()); + expect(r.ok).toBe(true); + expect(sms.sent[0].to).toBe('+8613800000000'); + expect(sms.sent[0].body).toBe('成約: Deal closed'); + }); + }); + it('accepts a phone-shaped recipient verbatim (no user lookup)', async () => { const sms = fakeSms(); const ch = channel(() => sms.service, fakeData({ users: {} })); diff --git a/packages/services/service-messaging/src/sms-channel.ts b/packages/services/service-messaging/src/sms-channel.ts index 0f818cf867..be2c32c167 100644 --- a/packages/services/service-messaging/src/sms-channel.ts +++ b/packages/services/service-messaging/src/sms-channel.ts @@ -13,7 +13,7 @@ import { renderNotification, DEFAULT_LOCALE, } from './template-renderer.js'; -import { USER_OBJECT } from './email-channel.js'; +import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js'; /** * Structural view of the SMS service (`@objectstack/service-sms`'s @@ -40,8 +40,15 @@ export interface SmsChannelOptions { store: NotificationTemplateStore; /** User identity object override (default `sys_user`). */ userObject?: string; - /** Locale used when the delivery carries none (default {@link DEFAULT_LOCALE}). */ + /** Locale used when neither the recipient nor the deployment names one (default {@link DEFAULT_LOCALE}). */ defaultLocale?: string; + /** + * The DEPLOYMENT DEFAULT locale (`II18nService.getDefaultLocale()`), + * probed lazily — the second rung under the recipient's own + * `sys_user.locale` (#13881), composed in `recipient-locale.ts` exactly as + * the email channel composes it. + */ + getDefaultTemplateLocale?(): string | undefined; } // Same shape rule as plugin-auth's `normalizePhoneNumber` (kept local; the @@ -86,22 +93,53 @@ export function createSmsChannel(opts: SmsChannelOptions): MessagingChannel { const userObject = opts.userObject ?? USER_OBJECT; const defaultLocale = opts.defaultLocale ?? DEFAULT_LOCALE; - async function resolvePhone( + const deploymentLocale = (): string | undefined => opts.getDefaultTemplateLocale?.(); + + /** + * The recipient, resolved ONCE per delivery: the number to text and the + * locale to render in (#13881 — one `sys_user` read yields both). + */ + async function resolveRecipient( ctx: MessagingChannelContext, data: IDataEngine | undefined, recipient: string, - ): Promise { + ): Promise<{ phone: string; locale: string | undefined } | undefined> { const literal = PHONE_SHAPE(recipient); - if (literal) return literal; // already a phone number + if (literal) { + // A literal number has no `sys_user` row to read a locale from. + return { phone: literal, locale: resolveRecipientLocale(undefined, deploymentLocale) }; + } if (!data) return undefined; + let user: Record | null | undefined; + // Set only when the row was read WITH the locale projection — after a + // retry the column was never asked for, so rung 2 applies. + let localeRead = false; try { - const user = await data.findOne(userObject, { where: { id: recipient }, fields: ['phone_number'] }); - const phone = user?.phone_number; - return typeof phone === 'string' ? PHONE_SHAPE(phone) : undefined; + user = await data.findOne(userObject, { + where: { id: recipient }, + fields: ['phone_number', RECIPIENT_LOCALE_FIELD], + }); + localeRead = true; } catch (err) { - ctx.logger.warn(`[sms] phone lookup for '${recipient}' failed (${(err as Error).message})`); - return undefined; + // Ruling item 3 (#13881): the locale read must never cost the + // delivery — retry the number alone, fall to the deployment default. + ctx.logger.warn( + `[sms] recipient lookup for '${recipient}' with '${RECIPIENT_LOCALE_FIELD}' failed (${(err as Error).message}); retrying phone-only`, + ); + try { + user = await data.findOne(userObject, { where: { id: recipient }, fields: ['phone_number'] }); + } catch (retryErr) { + ctx.logger.warn(`[sms] phone lookup for '${recipient}' failed (${(retryErr as Error).message})`); + return undefined; + } } + const raw = user?.phone_number; + const phone = typeof raw === 'string' ? PHONE_SHAPE(raw) : undefined; + if (!phone) return undefined; + return { + phone, + locale: resolveRecipientLocale(localeRead ? user?.[RECIPIENT_LOCALE_FIELD] : undefined, deploymentLocale), + }; } return { @@ -115,13 +153,18 @@ export function createSmsChannel(opts: SmsChannelOptions): MessagingChannel { } const n = delivery.notification; - const phone = await resolvePhone(ctx, opts.getData(), delivery.recipient); - if (!phone) { + const resolved = await resolveRecipient(ctx, opts.getData(), delivery.recipient); + if (!resolved) { return { ok: false, error: `no phone number for recipient '${delivery.recipient}'` }; } + const { phone } = resolved; const payload = (n.payload ?? {}) as Record; - const locale = typeof payload.locale === 'string' ? payload.locale : defaultLocale; + // Per-recipient locale (#13881): the recipient's own + // `sys_user.locale`, else the deployment default, else this + // channel's static default; the store walks its own ladder + // underneath. `payload.locale` is no longer consulted. + const locale = resolved.locale ?? defaultLocale; const template = await opts.store.load(n.topic ?? '', 'sms', locale); const rendered = renderNotification(template, { topic: n.topic ?? '', diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index 60af781102..931dc217dd 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -238,17 +238,17 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { // path; the two paths are mutually exclusive — loud refusal over silent // precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts). // - // The `locale` half of that pair is pinned below to the DEPLOYMENT DEFAULT, - // not to a per-recipient value. These strings previously said "recipient - // locale" unqualified, which reads as "each recipient's own language selects - // the row" — the delivery path does not do that and deliberately does not - // (maintainer ruling 2026-08-13: no per-user locale until measured pull; - // `sys_user` carries no locale column, and `payload.locale` is interpolated - // once before fan-out, so it is one value for the whole notification). The - // assertions therefore pin the qualification itself: a future edit that - // drops it back to a bare "recipient locale" turns these RED, because the - // wording an author reads is the whole contract here — declared must equal - // enforced. + // The `locale` half of that pair is pinned below to the PER-RECIPIENT chain + // the delivery path enforces since #13881 (maintainer ruling 2026-09-01): + // the recipient's own `sys_user.locale`, else the deployment default. + // Until that ruling these strings pinned the OPPOSITE — one value for the + // whole notification (`payload.locale`, interpolated once before fan-out, + // else the deployment default), because the 2026-08-13 ruling had deferred + // a per-user locale until measured pull. The assertions pin the substance + // of the chain, both rungs named, and REFUSE the retired single-value + // wording: a future edit that says "one value for the whole notification" + // or "not one per recipient" again turns these RED, because the wording an + // author reads is the whole contract here — declared must equal enforced. describe('template reference (#9205)', () => { /** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */ function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> { @@ -287,12 +287,14 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { expect(msg).toContain('`template`'); expect(msg).toContain('`title`'); // The localizable path is identified by what it actually resolves — - // `(name, locale)` with the locale qualified — never a bare - // "recipient locale", which promises per-recipient selection. + // `(name, locale)` with both rungs of the per-recipient chain named + // (#13881): the recipient's own column, then the deployment default. expect(msg).toMatch(/\(name, locale\)/); + expect(msg).toContain('`sys_user.locale`'); expect(msg).toMatch(/deployment default/); - expect(msg).toMatch(/not per recipient/); - expect(msg).not.toMatch(/recipient locale/); + expect(msg).toMatch(/per recipient/); + expect(msg).not.toMatch(/not per recipient/); + expect(msg).not.toMatch(/one locale per notification/); expect(msg).toMatch(/delete `title`\/`message`/); expect(msg).toMatch(/silently ignore/); } @@ -321,18 +323,25 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { expect(templateDoc).toMatch(/\(name, locale\)/); expect(templateDoc).toMatch(/delivery time/); expect(templateDoc).toContain('sys_email_template'); - // …with the locale named as what it IS — the deployment default, one - // value per notification. A bare "recipient locale" here is the defect - // this pin exists to catch: it licenses "convert the nodes and non-English - // users get non-English mail", which is false and is a net regression when - // acted on (TEMPLATE_* failures classify `permanent` and dead-letter). - expect(templateDoc).not.toMatch(/recipient locale/); + // …with the locale named as what it IS since #13881 — resolved per + // recipient, after fan-out, from the recipient's own `sys_user.locale` + // with the deployment default underneath. Both rungs are pinned by + // name, and the retired single-value wording is refused: "one value for + // the whole notification" would tell an author that converting the + // nodes does NOT localize per user, which is now false. + expect(templateDoc).toMatch(/per recipient/); + expect(templateDoc).toContain('`sys_user.locale`'); expect(templateDoc).toMatch(/deployment default/); expect(templateDoc).toContain('II18nService.getDefaultLocale()'); - expect(templateDoc).toMatch(/not one per recipient/); - // The deferral is dated, so the text carries its own provenance rather + expect(templateDoc).not.toMatch(/not one per recipient/); + expect(templateDoc).not.toMatch(/one value for the whole notification/i); + // The producer's pre-ruling knob is named as NOT consulted, so an author + // who still writes `payload.locale` learns from the contract that it is + // inert rather than from a recipient who got the wrong language. + expect(templateDoc).toMatch(/`payload\.locale` is not consulted/); + // The ruling is dated, so the text carries its own provenance rather // than reading as a permanent limitation of the design. - expect(templateDoc).toContain('2026-08-13'); + expect(templateDoc).toContain('2026-09-01'); // …and it is a RAW cross-reference, like topic/channels. expect(templateDoc).toMatch(/no `\{token\}` interpolation/i); diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index b44b47773e..bec202af12 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -135,21 +135,23 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { * delivery path resolves `(name, locale)` at delivery time via * `IEmailService.sendTemplate({ template, locale })`. * - * That `locale` is **ONE value for the whole notification, not one per - * recipient** — declared here exactly as the delivery path enforces it - * (`service-messaging/src/email-channel.ts`, which says the same thing at - * its own `getDefaultTemplateLocale`). It is `payload.locale` when the - * producer set one — interpolated ONCE, before fan-out, so every recipient - * of a node gets that single value — and otherwise the **deployment - * default**, `II18nService.getDefaultLocale()`, the same ruled source the - * auth emails use (#8195). The platform has no per-user locale to read: - * `sys_user` carries no locale column, and request-scoped locale - * (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at - * async delivery time. Per the maintainer ruling of **2026-08-13** a - * per-user locale is DEFERRED until measured pull; when it lands it layers - * in as an override at that same seam. So do not author on the belief that - * two recipients with different personal languages will receive different - * rows — today they receive the same one. + * That `locale` is resolved **per recipient, after fan-out** — declared + * here exactly as the delivery path enforces it + * (`service-messaging/src/recipient-locale.ts`, the one read point every + * channel resolves through). The chain is the recipient's own + * `sys_user.locale` → the **deployment default**, + * `II18nService.getDefaultLocale()` (the same ruled source the auth emails + * read as their deployment rung, #8195). A recipient whose column is + * absent, empty or malformed always falls back to the deployment default; + * no value ever dead-letters a delivery. Maintainer ruling **2026-09-01** + * (#13881), which lifted the 2026-08-13 deferral once hotcrm measured the + * pull (4 published languages × 16 notify nodes × 0 localizable). The + * pre-ruling single value — `payload.locale`, interpolated once before + * fan-out — is no longer consulted. Request-scoped locale + * (`Accept-Language` → `ExecutionContext.requestLocale`) still does not + * exist at async delivery time and is not part of this chain. So two + * recipients with different `sys_user.locale` values DO receive different + * rows of the same bundle. * Inline `title`/`message` are the NON-localizable path — raw strings sent * to every recipient verbatim. The two paths are mutually exclusive on one * node (see the `superRefine` below): runtime precedence would silently @@ -198,18 +200,19 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ * `IEmailService.sendTemplate({ template, locale })` picks that locale's row * with the documented en-US fallback ladder. * - * The `locale` is ONE value for the whole notification, NOT one per - * recipient: `payload.locale` when the producer set one (interpolated once, - * before fan-out), else the DEPLOYMENT DEFAULT — - * `II18nService.getDefaultLocale()`. There is no per-user locale to read - * (`sys_user` has no locale column); the 2026-08-13 ruling defers one until - * measured pull, and it layers in as an override when it lands. + * The `locale` is resolved PER RECIPIENT, after fan-out (maintainer ruling + * 2026-09-01, #13881): the recipient's own `sys_user.locale` when set, else + * the DEPLOYMENT DEFAULT — `II18nService.getDefaultLocale()`. A missing, + * empty or malformed recipient value always falls back; it never reaches + * the template lookup. A producer-set `payload.locale` is NOT consulted — + * it was the pre-ruling single value for the whole notification, and that + * shape is what the ruling retired. * * Read RAW like `topic`/`channels`: a static metadata cross-reference, never * interpolated. Mutually exclusive with inline `title`/`message`. */ template: z.string().optional() - .describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), + .describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), /** * Render context for the referenced template's `{{var}}` holes. Values are * interpolated per run (`{record.x}` resolves), so flow state can feed the @@ -273,7 +276,7 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ message: '`template` cannot be combined with inline `title`/`message` — pick ONE content path: ' + '`template` (localizable: resolves `(name, locale)` from sys_email_template at delivery, the locale being ' - + '`payload.locale` or the deployment default — one locale per notification, not per recipient) ' + + 'each recipient\'s own `sys_user.locale` or the deployment default — resolved per recipient, after fan-out) ' + 'or inline `title` + `message` (sent verbatim, not localizable). To localize, keep `template`, move ' + 'the text into the template bundle\'s rows, and delete `title`/`message`; runtime precedence would ' + 'silently ignore one of them.', @@ -294,8 +297,8 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ path: ['title'], message: 'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` ' - + 'reference resolving a sys_email_template bundle at delivery in the notification\'s locale ' - + '(`payload.locale` or the deployment default — one locale per notification, not per recipient). ' + + 'reference resolving a sys_email_template bundle at delivery in each recipient\'s locale ' + + '(the recipient\'s own `sys_user.locale` or the deployment default — resolved per recipient, after fan-out). ' + 'Neither was given, so there is nothing to deliver.', }); }