diff --git a/.changeset/notify-node-email-template-locale-bridge.md b/.changeset/notify-node-email-template-locale-bridge.md new file mode 100644 index 0000000000..d5c72cc3c3 --- /dev/null +++ b/.changeset/notify-node-email-template-locale-bridge.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": minor +"@objectstack/service-messaging": minor +--- + +feat(automation): flow `notify` nodes can reference an email template for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, resolved by `(name, recipient locale)` at delivery time (#9205) + +Ruled 「立项,走 emailTemplates 路线」: instead of widening the `flows` +translation surface (whose guidance excludes notification text, #7646), a +`notify` node now bridges to the existing localized email-template subsystem. + +- **Spec** — `NotifyConfigSchema` gains `template` (a `sys_email_template` + name, read raw like `topic`/`channels`) and `templateData` (render context + for the template's `{{var}}` holes; values interpolate `{token}` templates + per run) as the localizable alternative to inline `title`/`message`. Inline + strings stay fully valid and byte-identical for existing flows — they are + the non-localizable path, and the describes now say so. A node carrying BOTH + paths, or `templateData` without `template`, or NEITHER path, is refused + loudly with the fix in the message (the `objectNavTargetExclusivity` + posture: unrepresentable over silent precedence). +- **service-automation** — the notify executor forwards the template + reference and its interpolated render context in the emit payload (the + outbox snapshots it onto each delivery row), and no longer demands an + inline title when a template is referenced. +- **service-messaging** — the email channel routes a template-carrying + delivery through `IEmailService.sendTemplate({ template, locale, data })`, + resolving the recipient locale per delivery: `payload.locale` if the + producer set one, else the deployment default + (`II18nService.getDefaultLocale()`, the #8195 ruled source), else + `sendTemplate`'s documented `en-US` ladder. Template-resolution failures + (`TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`, and an + email service without `sendTemplate`) are graded `permanent` — dead + immediately with the code on the delivery row, instead of burning the retry + schedule on metadata that cannot fix itself. + +The inbox channel keeps its existing rendering (notification title/body, +falling back to the topic on the template path): it has no locale-capable +rendering seam to the email-template subsystem today, and that gap is +documented in the PR rather than papered over with a duplicated resolver. diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index 78458b7b73..d85bbb6ae2 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -96,8 +96,10 @@ const result = HttpConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run | -| **title** | `string` | ✅ | Notification title | -| **message** | `string` | optional | Notification body | +| **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, recipient locale)` at delivery time and renders subject/body per recipient. 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") | | **severity** | `Enum<'info' \| 'warning' \| 'critical'>` | optional | Severity forwarded to the messaging service | diff --git a/packages/services/service-automation/src/builtin/notify-node.test.ts b/packages/services/service-automation/src/builtin/notify-node.test.ts index 9b935c7ee3..c39de36fc8 100644 --- a/packages/services/service-automation/src/builtin/notify-node.test.ts +++ b/packages/services/service-automation/src/builtin/notify-node.test.ts @@ -210,6 +210,48 @@ describe('notify (baseline node)', () => { expect(result.error).toContain('title'); }); + // ── #9205 — the localizable content path: template references ──────── + it('emits the template reference + interpolated templateData instead of inline content', async () => { + engine.registerFlow('notify_flow', notifyFlow({ + topic: 'deal.won', + recipients: ['user_1'], + channels: ['inbox', 'email'], + template: 'crm.large_deal_won', + templateData: { dealName: '{dealName}', dealUrl: '/opps/{dealId}' }, + })); + + const result = await engine.execute('notify_flow', { + params: { dealName: 'Acme', dealId: '42' }, + } as any); + + expect(result.success).toBe(true); + expect(messaging.emitted).toHaveLength(1); + const payload = messaging.emitted[0].payload; + // The reference rides RAW (a static metadata cross-reference); its + // render context is interpolated per run — that pair is what the + // email channel resolves per recipient locale at delivery time. + expect(payload.template).toBe('crm.large_deal_won'); + expect(payload.templateData).toEqual({ dealName: 'Acme', dealUrl: '/opps/42' }); + // No inline content keys on this path: a channel without template + // support falls back to the topic, the honest degraded rendering — + // not an empty string masquerading as content. + expect(payload).not.toHaveProperty('title'); + expect(payload).not.toHaveProperty('body'); + }); + + it('refuses a node carrying BOTH template and inline title (the contract superRefine, at the parse seam)', async () => { + engine.registerFlow('notify_flow', notifyFlow({ + recipients: ['user_1'], + title: 'Deal won', + template: 'crm.large_deal_won', + })); + const result = await engine.execute('notify_flow'); + expect(result.success).toBe(false); + expect(result.error).toContain('`template`'); + expect(result.error).toContain('`title`'); + expect(messaging.emitted).toHaveLength(0); + }); + it('fails the step when no recipient is given', async () => { engine.registerFlow('notify_flow', notifyFlow({ title: 'Hi' })); const result = await engine.execute('notify_flow'); diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index c7d47a83f3..391d968bd3 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -168,8 +168,27 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) recipients: { description: 'Recipient user id(s) / audience selector(s)', }, - title: { type: 'string', description: 'Notification title' }, - message: { type: 'string', description: 'Notification body' }, + title: { + type: 'string', + description: 'Notification title, sent verbatim (not localizable — use template for per-locale content). Either this or template is required; mutually exclusive with template.', + }, + message: { + type: 'string', + description: 'Notification body, sent verbatim (not localizable). Only valid with inline title, never with template.', + }, + // ── Localizable content path (#9205) ───────────────────── + // Mirrors `NotifyConfigSchema.template`/`templateData`; the + // mutual exclusion with title/message lives in the Zod + // contract's superRefine (executed at parse time), matching + // how requiredness is owned there rather than by the form. + template: { + type: 'string', + description: 'Email template name (sys_email_template.name) — resolved by (name, recipient locale) at delivery time and rendered per recipient. Mutually exclusive with inline title/message.', + }, + templateData: { + type: 'object', + description: 'Render context for the referenced template\'s {{var}} placeholders; values interpolate {token} templates per run. Only valid together with template.', + }, channels: { type: 'array', items: { type: 'string' }, description: 'Channels to fan out to (default: inbox)', @@ -228,6 +247,14 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // useless `[object Object]` (#3450). Serialize it readably instead. const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context)); const body = stringifyForTemplate(interpolate(cfg.message ?? '', variables, context)); + // #9205 — the localizable content path. `template` is read RAW (a + // static metadata cross-reference, like `topic`/`channels`); + // `templateData` VALUES interpolate per run, so flow state can feed + // the template's `{{var}}` holes at delivery time. + const template = toStr(cfg.template); + const templateData = cfg.templateData + ? (interpolate(cfg.templateData, variables, context) as Record) + : undefined; const channels = toStringList(cfg.channels); const topic = cfg.topic ? String(cfg.topic) : undefined; const severity = cfg.severity ? String(cfg.severity) : undefined; @@ -246,7 +273,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const source = resolveSource(cfg, variables, context); const actorId = toStr(interpolate(cfg.actorId, variables, context)); - if (!title) return { success: false, error: 'notify: title is required' }; + // With a `template` reference the content lives in the template + // bundle, resolved per recipient locale at delivery — 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' }; if (recipients.length === 0) { // Name the templates that came up empty (framework#3582). The // dominant cause is a cross-object hop — `{record.owner.manager}` @@ -269,7 +300,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const messaging = getMessaging(); if (!messaging) { ctx.logger.warn( - `[notify] no messaging service registered; notification "${title}" not delivered`, + `[notify] no messaging service registered; notification "${title || `template ${template}`}" not delivered`, ); return { success: true, @@ -289,7 +320,21 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const result = await messaging.emit({ topic: topic ?? 'notify', audience: recipients, - payload: { ...(payload ?? {}), title, body, url: actionUrl }, + // Content rides in the payload per path (#9205): the inline + // strings, or the template reference + its render context — + // which the outbox snapshots onto each delivery row, so the + // per-recipient-locale resolution happens at delivery time + // in the channel (email-channel.ts reads payload.template). + // 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 ''. + payload: { + ...(payload ?? {}), + ...(template + ? { template, ...(templateData !== undefined ? { templateData } : {}) } + : { title, body }), + url: actionUrl, + }, severity, source, actorId, diff --git a/packages/services/service-messaging/src/email-channel.test.ts b/packages/services/service-messaging/src/email-channel.test.ts index 91fc96fdd2..5b13814c57 100644 --- a/packages/services/service-messaging/src/email-channel.test.ts +++ b/packages/services/service-messaging/src/email-channel.test.ts @@ -132,4 +132,149 @@ describe('email channel', () => { expect(r.error).toContain('smtp down'); expect(ch.classifyError?.(new Error('x'))).toBe('retryable'); }); + + // ── #9205 — notify `template` references route through sendTemplate ───── + describe('notify template path (#9205)', () => { + /** Email service double whose sendTemplate records its calls. */ + function fakeTemplateEmail(result: any = { id: 'email_row_9', status: 'sent' }) { + const sent: any[] = []; + const templated: any[] = []; + return { + sent, + templated, + service: { + async send(input: any) { sent.push(input); return { id: 'email_row_1' }; }, + async sendTemplate(input: any) { + templated.push(input); + if (result instanceof Error) throw result; + return result; + }, + }, + }; + } + + function templateDelivery(payload: Record, recipient = 'user_1') { + return delivery({ + // The notify executor sets no inline title/body on this path; + // the messaging service defaults payload.title/notification + // title from the topic. Mirror that shape. + title: 'deal.won', + body: '', + payload, + }, recipient); + } + + it('resolves the recipient address and hands template + data + locale to sendTemplate — never send()', async () => { + const email = fakeTemplateEmail(); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + getDefaultTemplateLocale: () => 'ja-JP', + }); + const r = await ch.send(silentCtx(), templateDelivery({ + template: 'crm.large_deal_won', + templateData: { dealName: 'Acme' }, + })); + expect(r.ok).toBe(true); + expect(r.externalId).toBe('email_row_9'); + expect(email.templated).toEqual([{ + template: 'crm.large_deal_won', + to: 'ada@example.com', + data: { dealName: 'Acme' }, + locale: 'ja-JP', + }]); + // The preservation half's negative face: the fallback/send path and + // the sys_notification_template renderer were never consulted. + expect(email.sent).toHaveLength(0); + }); + + it('a producer-supplied payload.locale wins over the deployment default', async () => { + const email = fakeTemplateEmail(); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + getDefaultTemplateLocale: () => 'ja-JP', + }); + await ch.send(silentCtx(), templateDelivery({ template: 'crm.large_deal_won', locale: 'es-ES' })); + expect(email.templated[0].locale).toBe('es-ES'); + }); + + it('with no deployment default, no locale is passed — sendTemplate resolves its documented en-US default', async () => { + const email = fakeTemplateEmail(); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + await ch.send(silentCtx(), templateDelivery({ template: 'crm.large_deal_won' })); + expect(email.templated[0]).not.toHaveProperty('locale'); + }); + + it('fails LOUDLY when the registered email service has no sendTemplate — no silent downgrade to unlocalized content', async () => { + const email = fakeEmail(); // send() only, like an older implementation + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), templateDelivery({ template: 'crm.large_deal_won' })); + expect(r.ok).toBe(false); + expect(r.error).toContain('TEMPLATE_UNSUPPORTED'); + expect(r.error).toContain('crm.large_deal_won'); + expect(email.sent).toHaveLength(0); + // …and the failure is graded permanent: re-trying cannot grow the capability. + expect(ch.classifyError?.(r.error)).toBe('permanent'); + }); + + it("surfaces sendTemplate's own failure vocabulary and grades it permanent (metadata, not transport)", async () => { + const email = fakeTemplateEmail(new Error('TEMPLATE_NOT_FOUND: crm.large_deal_won (locale=ja-JP)')); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), templateDelivery({ template: 'crm.large_deal_won' })); + expect(r.ok).toBe(false); + // The code stays at the FRONT of the delivery row's error — it is + // what classifyError greps and what an operator searches for. + expect(r.error).toMatch(/^TEMPLATE_NOT_FOUND/); + expect(ch.classifyError?.(r.error)).toBe('permanent'); + }); + + it("a sendTemplate result of status:'failed' (transport failure) reports ok:false and stays retryable", async () => { + const email = fakeTemplateEmail({ id: 'row', status: 'failed', error: 'smtp down' }); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), templateDelivery({ template: 'crm.large_deal_won' })); + expect(r.ok).toBe(false); + expect(r.error).toContain('smtp down'); + expect(ch.classifyError?.(r.error)).toBe('retryable'); + }); + + it('a payload WITHOUT a template reference keeps the pre-#9205 path byte-identically', async () => { + const email = fakeTemplateEmail(); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + getDefaultTemplateLocale: () => 'ja-JP', + }); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(true); + expect(email.templated).toHaveLength(0); + expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Deal closed', text: 'Acme signed' }); + }); + }); }); diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts index 22ebebf232..650fbfc608 100644 --- a/packages/services/service-messaging/src/email-channel.ts +++ b/packages/services/service-messaging/src/email-channel.ts @@ -31,6 +31,21 @@ export interface EmailSenderSurface { html?: string; text?: string; }): Promise<{ id?: string } | unknown>; + /** + * Structural mirror of `IEmailService.sendTemplate` (#9205) — resolves a + * `sys_email_template` bundle by `(template, locale)` with the documented + * en-US fallback ladder, renders it against `data`, and delivers. OPTIONAL + * because this is a structural view of a service resolved at runtime: an + * older or third-party email implementation may not provide it, and a + * delivery that needs it then fails LOUDLY on the delivery row rather than + * degrading to unlocalized content (declared = enforced, ADR-0049). + */ + sendTemplate?(input: { + template: string; + to: string | string[]; + data?: Record; + locale?: string; + }): Promise<{ id?: string; status?: string; error?: string } | unknown>; } export interface EmailChannelOptions { @@ -44,6 +59,20 @@ export interface EmailChannelOptions { userObject?: string; /** 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 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. + */ + getDefaultTemplateLocale?(): string | undefined; } const EMAIL_SHAPE = (s: string): boolean => { @@ -66,6 +95,13 @@ const EMAIL_SHAPE = (s: string): boolean => { * subject/body to the `email` service. Retry/backoff/dead-letter come for free * from the P1 outbox dispatcher. * + * 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}. + * * Degrades like the inbox channel: no email service ⇒ logged no-op success * (capability not installed); a recipient with no resolvable address ⇒ a * reported failure (so the delivery row shows why). @@ -108,6 +144,54 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel } const payload = (n.payload ?? {}) as Record; + + // ── The localizable path (#9205): a `notify` node's `template` + // reference, carried in the payload (snapshotted onto the delivery + // row by the outbox, so it survives the durable path). Resolution + // 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. + const templateName = + typeof payload.template === 'string' && payload.template.trim() + ? payload.template.trim() + : undefined; + if (templateName) { + if (typeof email.sendTemplate !== 'function') { + // Declared ≠ deliverable — fail loudly on the delivery row + // rather than silently downgrading to unlocalized content. + return { + ok: false, + 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 data = (payload.templateData ?? undefined) as Record | undefined; + try { + const result = (await email.sendTemplate({ + template: templateName, + to: address, + ...(data !== undefined ? { data } : {}), + ...(templateLocale ? { locale: templateLocale } : {}), + })) as { id?: unknown; status?: unknown; error?: unknown } | undefined; + // `IEmailService.send` reports transport failure as + // `status: 'failed'` rather than throwing — surface it. + if (result && result.status === 'failed') { + return { ok: false, error: String(result.error ?? 'email send failed') }; + } + const id = result?.id; + return { ok: true, externalId: id != null ? String(id) : undefined }; + } catch (err) { + // sendTemplate's own failure vocabulary (TEMPLATE_NOT_FOUND / + // TEMPLATE_INACTIVE / MISSING_VARIABLES) arrives as a thrown + // Error — keep the code at the front of the row's error so + // classifyError() below can grade it permanent. + return { ok: false, error: (err as Error)?.message ?? String(err) }; + } + } + const locale = typeof payload.locale === 'string' ? payload.locale : defaultLocale; const template = await opts.store.load(n.topic ?? '', 'email', locale); const rendered = renderNotification(template, { @@ -131,7 +215,18 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel } }, - classifyError(_err: unknown): ErrorClass { + classifyError(err: unknown): ErrorClass { + // #9205 — a template-resolution failure is wrong METADATA, not a + // transport hiccup: re-trying the identical delivery can never + // succeed until someone edits the template/node, so grade it + // permanent (→ dead immediately, with the code on the delivery + // row) instead of burning the whole retry schedule first. These + // are `IEmailService.sendTemplate`'s own error codes plus this + // channel's missing-capability refusal above. + const msg = typeof err === 'string' ? err : String((err as Error)?.message ?? err ?? ''); + if (/\b(TEMPLATE_NOT_FOUND|TEMPLATE_INACTIVE|MISSING_VARIABLES|TEMPLATE_UNSUPPORTED)\b/.test(msg)) { + return 'permanent'; + } return 'retryable'; }, }; diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index d610042d2b..77b605337d 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -219,10 +219,29 @@ 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 + // `sendTemplate`'s documented en-US default. + const getDefaultTemplateLocale = (): string | undefined => { + try { + const i18n = ctx.getService<{ getDefaultLocale?: () => string }>('i18n'); + const locale = typeof i18n?.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined; + return typeof locale === 'string' && locale.trim() ? locale : undefined; + } catch { + return undefined; + } + }; ctx.hook('kernel:ready', async () => { if (getEmail()) { - service.registerChannel(createEmailChannel({ getEmail, getData, store: templateStore })); - ctx.logger.info('[messaging] email channel registered (renders sys_notification_template)'); + 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 per recipient locale)'); } }); diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 8b1e9e3eec..0102199ce1 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -255,6 +255,8 @@ "automation/NotifyConfig:severity", "automation/NotifyConfig:sourceId", "automation/NotifyConfig:sourceObject", + "automation/NotifyConfig:template", + "automation/NotifyConfig:templateData", "automation/NotifyConfig:title", "automation/NotifyConfig:topic", "automation/ParallelBranch:edges", diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index 46db560944..1e9f2b02b3 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -27,7 +27,12 @@ function unknownKeyMessage(schema: { safeParse(v: unknown): { success: boolean; } describe('NotifyConfigSchema — strict as of #4001 批 9', () => { - it('accepts every declared key', () => { + // Since #9205 the declared keys split into TWO content paths that cannot + // coexist on one node (see the mutual-exclusion pins below), so "accepts + // every declared key" is two configs: the inline path carries every key + // except `template`/`templateData`; the template path carries those two in + // place of `title`/`message`. + it('accepts every declared key (inline content path — unchanged by #9205)', () => { const full = { recipients: ['{record.assignee}'], title: 'New task', @@ -44,6 +49,23 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { expect(NotifyConfigSchema.parse(full)).toEqual(full); }); + it('accepts every declared key (template content path — #9205)', () => { + const full = { + recipients: ['{record.assignee}'], + template: 'crm.large_deal_won', + templateData: { dealName: '{record.name}', amount: '{record.amount}' }, + channels: ['inbox', 'email'], + topic: 'notify', + severity: 'info', + sourceObject: 'showcase_task', + sourceId: '{record.id}', + actorId: '{trigger.userId}', + actionUrl: '/task/{record.id}', + payload: { taskName: '{record.name}' }, + }; + expect(NotifyConfigSchema.parse(full)).toEqual(full); + }); + it('rejects an undeclared key instead of dropping it', () => { // The pre-批-9 behaviour, stated as the thing that is no longer true: // this parsed clean and the notification went out without a click target. @@ -206,6 +228,97 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { expect(doc, 'the vocabulary belongs in the enum, not smuggled back into prose').not.toMatch(/\|/); }); }); + + // ── #9205 — the localizable content path: `template` + `templateData` ── + // + // Ruled 「立项,走 emailTemplates 路线」: a notify node references a + // `sys_email_template` bundle by name and the delivery path resolves + // `(name, recipient locale)` at delivery time. Inline `title`/`message` + // stay fully valid (the acceptance faces above) as the non-localizable + // path; the two paths are mutually exclusive — loud refusal over silent + // precedence, following `objectNavTargetExclusivity` (ui/app.zod.ts). + describe('template reference (#9205)', () => { + /** Custom (superRefine) issues at exactly `path`, or `[]` when accepted. */ + function customIssuesAt(value: unknown, path: string): ReadonlyArray<{ code: string; message: string }> { + const result = NotifyConfigSchema.safeParse(value); + if (result.success) return []; + return result.error.issues.filter( + (i) => i.code === 'custom' && i.path.length === 1 && i.path[0] === path, + ); + } + + it('accepts a template-only node (no inline title) — RED on origin/main pre-#9205, where `template` was an unrecognized key', () => { + expect(NotifyConfigSchema.safeParse({ + recipients: ['u1'], + template: 'crm.large_deal_won', + templateData: { dealName: '{record.name}' }, + }).success).toBe(true); + }); + + it('accepts a template reference without templateData (a template may need no variables)', () => { + expect(NotifyConfigSchema.safeParse({ + recipients: ['u1'], + template: 'crm.weekly_digest', + }).success).toBe(true); + }); + + it('refuses template + inline title/message, naming both paths and which to keep', () => { + for (const inline of [{ title: 'Deal won' }, { message: 'Body' }, { title: 'Deal won', message: 'Body' }]) { + const issues = customIssuesAt({ recipients: ['u1'], template: 'crm.large_deal_won', ...inline }, 'template'); + // `code` + `path`, never a bare `success === false` (the #7086 lesson): + // a strictObject refuses for several reasons, and this pin must stay + // apart from an unknown-key refusal. + expect(issues, `combo ${Object.keys(inline).join('+')} must be refused at ['template']`).toHaveLength(1); + const msg = issues[0]!.message; + // The prescription is behaviour: both keys named, the localizable path + // identified, and the fix stated. + expect(msg).toContain('`template`'); + expect(msg).toContain('`title`'); + expect(msg).toMatch(/recipient locale/); + expect(msg).toMatch(/delete `title`\/`message`/); + expect(msg).toMatch(/silently ignore/); + } + }); + + it('refuses templateData without template — nothing would ever read it', () => { + const issues = customIssuesAt({ recipients: ['u1'], title: 'hi', templateData: { a: 1 } }, 'templateData'); + expect(issues).toHaveLength(1); + expect(issues[0]!.message).toContain('`template`'); + }); + + it('refuses a node with NEITHER inline title NOR template (at-least-one; a bare missing title refused pre-#9205 too, as invalid_type)', () => { + const issues = customIssuesAt({ recipients: ['u1'] }, 'title'); + expect(issues).toHaveLength(1); + expect(issues[0]!.message).toContain('`template`'); + expect(issues[0]!.message).toContain('`title`'); + }); + + it('states the localization contract in the describes, plainly', () => { + const shape = (NotifyConfigSchema as unknown as { shape: Record }).shape; + + // Non-empty arms first, so the pattern arms cannot pass vacuously (#6918). + const templateDoc = shape.template!.description ?? ''; + expect(templateDoc.length, 'template .describe() must not be empty').toBeGreaterThan(0); + // The contract: resolves by (name, recipient locale) at delivery time… + expect(templateDoc).toMatch(/recipient locale/); + expect(templateDoc).toMatch(/delivery time/); + expect(templateDoc).toContain('sys_email_template'); + // …and it is a RAW cross-reference, like topic/channels. + expect(templateDoc).toMatch(/no `\{token\}` interpolation/i); + + // Inline strings are the non-localizable path, said out loud on both. + for (const key of ['title', 'message'] as const) { + const doc = shape[key]!.description ?? ''; + expect(doc.length, `${key} .describe() must not be empty`).toBeGreaterThan(0); + expect(doc).toMatch(/not localizable/i); + } + + // templateData names its coupling to template. + const dataDoc = shape.templateData!.description ?? ''; + expect(dataDoc.length, 'templateData .describe() must not be empty').toBeGreaterThan(0); + expect(dataDoc).toMatch(/together with `template`/); + }); + }); }); describe('HttpConfigSchema — strict as of #4001 批 9', () => { diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index d37a00c463..a64c6e2e57 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -123,18 +123,33 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { * * Executor semantics worth knowing beyond the key set: * - * - `recipients` and `title` are **required at execute time** (the step fails - * without them). The descriptor's form deliberately publishes no `required` - * array — see the comment on the `configSchema` literal — so requiredness - * lives here and in the execute-time guard, not in the form. + * - `recipients` is **required at execute time** (the step fails without it), + * and the node needs ONE content source: inline `title` (+ optional + * `message`), or a `template` reference (#9205). The descriptor's form + * deliberately publishes no `required` array — see the comment on the + * `configSchema` literal — so requiredness lives here and in the + * execute-time guard, not in the form. + * - **Localization contract (#9205, ruled 「走 emailTemplates 路线」):** + * `template` names a `sys_email_template` bundle + * (`EmailTemplateDefinitionSchema`, `system/email-template.zod.ts`), and the + * delivery path resolves `(name, recipient locale)` per recipient at + * delivery time via `IEmailService.sendTemplate({ template, locale })`. + * 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 + * ignore one of them, so the ambiguous combination is unrepresentable + * instead — the same posture as `objectNavTargetExclusivity` + * (`ui/app.zod.ts`). * - `recipients`, `title`, `message`, `actionUrl` and `payload` pass through - * `interpolate()`, so `{record.x}` templates are legal in them. `channels`, - * `topic` and `severity` are read RAW — a `{token}` in those three is - * forwarded verbatim, never resolved (channel ids are static routing and - * `severity` is a closed vocabulary, not per-record data). Re-measured - * against `notify-node.ts` for #7086: the previous wording ("every - * string-ish value except `channels`") was stale for `topic` and `severity`, - * and it is what makes closing the `severity` gate below safe. + * `interpolate()`, so `{record.x}` templates are legal in them. So do + * `templateData` VALUES (they are per-run render inputs). `channels`, + * `topic`, `severity` and `template` are read RAW — a `{token}` in those is + * forwarded verbatim, never resolved (channel ids are static routing, + * `severity` is a closed vocabulary, and `template` is a static metadata + * cross-reference, not per-record data). Re-measured against + * `notify-node.ts` for #7086: the previous wording ("every string-ish value + * except `channels`") was stale for `topic` and `severity`, and it is what + * makes closing the `severity` gate below safe. * - `sourceObject`/`sourceId` only take effect as a PAIR — a half-specified * click-through target is dropped so the inbox never renders a dead link. * The schema keeps both optional rather than refining, because the executor @@ -152,10 +167,32 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ /** Who gets the notification — user id(s) / audience selector(s). */ recipients: z.union([z.string(), z.array(z.string())]) .describe('Recipient user id(s) / audience selector(s); `{token}` templates resolve per run'), - /** Notification title (execute-time required). */ - title: z.string().describe('Notification title'), - /** Notification body. */ - message: z.string().optional().describe('Notification body'), + /** + * Inline notification title — the NON-localizable content path. Required + * unless `template` is set (the superRefine below owes one of the two). + */ + title: z.string().optional() + .describe('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.'), + /** Notification body (inline path only). */ + message: z.string().optional() + .describe('Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.'), + /** + * The localizable content path (#9205): name of a `sys_email_template` + * bundle. Resolved by `(name, recipient locale)` AT DELIVERY TIME — + * `IEmailService.sendTemplate({ template, locale })` picks the recipient + * locale's row with the documented en-US fallback ladder. 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, recipient locale)` at delivery time and renders subject/body per recipient. 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 + * template. Only meaningful with `template` — refused without it. + */ + templateData: z.record(z.string(), z.unknown()).optional() + .describe('Render context for the referenced template\'s `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`.'), /** Channels to fan out to (default: inbox). Read raw — no template interpolation. */ channels: z.union([z.string(), z.array(z.string())]).optional() .describe('Channels to fan out to (default: inbox)'), @@ -194,6 +231,48 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ /** Extra template inputs merged into the notification payload. */ payload: z.record(z.string(), z.unknown()).optional() .describe('Extra template inputs merged into the notification payload'), +}).superRefine((cfg, ctx) => { + // #9205 — correct-by-construction (the `objectNavTargetExclusivity` + // posture, ui/app.zod.ts): `template` combined with inline `title`/`message` + // is an authoring ambiguity — the delivery path would have to silently pick + // which content wins, per channel, and whichever loses would look exactly + // like a delivered notification. Reject at the gate with the fix in the + // message instead of resolving by precedence. + // + // These checks run only on a structurally valid config (zod skips object + // -level refinements once a property has failed — probed on zod 4.4.3 for + // ui/action.zod.ts), which is fine: each names keys, not values. + if (cfg.template !== undefined && (cfg.title !== undefined || cfg.message !== undefined)) { + ctx.addIssue({ + code: 'custom', + path: ['template'], + message: + '`template` cannot be combined with inline `title`/`message` — pick ONE content path: ' + + '`template` (localizable: resolves `(name, recipient locale)` from sys_email_template at delivery) ' + + '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.', + }); + } + if (cfg.templateData !== undefined && cfg.template === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['templateData'], + message: + '`templateData` is the render context for a `template` reference, and this node names no `template` — ' + + 'nothing would ever read it. Add the `template` it feeds, or delete `templateData`.', + }); + } + if (cfg.template === undefined && cfg.title === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['title'], + message: + 'A notify node needs one content source: inline `title` (+ optional `message`), or a `template` ' + + 'reference resolving a sys_email_template bundle per recipient locale at delivery. Neither was given, ' + + 'so there is nothing to deliver.', + }); + } })); export type NotifyConfig = z.input;