Skip to content

Commit d31785f

Browse files
os-zhuangclaude
andauthored
feat(automation): notify nodes reference email templates for localized delivery — resolve (name, recipient locale) at delivery time (#9224)
* feat(spec,automation,messaging): notify nodes reference email templates for localized delivery (#9205) * chore(spec): regenerate docs references; add changeset for notify template bridge (#9205) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ea1372 commit d31785f

10 files changed

Lines changed: 608 additions & 26 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/service-messaging": minor
5+
---
6+
7+
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)
8+
9+
Ruled 「立项,走 emailTemplates 路线」: instead of widening the `flows`
10+
translation surface (whose guidance excludes notification text, #7646), a
11+
`notify` node now bridges to the existing localized email-template subsystem.
12+
13+
- **Spec**`NotifyConfigSchema` gains `template` (a `sys_email_template`
14+
name, read raw like `topic`/`channels`) and `templateData` (render context
15+
for the template's `{{var}}` holes; values interpolate `{token}` templates
16+
per run) as the localizable alternative to inline `title`/`message`. Inline
17+
strings stay fully valid and byte-identical for existing flows — they are
18+
the non-localizable path, and the describes now say so. A node carrying BOTH
19+
paths, or `templateData` without `template`, or NEITHER path, is refused
20+
loudly with the fix in the message (the `objectNavTargetExclusivity`
21+
posture: unrepresentable over silent precedence).
22+
- **service-automation** — the notify executor forwards the template
23+
reference and its interpolated render context in the emit payload (the
24+
outbox snapshots it onto each delivery row), and no longer demands an
25+
inline title when a template is referenced.
26+
- **service-messaging** — the email channel routes a template-carrying
27+
delivery through `IEmailService.sendTemplate({ template, locale, data })`,
28+
resolving the recipient locale per delivery: `payload.locale` if the
29+
producer set one, else the deployment default
30+
(`II18nService.getDefaultLocale()`, the #8195 ruled source), else
31+
`sendTemplate`'s documented `en-US` ladder. Template-resolution failures
32+
(`TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`, and an
33+
email service without `sendTemplate`) are graded `permanent` — dead
34+
immediately with the code on the delivery row, instead of burning the retry
35+
schedule on metadata that cannot fix itself.
36+
37+
The inbox channel keeps its existing rendering (notification title/body,
38+
falling back to the topic on the template path): it has no locale-capable
39+
rendering seam to the email-template subsystem today, and that gap is
40+
documented in the PR rather than papered over with a duplicated resolver.

content/docs/references/automation/io-node-config.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ const result = HttpConfigSchema.parse(data);
9696
| Property | Type | Required | Description |
9797
| :--- | :--- | :--- | :--- |
9898
| **recipients** | `string \| string[]` || Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
99-
| **title** | `string` || Notification title |
100-
| **message** | `string` | optional | Notification body |
99+
| **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. |
100+
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
101+
| **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. |
102+
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
101103
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
102104
| **topic** | `string` | optional | Event topic (default: "notify") |
103105
| **severity** | `Enum<'info' \| 'warning' \| 'critical'>` | optional | Severity forwarded to the messaging service |

packages/services/service-automation/src/builtin/notify-node.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,48 @@ describe('notify (baseline node)', () => {
210210
expect(result.error).toContain('title');
211211
});
212212

213+
// ── #9205 — the localizable content path: template references ────────
214+
it('emits the template reference + interpolated templateData instead of inline content', async () => {
215+
engine.registerFlow('notify_flow', notifyFlow({
216+
topic: 'deal.won',
217+
recipients: ['user_1'],
218+
channels: ['inbox', 'email'],
219+
template: 'crm.large_deal_won',
220+
templateData: { dealName: '{dealName}', dealUrl: '/opps/{dealId}' },
221+
}));
222+
223+
const result = await engine.execute('notify_flow', {
224+
params: { dealName: 'Acme', dealId: '42' },
225+
} as any);
226+
227+
expect(result.success).toBe(true);
228+
expect(messaging.emitted).toHaveLength(1);
229+
const payload = messaging.emitted[0].payload;
230+
// The reference rides RAW (a static metadata cross-reference); its
231+
// render context is interpolated per run — that pair is what the
232+
// email channel resolves per recipient locale at delivery time.
233+
expect(payload.template).toBe('crm.large_deal_won');
234+
expect(payload.templateData).toEqual({ dealName: 'Acme', dealUrl: '/opps/42' });
235+
// No inline content keys on this path: a channel without template
236+
// support falls back to the topic, the honest degraded rendering —
237+
// not an empty string masquerading as content.
238+
expect(payload).not.toHaveProperty('title');
239+
expect(payload).not.toHaveProperty('body');
240+
});
241+
242+
it('refuses a node carrying BOTH template and inline title (the contract superRefine, at the parse seam)', async () => {
243+
engine.registerFlow('notify_flow', notifyFlow({
244+
recipients: ['user_1'],
245+
title: 'Deal won',
246+
template: 'crm.large_deal_won',
247+
}));
248+
const result = await engine.execute('notify_flow');
249+
expect(result.success).toBe(false);
250+
expect(result.error).toContain('`template`');
251+
expect(result.error).toContain('`title`');
252+
expect(messaging.emitted).toHaveLength(0);
253+
});
254+
213255
it('fails the step when no recipient is given', async () => {
214256
engine.registerFlow('notify_flow', notifyFlow({ title: 'Hi' }));
215257
const result = await engine.execute('notify_flow');

packages/services/service-automation/src/builtin/notify-node.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,27 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
168168
recipients: {
169169
description: 'Recipient user id(s) / audience selector(s)',
170170
},
171-
title: { type: 'string', description: 'Notification title' },
172-
message: { type: 'string', description: 'Notification body' },
171+
title: {
172+
type: 'string',
173+
description: 'Notification title, sent verbatim (not localizable — use template for per-locale content). Either this or template is required; mutually exclusive with template.',
174+
},
175+
message: {
176+
type: 'string',
177+
description: 'Notification body, sent verbatim (not localizable). Only valid with inline title, never with template.',
178+
},
179+
// ── Localizable content path (#9205) ─────────────────────
180+
// Mirrors `NotifyConfigSchema.template`/`templateData`; the
181+
// mutual exclusion with title/message lives in the Zod
182+
// contract's superRefine (executed at parse time), matching
183+
// how requiredness is owned there rather than by the form.
184+
template: {
185+
type: 'string',
186+
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.',
187+
},
188+
templateData: {
189+
type: 'object',
190+
description: 'Render context for the referenced template\'s {{var}} placeholders; values interpolate {token} templates per run. Only valid together with template.',
191+
},
173192
channels: {
174193
type: 'array', items: { type: 'string' },
175194
description: 'Channels to fan out to (default: inbox)',
@@ -228,6 +247,14 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
228247
// useless `[object Object]` (#3450). Serialize it readably instead.
229248
const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context));
230249
const body = stringifyForTemplate(interpolate(cfg.message ?? '', variables, context));
250+
// #9205 — the localizable content path. `template` is read RAW (a
251+
// static metadata cross-reference, like `topic`/`channels`);
252+
// `templateData` VALUES interpolate per run, so flow state can feed
253+
// the template's `{{var}}` holes at delivery time.
254+
const template = toStr(cfg.template);
255+
const templateData = cfg.templateData
256+
? (interpolate(cfg.templateData, variables, context) as Record<string, unknown>)
257+
: undefined;
231258
const channels = toStringList(cfg.channels);
232259
const topic = cfg.topic ? String(cfg.topic) : undefined;
233260
const severity = cfg.severity ? String(cfg.severity) : undefined;
@@ -246,7 +273,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
246273
const source = resolveSource(cfg, variables, context);
247274
const actorId = toStr(interpolate(cfg.actorId, variables, context));
248275

249-
if (!title) return { success: false, error: 'notify: title is required' };
276+
// With a `template` reference the content lives in the template
277+
// bundle, resolved per recipient locale at delivery — no inline
278+
// title to demand (the Zod contract already refused a node carrying
279+
// NEITHER, and one carrying BOTH).
280+
if (!title && !template) return { success: false, error: 'notify: title is required' };
250281
if (recipients.length === 0) {
251282
// Name the templates that came up empty (framework#3582). The
252283
// dominant cause is a cross-object hop — `{record.owner.manager}`
@@ -269,7 +300,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
269300
const messaging = getMessaging();
270301
if (!messaging) {
271302
ctx.logger.warn(
272-
`[notify] no messaging service registered; notification "${title}" not delivered`,
303+
`[notify] no messaging service registered; notification "${title || `template ${template}`}" not delivered`,
273304
);
274305
return {
275306
success: true,
@@ -289,7 +320,21 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
289320
const result = await messaging.emit({
290321
topic: topic ?? 'notify',
291322
audience: recipients,
292-
payload: { ...(payload ?? {}), title, body, url: actionUrl },
323+
// Content rides in the payload per path (#9205): the inline
324+
// strings, or the template reference + its render context —
325+
// which the outbox snapshots onto each delivery row, so the
326+
// per-recipient-locale resolution happens at delivery time
327+
// in the channel (email-channel.ts reads payload.template).
328+
// On the template path no inline title/body keys are set:
329+
// channels without template support fall back to the topic,
330+
// which is the honest degraded rendering, not ''.
331+
payload: {
332+
...(payload ?? {}),
333+
...(template
334+
? { template, ...(templateData !== undefined ? { templateData } : {}) }
335+
: { title, body }),
336+
url: actionUrl,
337+
},
293338
severity,
294339
source,
295340
actorId,

0 commit comments

Comments
 (0)