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

feat(automation): notify nodes reference email templates for localized delivery — resolve (name, recipient locale) at delivery time#9224
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 optionalsendTemplate 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-LanguageExecutionContext.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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude