Skip to content

feat(automation): notify nodes reference email templates for localized delivery — resolve (name, recipient locale) at delivery time - #9224

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-9205-notify-template-locale-bridge
Aug 17, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-9205-notify-template-locale-bridge

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #9205

Bridges flow notify nodes to the existing localized email-template subsystem, per the maintainer ruling recorded on the card (verbatim, untranslated): 「立项,走 emailTemplates 路线」 — a template reference on the node, not a widening of the flows translation surface (which would overturn #7646; translation.zod.ts is untouched).

Spec contract (packages/spec/src/automation/io-node-config.zod.ts)

NotifyConfigSchema gains the localizable content path:

  • template — a sys_email_template name (e.g. crm.large_deal_won). Read raw like topic/channels (a static metadata cross-reference, never interpolated). The describe states the localization contract plainly: resolved by (name, recipient locale) at delivery time; inline strings are the non-localizable path.
  • templateData — render context for the template's {{var}} holes; values interpolate {token} templates per run.
  • title becomes .optional() only in the type — the acceptance set for existing authors is unchanged: a config with neither title nor template is still refused (previously invalid_type on title, now a custom issue at ['title'] naming both options). Inline-only configs parse byte-identically (pinned).

Dual-path decision — loud refusal, not precedence. Measured precedents: objectNavTargetExclusivity (ui/app.zod.ts) refuses filters + recordId/viewName with "the ambiguous combination is unrepresentable rather than silently resolved by precedence"; time-relative-trigger.zod.ts refuses on "exactly one of withinDays/offsetDays"; the counter-precedent (screen's two shapes, prose-only) predates both and enforces nothing. Followed the strongest: a node carrying both template and inline title/message is refused by superRefine at ['template'], naming both keys, which to keep for localization, and why (runtime precedence would silently ignore one). templateData without template is likewise refused (nothing would ever read it).

Template-name referential validation — tier measured, none invented. The in-family analog is a flow node's by-name reference to another flow (subflow/map.flowName): it validates nowhere at authoring time — no lint rule, and validateCrossReferences (defineStack) does not descend into node configs. Matching that tier, template gets no new authoring-time existence check; enforcement is at delivery (TEMPLATE_NOT_FOUND on the delivery row — made loud and terminal, below). This also matches the semantics of the reference: sys_email_template rows are tenant-authorable in Studio and plugin-shipped (the auth bundle), so stack-time membership would refuse valid deployments.

Delivery-time resolution (declare = enforce, same PR — the #8993 standing pin)

  • service-automation (builtin/notify-node.ts): the executor forwards template (raw) + interpolated templateData in the emit payload — which the outbox snapshots onto each sys_notification_delivery row, so the durable (P1) path carries it identically to inline (P0). The "title is required" execute guard is skipped when a template is referenced. On the template path no inline title/body payload keys are set: channels without template support fall back to the topic (honest degraded rendering, not an empty string). The descriptor configSchema mirrors the two new keys; the form↔Zod ledger (io-node-form-zod-ledger.test.ts) reconciles both directions and is green.
  • service-messaging (email-channel.ts): a delivery whose payload carries template routes through IEmailService.sendTemplate({ template, to, data, locale }) — the existing seam, structurally mirrored as an optional sendTemplate on EmailSenderSurface. An email service without it fails the delivery loudly (TEMPLATE_UNSUPPORTED on the row) instead of silently downgrading to unlocalized content. sendTemplate's own failure vocabulary (TEMPLATE_NOT_FOUND / TEMPLATE_INACTIVE / MISSING_VARIABLES) surfaces verbatim on the delivery row, and classifyError grades all four permanent — dead immediately, matching "wrong metadata cannot be retried into working" — while transport failures (status: 'failed') stay retryable.

Recipient locale source — measured, and its limits stated. The platform has no per-user locale: 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. The measured source used by sendTemplate's existing callers (auth emails, #8195) is the deployment default — II18nService.getDefaultLocale(), probed lazily at delivery so it tracks live localization.locale changes. The email channel resolves: payload.locale (producer override) → deployment default → unset (⇒ sendTemplate's documented en-US ladder). When a per-user locale lands, EmailChannelOptions.getDefaultTemplateLocale is the seam it plugs into; until then every recipient of one deployment gets the deployment's language — better than English-only, not yet per-reader.

Inbox channel: gap documented (the card's "ideally" leg)

Measured: the inbox channel does not render templates at all today — it writes sys_inbox_message rows straight from the notification title/body and is not wired to NotificationTemplateStore (which is keyed by (topic, channel, locale), a different subsystem from email templates anyway). There is no existing seam that resolves-and-renders a sys_email_template without sending mail: IEmailService exposes only send/sendTemplate, and the locale ladder + {{var}} renderer (with ADR-0053 format filters) live inside plugin-email. Wiring the inbox would therefore mean either a new IEmailService render method (new contract surface) or a duplicated resolver/renderer in service-messaging (a second de-facto contract that drifts) — both are the "forced new subsystem" the card rules out. Consequence, precisely: a template-path notify with channels: ['inbox', 'email'] delivers a localized email, and an inbox row whose title is the topic and whose body is empty. Follow-up is the PM's call; the natural shape is a small render-only method on IEmailService.

Tests

  • Spec (io-node-config.test.ts): template-only accepted (measured RED pre-change — template was an unrecognized key); inline-only accepted unchanged; both-paths combo refused (asserted by code: 'custom' + path, never bare success === false, with the prescription's load-bearing phrases pinned); templateData-without-template refused; neither-path refused; the localization contract pinned in the describes.
  • service-automation (notify-node.test.ts): template + interpolated templateData reach emit() with no inline content keys; the both-paths combo refuses the step at the parse seam; all pre-existing inline-path pins unchanged.
  • service-messaging (email-channel.test.ts): template + data + locale reach sendTemplate and send() is never consulted; producer payload.locale wins over the deployment default; absent both ⇒ no locale passed (sendTemplate's own en-US ladder); sendTemplate-less service ⇒ loud TEMPLATE_UNSUPPORTED, graded permanent; TEMPLATE_NOT_FOUND surfaces verbatim, graded permanent; transport status:'failed' stays retryable; a payload without template keeps the pre-change path byte-identically.

Reverse verification (fix committed first; implementation files restored from origin/main with git restore --source, tests kept, then restored): spec contract ablated ⇒ the 7 new spec cases go red (template refused as unrecognized key; the at-least-one case falls back to invalid_type) and all 21 pre-existing cases stay green; email-channel.ts ablated ⇒ 6 of the 7 new channel cases go red (delivery falls into the legacy send path / no loud refusal) while the 7th — the no-template preservation pin — stays green by design, and all 7 pre-existing cases stay green; notify-node.ts ablated ⇒ both new executor cases go red (2 red / 13 green). Direction observed matched the pre-declared expectation in all three legs.

Verification

  • pnpm --filter @objectstack/spec build + full pnpm --filter @objectstack/spec test (407 files / 10834 tests) / typecheck — green
  • pnpm --filter @objectstack/service-automation test (80 files / 964 tests) and --filter @objectstack/service-messaging test (22 files / 236 tests) + service-messaging typecheck — green (service-automation is typecheck-debt-exempt by ledger)
  • check:generated regenerated: docs references + authorable-surface shards (gen:schema, gen:docs)
  • Gate union re-derived from changed paths via node scripts/pm/dispatch-gates.mjs at the final head — union re-run at head 2f80ce13c (clean tree) — all green; see the report comment

Changeset

notify-node-email-template-locale-bridge.md — minor for @objectstack/spec, @objectstack/service-automation, @objectstack/service-messaging (declared-key addition, #9113 placeholder precedent; pure widening ⇒ no ADR-0087 marker).

Cross-repo: the hotcrm consumer card (16 notify nodes × 4 locales) stays Blocked-by: #9205 and unlocks when this lands. hotcrm is untouched here.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/service-automation, @objectstack/service-messaging, @objectstack/spec.

106 hand-written doc(s) reference the affected code — list omitted above 15 rows. Re-derive: node scripts/docs-audit/affected-docs.mjs --json origin/main.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants