From 2d33cd6dadb7e7d243efa812dbccd7f309f06402 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 05:49:13 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(messaging):=20ADR-0030=20P3a=20?= =?UTF-8?q?=E2=80=94=20email=20channel=20+=20notification=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same emit() now reaches inbox AND email per the user's preferences, rendered from a template. - email channel (createEmailChannel): a thin MessagingChannel that delegates transport to the existing `email` service (ADR-0022). Resolves recipient user id → sys_user.email (or an email-shaped recipient verbatim), renders, sends. Retry/backoff/dead-letter come from the P1 outbox dispatcher. Registered at kernel:ready only when an `email` service is present. - sys_notification_template (topic × channel × locale) + renderer: declarative {{ payload.x }} interpolation (no logic — auditable), html/markdown/text bodies, locale fallback (en-US → en → default), generic fallback to payload.title/body when no template. Contributed to Setup → Configuration nav. Scope (ADR-0022): Slack stays a connector (connector-slack already ships the raw API path); a Slack notification channel needs identity mapping + OAuth and is enterprise-tier — deferred. push/webhook channels + digest/quiet-hours (P3b) are follow-ups on the same seam. Tests: service-messaging 85 passing (adds template-renderer.test.ts + email-channel.test.ts). https://claude.ai/code/session_015pRGvrm3zrk5m8YvZhkAmF --- .changeset/adr-0030-notification-p3a-email.md | 33 +++++ .../adr-0030-notification-convergence.md | 24 ++- .../src/email-channel.test.ts | 135 +++++++++++++++++ .../service-messaging/src/email-channel.ts | 138 +++++++++++++++++ .../services/service-messaging/src/index.ts | 18 +++ .../src/messaging-service-plugin.ts | 27 ++++ .../service-messaging/src/objects/index.ts | 1 + .../objects/notification-template.object.ts | 87 +++++++++++ .../src/template-renderer.test.ts | 116 +++++++++++++++ .../src/template-renderer.ts | 140 ++++++++++++++++++ 10 files changed, 716 insertions(+), 3 deletions(-) create mode 100644 .changeset/adr-0030-notification-p3a-email.md create mode 100644 packages/services/service-messaging/src/email-channel.test.ts create mode 100644 packages/services/service-messaging/src/email-channel.ts create mode 100644 packages/services/service-messaging/src/objects/notification-template.object.ts create mode 100644 packages/services/service-messaging/src/template-renderer.test.ts create mode 100644 packages/services/service-messaging/src/template-renderer.ts diff --git a/.changeset/adr-0030-notification-p3a-email.md b/.changeset/adr-0030-notification-p3a-email.md new file mode 100644 index 000000000..a6043813d --- /dev/null +++ b/.changeset/adr-0030-notification-p3a-email.md @@ -0,0 +1,33 @@ +--- +"@objectstack/service-messaging": minor +--- + +ADR-0030 P3a — email channel + notification templates. The same `emit()` now +reaches inbox **and** email per the user's preferences, rendered from a +template. + +- **`email` channel** (`createEmailChannel`) — a thin `MessagingChannel` that + delegates transport to the existing `email` service (ADR-0022: channel adds + messaging semantics, the email sub-system stays the transport). It resolves the + recipient user id → address (`sys_user.email`, or an email-shaped recipient + verbatim), renders, and sends. Retry/backoff/dead-letter come free from the P1 + outbox dispatcher. Registered at `kernel:ready` only when an `email` service is + present; absent ⇒ no channel (an explicit `channels:['email']` then reports + "not registered" rather than silently no-opping). No-ops gracefully like the + inbox channel when the capability isn't installed. +- **`sys_notification_template`** (topic × channel × locale) + a renderer: + declarative `{{ payload.x }}` interpolation (no logic — auditable metadata), + HTML/markdown/text bodies, locale fallback (`en-US` → `en` → default), and a + **generic fallback to `payload.title`/`body`** when no template matches (so + templates are purely additive). Contributed to the Setup → Configuration nav. +- Channels are now keyed per recipient (from P2), so a notification reaches each + user on exactly the channels they accept, rendered by that channel's template. + +Scope note (ADR-0022): **Slack stays a connector** (`connector-slack` already +ships the raw API path); a Slack *notification channel* needs per-user identity +mapping + OAuth and is enterprise-tier — deferred. push/webhook channels and the +digest / quiet-hours middleware (P3b) are follow-ups on the same seam. + +Tests: service-messaging **85 passing** — adds `template-renderer.test.ts` and +`email-channel.test.ts` (address resolution, template vs fallback rendering, +no-service no-op, unresolved-address failure, transport-failure retry). diff --git a/docs/handoff/adr-0030-notification-convergence.md b/docs/handoff/adr-0030-notification-convergence.md index 81a5f4256..9a1ec6462 100644 --- a/docs/handoff/adr-0030-notification-convergence.md +++ b/docs/handoff/adr-0030-notification-convergence.md @@ -159,6 +159,24 @@ flipped — the inbox is being populated the whole time.) - *Follow-ups*: subscription-driven fan-out (expand a topic's subscribers when a producer emits without an explicit audience) is schema-only so far; `digest`/`quiet_hours` fields exist but the batching middleware is P3. -- **P3 — Channels + templates + digest**: email/push/webhook/Slack channels on - connectors (ADR-0022); `sys_notification_template` (topic×channel×locale) + - renderer; digest / quiet-hours middleware. +- **P3 — Channels + templates + digest**: split into slices. + - **P3a — email channel + templates**: ✅ shipped. `createEmailChannel` + (delegates transport to the `email` service per ADR-0022) + + `sys_notification_template` (topic×channel×locale) + `{{ payload.x }}` + renderer with generic `payload.title`/`body` fallback. Same `emit` now + reaches inbox + email per prefs. + - **P3b — digest + quiet-hours**: pending. Plan: express both as **deferring + the delivery row's `next_attempt_at`** in the P1 outbox (digest = enqueue to + the next window + collapse same-`(user, channel, window)` rows; quiet-hours = + push `next_attempt_at` to the window end), reusing the dispatcher's + claim/retry/observability — one delivery spine, not a parallel scheduler. + Consumes the `digest`/`quiet_hours` fields P2 added. critical/mandatory + bypass. tz from `quiet_hours.tz` → `sys_user` → org/UTC. + - **Deferred (same seam, incremental)**: **Slack** stays a *connector* + (`connector-slack` ships the raw API path today); a Slack notification + *channel* needs identity mapping (`sys_channel_user_link`) + OAuth and is + enterprise-tier (ADR-0022). **push** needs `sys_user_device` + APNs/FCM. + **webhook** should reuse the existing `plugin-webhooks` outbox rather than a + redundant channel. **MJML** compilation for email (P3a treats `mjml` format + as raw HTML). **`defineTopic()`** declarative topic catalog (Studio + discoverability for topics/templates/preferences). diff --git a/packages/services/service-messaging/src/email-channel.test.ts b/packages/services/service-messaging/src/email-channel.test.ts new file mode 100644 index 000000000..66c6be849 --- /dev/null +++ b/packages/services/service-messaging/src/email-channel.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { createEmailChannel } from './email-channel.js'; +import { NotificationTemplateStore } from './template-renderer.js'; +import type { Delivery } from './channel.js'; + +function silentCtx() { + return { logger: { info: () => {}, warn: () => {}, error: () => {} } }; +} + +function delivery(over: Partial = {}, recipient = 'user_1'): Delivery { + return { + channel: 'email', + recipient, + notification: { + notificationId: 'evt_1', + topic: 'deal.won', + title: 'Deal closed', + body: 'Acme signed', + severity: 'info', + recipients: [recipient], + payload: { title: 'Deal closed', body: 'Acme signed' }, + ...over, + }, + }; +} + +/** Fake data engine: user id → email, and template lookups. */ +function fakeData(opts: { users?: Record; templates?: any[] } = {}) { + const users = opts.users ?? { user_1: 'ada@example.com' }; + const templates = opts.templates ?? []; + return { + async findOne(object: string, query: any) { + const w = query?.where ?? {}; + if (object === 'sys_user') { + const email = users[String(w.id)]; + return email ? { email } : null; + } + 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; + } + return null; + }, + async find() { return []; }, + async insert(_o: string, r: any) { return { id: 'x', ...r }; }, + async update() { return {}; }, + async delete() { return {}; }, + async count() { return 0; }, + async aggregate() { return []; }, + } as any; +} + +function fakeEmail() { + const sent: any[] = []; + return { + sent, + service: { + async send(input: any) { + sent.push(input); + return { id: 'email_row_1' }; + }, + }, + }; +} + +function channel(getEmail: () => any, data: any, templates: any[] = []) { + const store = new NotificationTemplateStore({ getData: () => data }); + return createEmailChannel({ getEmail, getData: () => data, store: store }); +} + +describe('email channel', () => { + it('has the stable id "email"', () => { + const ch = channel(() => undefined, fakeData()); + expect(ch.id).toBe('email'); + }); + + it('no-ops (success) when no email service is registered', async () => { + const ch = channel(() => undefined, fakeData()); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(true); + expect(r.externalId).toBeUndefined(); + }); + + it('resolves the recipient user id → email and sends the fallback subject/body', async () => { + const email = fakeEmail(); + const ch = channel(() => email.service, fakeData({ users: { user_1: 'ada@example.com' } })); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(true); + expect(r.externalId).toBe('email_row_1'); + expect(email.sent).toHaveLength(1); + expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Deal closed', text: 'Acme signed' }); + }); + + it('renders an HTML template when one exists for (topic, email, locale)', async () => { + const email = fakeEmail(); + const data = fakeData({ + users: { user_1: 'ada@example.com' }, + templates: [{ topic: 'deal.won', channel: 'email', locale: 'en', is_active: true, subject: 'Won {{ payload.title }}', body: '

{{ payload.title }}

', format: 'html' }], + }); + const ch = channel(() => email.service, data); + await ch.send(silentCtx(), delivery()); + expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Won Deal closed', html: '

Deal closed

' }); + }); + + it('accepts an email-shaped recipient verbatim (no user lookup)', async () => { + const email = fakeEmail(); + const ch = channel(() => email.service, fakeData({ users: {} })); + const r = await ch.send(silentCtx(), delivery({}, 'bob@example.com')); + expect(r.ok).toBe(true); + expect(email.sent[0].to).toBe('bob@example.com'); + }); + + it('reports a failure when no address resolves (observable on the delivery row)', async () => { + const email = fakeEmail(); + const ch = channel(() => email.service, fakeData({ users: {} })); + const r = await ch.send(silentCtx(), delivery({}, 'ghost')); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/no email address/); + expect(email.sent).toHaveLength(0); + }); + + it('surfaces a transport failure as ok:false (dispatcher will retry)', async () => { + const data = fakeData(); + const ch = createEmailChannel({ + getEmail: () => ({ async send() { throw new Error('smtp down'); } }), + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(false); + expect(r.error).toContain('smtp down'); + expect(ch.classifyError?.(new Error('x'))).toBe('retryable'); + }); +}); diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts new file mode 100644 index 000000000..22ebebf23 --- /dev/null +++ b/packages/services/service-messaging/src/email-channel.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { + Delivery, + ErrorClass, + MessagingChannel, + MessagingChannelContext, + SendResult, +} from './channel.js'; +import { + NotificationTemplateStore, + renderNotification, + DEFAULT_LOCALE, +} from './template-renderer.js'; + +/** The user identity object a recipient id is resolved to an address against. */ +export const USER_OBJECT = 'sys_user'; + +/** + * Structural view of the email service (`@objectstack/plugin-email`'s + * `EmailService`), declared locally so service-messaging takes no runtime + * dependency on it — the channel resolves whatever is registered under the + * `email` service and sends through this shape (mirrors the `notify` node's + * `MessagingServiceSurface` pattern). + */ +export interface EmailSenderSurface { + send(input: { + to: string | string[]; + subject: string; + html?: string; + text?: string; + }): Promise<{ id?: string } | unknown>; +} + +export interface EmailChannelOptions { + /** Resolve the email service; `undefined` ⇒ the channel no-ops (not installed). */ + getEmail(): EmailSenderSurface | undefined; + /** Resolve the data engine (recipient address lookup). */ + getData(): IDataEngine | undefined; + /** Template store for `(topic, 'email', locale)` rendering. */ + store: NotificationTemplateStore; + /** User identity object override (default {@link USER_OBJECT}). */ + userObject?: string; + /** Locale used when the delivery carries none (default {@link DEFAULT_LOCALE}). */ + defaultLocale?: string; +} + +const EMAIL_SHAPE = (s: string): boolean => { + // Linear, non-backtracking "looks like an email" — same shape as the + // recipient resolver's check (avoids the ReDoS-prone regex). + if (!s || /\s/.test(s)) return false; + const at = s.indexOf('@'); + if (at <= 0 || at !== s.lastIndexOf('@') || at === s.length - 1) return false; + const dot = s.slice(at + 1).indexOf('.'); + return dot > 0 && dot < s.length - at - 2; +}; + +/** + * The `email` channel (ADR-0030 P3) — delivers a notification by email. + * + * It adds only the messaging semantics on top of the existing email transport + * (ADR-0022 "channel delegates transport to a sub-system"): resolve the + * recipient's address, render `(topic, 'email', locale)` from + * `sys_notification_template` (fallback to `payload.title`/`body`), and hand the + * subject/body to the `email` service. Retry/backoff/dead-letter come for free + * from the P1 outbox dispatcher. + * + * 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). + */ +export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel { + const userObject = opts.userObject ?? USER_OBJECT; + const defaultLocale = opts.defaultLocale ?? DEFAULT_LOCALE; + + async function resolveAddress( + ctx: MessagingChannelContext, + data: IDataEngine | undefined, + recipient: string, + ): Promise { + if (EMAIL_SHAPE(recipient)) return recipient; // already an address + if (!data) return undefined; + 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; + } catch (err) { + ctx.logger.warn(`[email] address lookup for '${recipient}' failed (${(err as Error).message})`); + return undefined; + } + } + + return { + id: 'email', + + async send(ctx: MessagingChannelContext, delivery: Delivery): Promise { + const email = opts.getEmail(); + if (!email) { + ctx.logger.warn(`[email] no email service registered; '${delivery.recipient}' not emailed`); + return { ok: true }; // capability not installed — no-op, like inbox w/o data + } + + const n = delivery.notification; + const address = await resolveAddress(ctx, opts.getData(), delivery.recipient); + if (!address) { + return { ok: false, error: `no email address for recipient '${delivery.recipient}'` }; + } + + const payload = (n.payload ?? {}) as Record; + const locale = typeof payload.locale === 'string' ? payload.locale : defaultLocale; + const template = await opts.store.load(n.topic ?? '', 'email', locale); + const rendered = renderNotification(template, { + topic: n.topic ?? '', + payload, + title: n.title, + body: n.body, + }); + + try { + const result: any = await email.send({ + to: address, + subject: rendered.subject, + ...(rendered.html !== undefined ? { html: rendered.html } : {}), + ...(rendered.text !== undefined ? { text: rendered.text } : {}), + }); + const id = result?.id; + return { ok: true, externalId: id != null ? String(id) : undefined }; + } catch (err) { + return { ok: false, error: `email send failed: ${(err as Error).message}` }; + } + }, + + classifyError(_err: unknown): ErrorClass { + return 'retryable'; + }, + }; +} diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index 665dd9226..1fabccb30 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -54,6 +54,23 @@ export type { // Channel seam export { createInboxChannel, INBOX_OBJECT, RECEIPT_OBJECT } from './inbox-channel.js'; 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'; + +// Templates + renderer (ADR-0030 P3) +export { + NotificationTemplateStore, + renderNotification, + interpolate, + TEMPLATE_OBJECT, + DEFAULT_LOCALE, +} from './template-renderer.js'; +export type { + NotificationTemplateRow, + RenderedNotification, + RenderInput, + NotificationTemplateStoreOptions, +} from './template-renderer.js'; export type { MessagingChannel, MessagingChannelContext, @@ -92,4 +109,5 @@ export { NotificationDelivery, NotificationPreference, NotificationSubscription, + NotificationTemplate, } from './objects/index.js'; diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 17d2f2d02..118d4f41c 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -7,12 +7,15 @@ import { MessagingService } from './messaging-service.js'; import { createInboxChannel } from './inbox-channel.js'; import { SqlNotificationOutbox } from './sql-outbox.js'; import { NotificationDispatcher, type DispatchCluster } from './dispatcher.js'; +import { createEmailChannel } from './email-channel.js'; +import { NotificationTemplateStore } from './template-renderer.js'; import { InboxMessage, NotificationReceipt, NotificationDelivery, NotificationPreference, NotificationSubscription, + NotificationTemplate, } from './objects/index.js'; export interface MessagingServicePluginOptions { @@ -123,6 +126,7 @@ export class MessagingServicePlugin implements Plugin { NotificationDelivery, NotificationPreference, NotificationSubscription, + NotificationTemplate, ], navigationContributions: [ { @@ -132,11 +136,34 @@ export class MessagingServicePlugin implements Plugin { items: [ { id: 'nav_notification_preferences', type: 'object', label: 'Notification Preferences', objectName: 'sys_notification_preference', icon: 'bell-ring', requiresObject: 'sys_notification_preference' }, { id: 'nav_notification_subscriptions', type: 'object', label: 'Notification Subscriptions', objectName: 'sys_notification_subscription', icon: 'rss', requiresObject: 'sys_notification_subscription' }, + { id: 'nav_notification_templates', type: 'object', label: 'Notification Templates', objectName: 'sys_notification_template', icon: 'file-text', requiresObject: 'sys_notification_template' }, ], }, ], }); + // Email channel (ADR-0030 P3): register when an `email` service is + // present. Resolved at kernel:ready so init order with the email plugin + // doesn't matter; absent email ⇒ no channel (a notify(channels:['email']) + // then reports "not registered" rather than silently no-opping). The + // dispatcher looks channels up dynamically, so registering after it is fine. + if (typeof ctx.hook === 'function') { + const templateStore = new NotificationTemplateStore({ getData }); + const getEmail = () => { + try { + return ctx.getService('email'); + } 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)'); + } + }); + } + // Reliable delivery (P1): wire the outbox + dispatcher once the engine // is resolvable. Until then `emit()` runs inline best-effort. if (this.options.reliableDelivery && typeof ctx.hook === 'function') { diff --git a/packages/services/service-messaging/src/objects/index.ts b/packages/services/service-messaging/src/objects/index.ts index 0c48cbf76..81defeb49 100644 --- a/packages/services/service-messaging/src/objects/index.ts +++ b/packages/services/service-messaging/src/objects/index.ts @@ -5,3 +5,4 @@ export { NotificationReceipt } from './notification-receipt.object.js'; export { NotificationDelivery } from './notification-delivery.object.js'; export { NotificationPreference } from './notification-preference.object.js'; export { NotificationSubscription } from './notification-subscription.object.js'; +export { NotificationTemplate } from './notification-template.object.js'; diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts new file mode 100644 index 000000000..5aa7393e8 --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * `sys_notification_template` — channel-agnostic render template (ADR-0030 + * cross-cutting / P3). + * + * One row per `(topic, channel, locale)` carrying the `subject`/`body` a channel + * renders from the event `payload` (declarative `{{ payload.x }}` interpolation + * — see `template-renderer.ts`). `format` tells the channel how to treat `body` + * (markdown/html/text). When no template matches, channels fall back to + * `payload.title` / `payload.body` (the P0/P1 behavior), so templates are purely + * additive. + * + * Studio-configurable (contributed to the Setup → Configuration nav). Belongs to + * `service-messaging`. + */ +export const NotificationTemplate = ObjectSchema.create({ + name: 'sys_notification_template', + label: 'Notification Template', + pluralLabel: 'Notification Templates', + icon: 'file-text', + isSystem: true, + managedBy: 'system', + description: 'Per (topic × channel × locale) render template for notifications.', + titleFormat: '{topic} · {channel} · {locale}', + compactLayout: ['topic', 'channel', 'locale', 'is_active'], + + fields: { + id: Field.text({ label: 'Template ID', required: true, readonly: true }), + + topic: Field.text({ label: 'Topic', required: true, searchable: true }), + + channel: Field.text({ + label: 'Channel', + required: true, + defaultValue: 'email', + description: 'Channel id this template renders for (email/inbox/push/…).', + }), + + locale: Field.text({ + label: 'Locale', + required: true, + defaultValue: 'en', + description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'.", + }), + + version: Field.number({ + label: 'Version', + required: false, + defaultValue: 1, + }), + + subject: Field.text({ + label: 'Subject / Title', + required: false, + description: 'Rendered into the email subject / inbox title. Supports {{ payload.x }}.', + }), + + body: Field.markdown({ + label: 'Body', + required: false, + description: 'Template body. Supports {{ payload.x }}. Interpreted per `format`.', + }), + + format: Field.select(['markdown', 'html', 'text', 'mjml'], { + label: 'Body Format', + required: false, + defaultValue: 'markdown', + }), + + is_active: Field.boolean({ + label: 'Active', + defaultValue: true, + description: 'Only active templates are selected at render time.', + }), + + created_at: Field.datetime({ label: 'Created At', readonly: true }), + updated_at: Field.datetime({ label: 'Updated At', required: false }), + }, + + indexes: [ + { fields: ['topic', 'channel', 'locale'] }, + { fields: ['topic'] }, + ], +}); diff --git a/packages/services/service-messaging/src/template-renderer.test.ts b/packages/services/service-messaging/src/template-renderer.test.ts new file mode 100644 index 000000000..d3feb6153 --- /dev/null +++ b/packages/services/service-messaging/src/template-renderer.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + interpolate, + renderNotification, + NotificationTemplateStore, +} from './template-renderer.js'; + +describe('interpolate', () => { + it('substitutes {{ path.to.value }} from the context, linearly', () => { + const out = interpolate('Hi {{ payload.name }} — {{ topic }}', { + payload: { name: 'Ada' }, + topic: 'deal.won', + }); + expect(out).toBe('Hi Ada — deal.won'); + }); + + it('renders an unknown path to empty string (no throw)', () => { + expect(interpolate('x={{ a.b.c }}', {})).toBe('x='); + }); + + it('does not evaluate logic — single braces and malformed tokens pass through literally', () => { + // `{{also bad path}}` has spaces inside, so it is not a valid token and + // is left untouched (no evaluation); a well-formed unknown token renders + // empty (covered above). + expect(interpolate('{ not a token } {{also bad path}}', {})).toBe('{ not a token } {{also bad path}}'); + }); +}); + +describe('renderNotification', () => { + const input = { topic: 'deal.won', payload: { title: 'Deal closed', body: 'Acme signed', amount: 42 } }; + + it('renders an HTML template into { subject, html }', () => { + const r = renderNotification( + { subject: '{{ payload.title }}', body: '{{ payload.amount }}', format: 'html' }, + input, + ); + expect(r).toEqual({ subject: 'Deal closed', html: '42' }); + }); + + it('renders a markdown/text template into { subject, text }', () => { + const r = renderNotification( + { subject: 'Won: {{ payload.title }}', body: 'Amount {{ payload.amount }}', format: 'markdown' }, + input, + ); + expect(r).toEqual({ subject: 'Won: Deal closed', text: 'Amount 42' }); + }); + + it('falls back to title/body when there is no template', () => { + expect(renderNotification(null, input)).toEqual({ subject: 'Deal closed', text: 'Acme signed' }); + }); + + it('falls back to the topic when no title is available', () => { + const r = renderNotification(null, { topic: 'sys.alert', payload: {} }); + expect(r).toEqual({ subject: 'sys.alert', text: '' }); + }); + + it('uses the explicit title/body over payload when provided', () => { + const r = renderNotification(null, { topic: 't', payload: { title: 'p', body: 'pb' }, title: 'T', body: 'B' }); + expect(r).toEqual({ subject: 'T', text: 'B' }); + }); +}); + +describe('NotificationTemplateStore', () => { + function fakeData(rows: any[] = []) { + const queries: any[] = []; + return { + queries, + engine: { + async findOne(object: string, query: any) { + queries.push({ object, where: query?.where }); + const w = query?.where ?? {}; + return ( + rows.find( + (r) => r.topic === w.topic && r.channel === w.channel && r.locale === w.locale && r.is_active, + ) ?? null + ); + }, + async find() { return []; }, + async insert(_o: string, r: any) { return { id: 'x', ...r }; }, + async update() { return {}; }, + async delete() { return {}; }, + async count() { return 0; }, + async aggregate() { return []; }, + } as any, + }; + } + + it('returns null when there is no data engine', async () => { + const store = new NotificationTemplateStore({ getData: () => undefined }); + expect(await store.load('t', 'email', 'en')).toBeNull(); + }); + + it('loads the exact (topic, channel, locale) active template', async () => { + const data = fakeData([{ topic: 't', channel: 'email', locale: 'en', is_active: true, subject: 'S', body: 'B' }]); + const store = new NotificationTemplateStore({ getData: () => data.engine }); + const row = await store.load('t', 'email', 'en'); + expect(row).toMatchObject({ subject: 'S', body: 'B' }); + }); + + it('falls back en-US → en → default locale', async () => { + const data = fakeData([{ topic: 't', channel: 'email', locale: 'en', is_active: true, subject: 'EN' }]); + const store = new NotificationTemplateStore({ getData: () => data.engine }); + const row = await store.load('t', 'email', 'en-US'); + expect(row).toMatchObject({ subject: 'EN' }); + // First tried en-US, then en. + expect(data.queries.map((q) => q.where.locale)).toEqual(['en-US', 'en']); + }); + + it('returns null (generic fallback) on a lookup error', async () => { + const engine = { async findOne() { throw new Error('locked'); } } as any; + const store = new NotificationTemplateStore({ getData: () => engine }); + expect(await store.load('t', 'email', 'en')).toBeNull(); + }); +}); diff --git a/packages/services/service-messaging/src/template-renderer.ts b/packages/services/service-messaging/src/template-renderer.ts new file mode 100644 index 000000000..3d5084c71 --- /dev/null +++ b/packages/services/service-messaging/src/template-renderer.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IDataEngine } from '@objectstack/spec/contracts'; + +/** The object notification templates live in. */ +export const TEMPLATE_OBJECT = 'sys_notification_template'; + +/** Default locale used when a delivery carries none. */ +export const DEFAULT_LOCALE = 'en'; + +/** A loaded template row (the columns the renderer reads). */ +export interface NotificationTemplateRow { + subject?: string | null; + body?: string | null; + format?: 'markdown' | 'html' | 'text' | 'mjml' | null; +} + +/** The rendered artifact a channel turns into its transport payload. */ +export interface RenderedNotification { + subject: string; + /** Set when the template/format is HTML-ish (html/mjml). */ + html?: string; + /** Set otherwise (markdown/text, or the no-template fallback). */ + text?: string; +} + +/** + * Render context: the event payload plus a few top-level conveniences so a + * template can write `{{ title }}` as well as `{{ payload.title }}`. + */ +export interface RenderInput { + topic: string; + payload: Record; + /** Resolved title/body from the notification (fallback when no template). */ + title?: string; + body?: string; +} + +const TOKEN = /\{\{\s*([\w.$]+)\s*\}\}/g; + +/** + * Declarative `{{ path.to.value }}` interpolation over a context object. No + * logic, no conditionals — templates stay auditable metadata (a deliberate + * low-code constraint). An unknown path renders to an empty string. This is the + * single, linear-time substitution pass; it never evaluates template code. + */ +export function interpolate(template: string, context: Record): string { + if (!template) return ''; + return template.replace(TOKEN, (_m, path: string) => { + const v = lookup(context, path); + return v == null ? '' : String(v); + }); +} + +function lookup(ctx: Record, path: string): unknown { + let cur: unknown = ctx; + for (const key of path.split('.')) { + if (cur == null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[key]; + } + return cur; +} + +/** + * Render a notification for a channel. With a template, `subject`/`body` are + * interpolated and `body` is routed to `html` (html/mjml) or `text` + * (markdown/text). With no template, fall back to the notification's + * `title`/`body` (the P0/P1 behavior) as `subject`/`text`. + */ +export function renderNotification( + template: NotificationTemplateRow | null | undefined, + input: RenderInput, +): RenderedNotification { + const ctx: Record = { + ...input.payload, + payload: input.payload, + topic: input.topic, + title: input.title ?? input.payload.title, + body: input.body ?? input.payload.body, + }; + + if (template && (template.subject || template.body)) { + const subject = interpolate(String(template.subject ?? ''), ctx) || String(ctx.title ?? input.topic); + const renderedBody = interpolate(String(template.body ?? ''), ctx); + const isHtml = template.format === 'html' || template.format === 'mjml'; + return isHtml ? { subject, html: renderedBody } : { subject, text: renderedBody }; + } + + // Generic fallback — no template for this (topic, channel, locale). + return { + subject: String(ctx.title ?? input.topic), + text: String(ctx.body ?? ''), + }; +} + +export interface NotificationTemplateStoreOptions { + getData(): IDataEngine | undefined; + objectName?: string; +} + +/** + * Loads `sys_notification_template` rows by `(topic, channel, locale)`, with a + * locale fallback (`en-US` → `en` → {@link DEFAULT_LOCALE}). Best-effort: no + * data engine or a lookup error yields `null` (→ the renderer's generic + * fallback), never a throw — a template outage must not block delivery. + */ +export class NotificationTemplateStore { + private readonly objectName: string; + constructor(private readonly opts: NotificationTemplateStoreOptions) { + this.objectName = opts.objectName ?? TEMPLATE_OBJECT; + } + + async load(topic: string, channel: string, locale?: string): Promise { + const data = this.opts.getData(); + if (!data) return null; + const candidates = localeCandidates(locale); + for (const loc of candidates) { + try { + const row = await data.findOne(this.objectName, { + where: { topic, channel, locale: loc, is_active: true }, + fields: ['subject', 'body', 'format'], + }); + if (row) return row as NotificationTemplateRow; + } catch { + return null; // best-effort — fall back to generic rendering + } + } + return null; + } +} + +/** `en-US` → ['en-US','en', DEFAULT_LOCALE]; dedups, keeps order. */ +function localeCandidates(locale?: string): string[] { + const out: string[] = []; + const push = (l?: string) => { if (l && !out.includes(l)) out.push(l); }; + push(locale); + if (locale && locale.includes('-')) push(locale.split('-')[0]); + push(DEFAULT_LOCALE); + return out; +} From c9df35131b9673b36d49205143722dc1b4756ae0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 06:05:47 +0000 Subject: [PATCH 2/3] ci: re-trigger Test Core (flaky @objectstack/spec build-order race; full suite green locally) https://claude.ai/code/session_015pRGvrm3zrk5m8YvZhkAmF From f88bb54ffa590516ea5e89b4e881bc66003d43d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 06:13:25 +0000 Subject: [PATCH 3/3] ci: re-trigger Build Core (flaky spec subpath build-order race; pnpm -r build + turbo test both green locally) https://claude.ai/code/session_015pRGvrm3zrk5m8YvZhkAmF