diff --git a/objectstack.config.ts b/objectstack.config.ts index 88cf3f8..f73ef1d 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -22,6 +22,7 @@ import { dulyDashboards } from './src/dashboards/index.js'; import { dulyDatasets } from './src/datasets/index.js'; import { dulyMappings } from './src/mappings/index.js'; import { dulyTranslations } from './src/translations/index.js'; +import { dulyEmailTemplates } from './src/email-templates/index.js'; import { dulySeeds } from './src/data/index.js'; // [#7036] Lifecycle hooks are NOT collected from the objects barrel — the // runtime reads them from `defineStack({ hooks })` only. A `*.hook.ts` that is @@ -141,6 +142,7 @@ export default defineStack({ datasets: dulyDatasets, mappings: dulyMappings, translations: dulyTranslations, + emailTemplates: dulyEmailTemplates, data: dulySeeds, hooks: dulyHooks, functions: dulyFunctions, diff --git a/package.json b/package.json index 1a2b123..acb939a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ }, "devDependencies": { "@objectstack/cli": "^17.2.0", + "@objectstack/plugin-email": "^17.2.0", + "@objectstack/service-automation": "^17.2.0", + "@objectstack/service-job": "^17.2.0", + "@objectstack/service-messaging": "^17.2.0", + "@objectstack/trigger-schedule": "^17.2.0", "typescript": "^6.0.0", "vitest": "^4.1.10" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c36355b..11aa839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,21 @@ importers: '@objectstack/cli': specifier: ^17.2.0 version: 17.2.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(kysely@0.29.5)(mongodb@7.6.0)(nanostores@1.5.2)(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/plugin-email': + specifier: ^17.2.0 + version: 17.2.0(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/service-automation': + specifier: ^17.2.0 + version: 17.2.0 + '@objectstack/service-job': + specifier: ^17.2.0 + version: 17.2.0(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/service-messaging': + specifier: ^17.2.0 + version: 17.2.0(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/trigger-schedule': + specifier: ^17.2.0 + version: 17.2.0 typescript: specifier: ^6.0.0 version: 6.0.3 diff --git a/src/email-templates/index.ts b/src/email-templates/index.ts new file mode 100644 index 0000000..77c758a --- /dev/null +++ b/src/email-templates/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Barrel for src/email-templates/. +// +// Every metadata directory is pre-created and already wired into +// objectstack.config.ts — including the empty ones — so a feature branch adds +// its entry HERE and never edits the config. The config is the one file every +// parallel task would otherwise collide on. +// +// The collection is a named array rather than `Object.values(barrel)`: on an +// empty namespace `Object.values` has nothing to infer from and TypeScript +// resolves it against the keyed branch of `MetadataCollectionInput`, which +// makes `name` optional and fails the assignment. A named array is `never[]` +// while empty and infers correctly the moment something is pushed into it. +// +// ── What a member of this collection is ───────────────────────────────── +// ONE `(name, locale)` ROW, not one template. A template "bundle" is the set +// of rows sharing a `name`: `IEmailService` resolves `(name, locale)` at +// delivery and renders the row it picks, so `duly.task_lead_time` appears +// here twice — once as `en`, once as `zh-CN`. Adding a locale is adding an +// entry to this array, never editing an existing one. + +import { + TaskDueSoonReminderEn, + TaskDueSoonReminderZhCN, + TaskLeadTimeReminderEn, + TaskLeadTimeReminderZhCN, + TaskOverdueEscalationEn, + TaskOverdueEscalationZhCN, + dulyReminderEmailTemplates, +} from './reminders.email-template.js'; + +export { + TaskLeadTimeReminderEn, + TaskLeadTimeReminderZhCN, + TaskDueSoonReminderEn, + TaskDueSoonReminderZhCN, + TaskOverdueEscalationEn, + TaskOverdueEscalationZhCN, + dulyReminderEmailTemplates, +}; + +export const dulyEmailTemplates = [...dulyReminderEmailTemplates]; diff --git a/src/email-templates/reminders.email-template.ts b/src/email-templates/reminders.email-template.ts new file mode 100644 index 0000000..50bf480 --- /dev/null +++ b/src/email-templates/reminders.email-template.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineEmailTemplateDefinition } from '@objectstack/spec'; + +/** + * The notification text of the three owner-facing reminder sweeps + * (`src/flows/reminders.flow.ts`), as `sys_email_template` bundles. + * + * ── Why the text lives here and not on the notify node ─────────────────── + * A `notify` node has exactly two content paths and `NotifyConfigSchema`'s + * `superRefine` makes them mutually exclusive: + * + * - inline `title` + `message` — sent to every recipient verbatim. The + * schema's own `.describe()` calls this "not localizable". + * - `template` — names a bundle here; the delivery path resolves + * `(name, locale)` per delivery and renders subject/body from the row it + * picks. + * + * AGENTS.md §8 ("English is the source language … do not hard-code display + * text in a hook or flow") is only satisfiable on the second path, so the + * three sweeps' strings live here. Each name carries TWO rows — `en` and + * `zh-CN` — which is what a template "bundle" is: same `name`, one row per + * locale, exactly the two locales `objectstack.config.ts` declares in + * `i18n.supportedLocales`. + * + * ── Why `{{{…}}}` in `subject` / `bodyText` and `{{…}}` in `bodyHtml` ──── + * `renderTemplate` HTML-escapes a `{{hole}}` and leaves a `{{{hole}}}` raw + * (`template-engine.ts`). Measured on `@objectstack/plugin-email` 17.2.0 with + * `subject: "Owner's monthly return"`: + * + * subject: '{{subject}}' → Owner's monthly return + * subject: '{{{subject}}}' → Owner's monthly return + * + * The inbox channel writes the rendered SUBJECT into `sys_inbox_message.title` + * and the rendered TEXT into `body_md` — neither is an HTML document — so the + * escaping form would put entities on the screen for any duty whose subject + * contains an apostrophe or an `&`, which the inline `title: '{record.subject}'` + * it replaces never did. `bodyHtml` IS markup and keeps the escaping form. + * `test/email-templates.test.ts` pins both halves. + * + * ── `bodyText` is authored, not derived ────────────────────────────────── + * With `bodyText` omitted the service derives the text alternative by + * stripping tags from `bodyHtml`. The inbox body is the DERIVED string, so + * leaving it out would make the in-app notification a side effect of the HTML + * markup. It is authored so the body the owner reads is the string written + * here — the same sentence the inline `message` sent before this card. + * + * ── `variables` ───────────────────────────────────────────────────────── + * `required: true` is enforced at render (`requireVars` → `MISSING_VARIABLES`, + * which the inbox channel classifies as PERMANENT — a dead delivery, not a + * retry). So it is declared only where the object itself already guarantees + * the value: `duly_task.subject` is `required: true`. `due_date` is NOT + * required on the object, and a reminder that says "Due ." is worth more to + * its owner than a reminder that was never delivered — which is exactly what + * a required-but-absent hole would produce. + */ + +/** Shared by all three: the task's own subject line, rendered unescaped. */ +const SUBJECT_LINE = '{{{subject}}}'; + +/** Declared render inputs. Kept in one place so the six rows cannot drift. */ +const REMINDER_VARIABLES = [ + { + name: 'subject', + type: 'string' as const, + required: true, + description: "The task's subject — duly_task.subject, required on the object.", + }, + { + name: 'due_date', + type: 'date' as const, + required: false, + description: 'The task due date as stored (ISO-8601). Optional on duly_task.', + }, +]; + +// ─── 1 · Lead-time reminder ────────────────────────────────────────────── + +export const TaskLeadTimeReminderEn = defineEmailTemplateDefinition({ + name: 'duly.task_lead_time', + label: 'Task lead-time reminder', + category: 'notification', + locale: 'en', + subject: SUBJECT_LINE, + bodyHtml: '
This is now on your list. Due {{due_date}}.
', + bodyText: 'This is now on your list. Due {{{due_date}}}.', + variables: REMINDER_VARIABLES, + description: "Sent once, on the day a task crosses visible_from, to the task's owner.", +}); + +export const TaskLeadTimeReminderZhCN = defineEmailTemplateDefinition({ + name: 'duly.task_lead_time', + label: '任务提前提醒', + category: 'notification', + locale: 'zh-CN', + subject: SUBJECT_LINE, + bodyHtml: '这项任务已进入你的待办列表,截止日期 {{due_date}}。
', + bodyText: '这项任务已进入你的待办列表,截止日期 {{{due_date}}}。', + variables: REMINDER_VARIABLES, + description: '任务到达 visible_from 当天,向负责人发送一次。', +}); + +// ─── 2 · Due-soon reminder ─────────────────────────────────────────────── + +export const TaskDueSoonReminderEn = defineEmailTemplateDefinition({ + name: 'duly.task_due_soon', + label: 'Task due-soon reminder', + category: 'notification', + locale: 'en', + subject: SUBJECT_LINE, + bodyHtml: 'Due in 2 days, on {{due_date}}.
', + bodyText: 'Due in 2 days, on {{{due_date}}}.', + variables: REMINDER_VARIABLES, + description: "Sent once, two days before a task is due, to the task's owner.", +}); + +export const TaskDueSoonReminderZhCN = defineEmailTemplateDefinition({ + name: 'duly.task_due_soon', + label: '任务即将到期提醒', + category: 'notification', + locale: 'zh-CN', + subject: SUBJECT_LINE, + bodyHtml: '还有 2 天到期,截止日期 {{due_date}}。
', + bodyText: '还有 2 天到期,截止日期 {{{due_date}}}。', + variables: REMINDER_VARIABLES, + description: '任务到期前两天,向负责人发送一次。', +}); + +// ─── 3 · Overdue escalation, stage one ─────────────────────────────────── + +export const TaskOverdueEscalationEn = defineEmailTemplateDefinition({ + name: 'duly.task_overdue', + label: 'Task overdue escalation — owner', + category: 'notification', + locale: 'en', + subject: SUBJECT_LINE, + bodyHtml: 'Past due since {{due_date}}.
', + bodyText: 'Past due since {{{due_date}}}.', + variables: REMINDER_VARIABLES, + description: + "Sent once, on the first day a task is past due_date plus the duty's grace, to the task's owner.", +}); + +export const TaskOverdueEscalationZhCN = defineEmailTemplateDefinition({ + name: 'duly.task_overdue', + label: '任务逾期升级提醒 — 负责人', + category: 'notification', + locale: 'zh-CN', + subject: SUBJECT_LINE, + bodyHtml: '自 {{due_date}} 起已逾期。
', + bodyText: '自 {{{due_date}}} 起已逾期。', + variables: REMINDER_VARIABLES, + description: '任务超过截止日期加宽限期的第一天,向负责人发送一次。', +}); + +/** + * Everything in this file, in the same order as `dulyReminderFlows`, so the + * barrel spreads one name and the pairing with `reminders.flow.ts` stays + * readable: one flow, one template name, two locale rows. + */ +export const dulyReminderEmailTemplates = [ + TaskLeadTimeReminderEn, + TaskLeadTimeReminderZhCN, + TaskDueSoonReminderEn, + TaskDueSoonReminderZhCN, + TaskOverdueEscalationEn, + TaskOverdueEscalationZhCN, +]; diff --git a/src/flows/reminders.flow.ts b/src/flows/reminders.flow.ts index 0d31559..977942c 100644 --- a/src/flows/reminders.flow.ts +++ b/src/flows/reminders.flow.ts @@ -115,8 +115,8 @@ import { defineFlow } from '@objectstack/spec'; * above compose safely. Predicates written out in full may keep using P`…`; * the rule is only that a `${…}` hole in P is a value, never CEL. * - * ── Two platform gaps this file does NOT work around ───────────────────── - * **1. Digest by recipient is not authorable.** The manager-facing flows are + * ── The one platform gap this file does NOT work around ────────────────── + * **Digest by recipient is not authorable.** The manager-facing flows are * absent because the node vocabulary cannot express "one message per manager * listing their N tasks": * - there is no aggregate / group-by node, so records can only be bucketed @@ -133,16 +133,27 @@ import { defineFlow } from '@objectstack/spec'; * satisfied "one message, not thirty" while quietly dropping "listing 30", and * that is the kind of workaround that makes a platform gap permanent. * - * **2. Notification text cannot be localized in this app.** The localizable - * `notify` path is `template`, naming a `sys_email_template` bundle; - * `defineStack` accepts an `emailTemplates` collection but this app wires no - * barrel for one, and `objectstack.config.ts` is not editable from a feature - * branch. So the inline `title`/`message` below are the only content path - * available, and they are the explicitly NON-localizable one — a declared - * deviation from the house rule "do not hard-code display text in a flow" - * (AGENTS.md §8), not an oversight. English is the source language, so the - * strings are at least the right source text; they are simply untranslatable - * until the app has an email-template barrel. + * (A second gap stood here — "notification text cannot be localized in this + * app" — declaring the inline `title`/`message` on the three notify nodes as + * a deviation from AGENTS.md §8. Both are gone: the app wires + * `src/email-templates/`, the three sweeps name a `template`, and no display + * text is authored in this file at all.) + * + * ── Where the notification text lives ──────────────────────────────────── + * In `src/email-templates/reminders.email-template.ts`, one bundle per sweep, + * two `(name, locale)` rows each (`en`, `zh-CN`). The node carries only the + * bundle NAME plus `templateData`, and the two content paths are mutually + * exclusive by schema — `template` alongside `title`/`message` is a parse + * error, not a precedence rule — so re-adding a string here is refused at the + * gate rather than silently winning over the bundle. + * + * `template` is read RAW (no `{token}` interpolation): it is a static metadata + * cross-reference. `templateData` VALUES are interpolated per run, which is + * how `{record.subject}` still reaches the rendered text. A name that matches + * no row is the one silent failure this swap introduces — the inbox channel + * classifies `TEMPLATE_NOT_FOUND` as PERMANENT, so the delivery dead-letters + * and the run still reports success. `test/email-templates.test.ts` pins every + * `template` on every notify node against the barrel for exactly that reason. */ // ─── Shared authoring constants ────────────────────────────────────────── @@ -253,6 +264,26 @@ const DUTY_GRACE = /** Days elapsed since the due date. Positive once the task is late. */ const DAYS_PAST_DUE = 'daysBetween(record.due_date, today())'; +/** + * Render context handed to every reminder bundle — the `{{var}}` holes the + * three templates declare. + * + * One constant for all three because the three bundles declare the SAME two + * variables; a per-flow copy would let one drift into passing a hole its + * template does not read (rendered as nothing, silently) or omitting one it + * does. The keys are the template's variable names; the values are flow + * templates resolved per run against the swept record. + * + * `due_date` is deliberately passed raw rather than pre-formatted: the + * rendering side owns presentation per locale, and a value formatted here + * would be formatted once, in one language, for every recipient — which is + * the property this whole card exists to remove. + */ +const TASK_TEMPLATE_DATA = { + subject: '{record.subject}', + due_date: '{record.due_date}', +} as const; + // ─── 1 · Lead-time reminder — the task appears on the owner's list ─────── /** @@ -323,8 +354,11 @@ export const LeadTimeReminder = defineFlow({ label: 'Tell the owner', config: { recipients: '{record.owner}', - title: '{record.subject}', - message: 'This is now on your list. Due {record.due_date}.', + // The localizable content path. `template` names the bundle in + // src/email-templates/; `templateData` is its render context and IS + // interpolated per run, so the record still supplies the values. + template: 'duly.task_lead_time', + templateData: TASK_TEMPLATE_DATA, severity: 'info', topic: 'duly.task_lead_time', // The pair only takes effect together; a half-specified target is @@ -425,8 +459,8 @@ export const DueSoonReminder = defineFlow({ label: 'Tell the owner', config: { recipients: '{record.owner}', - title: '{record.subject}', - message: 'Due in 2 days, on {record.due_date}.', + template: 'duly.task_due_soon', + templateData: TASK_TEMPLATE_DATA, severity: 'info', topic: 'duly.task_due_soon', sourceObject: 'duly_task', @@ -535,8 +569,8 @@ export const OverdueOwnerEscalation = defineFlow({ label: 'Tell the owner it is late', config: { recipients: '{record.owner}', - title: '{record.subject}', - message: 'Past due since {record.due_date}.', + template: 'duly.task_overdue', + templateData: TASK_TEMPLATE_DATA, severity: 'warning', topic: 'duly.task_overdue', sourceObject: 'duly_task', diff --git a/test/email-templates.test.ts b/test/email-templates.test.ts new file mode 100644 index 0000000..cdd71c7 --- /dev/null +++ b/test/email-templates.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AppPlugin, ObjectKernel, createStandaloneStack } from '@objectstack/runtime'; +import { AutomationServicePlugin } from '@objectstack/service-automation'; +import { JobServicePlugin } from '@objectstack/service-job'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; +import { EmailServicePlugin } from '@objectstack/plugin-email'; +import { TimeRelativeTriggerPlugin } from '@objectstack/trigger-schedule'; + +import stack from '../objectstack.config.js'; +import { dulyEmailTemplates } from '../src/email-templates/index.js'; +import { dulyFlows } from '../src/flows/index.js'; + +/** + * The reminder sweeps' notification text, as `sys_email_template` bundles. + * + * Two things are pinned here and nothing else in the toolchain pins either: + * + * - **The cross-reference.** A notify node's `template` is read RAW and is a + * metadata cross-reference nothing resolves at author time. `pnpm validate` + * parses the bundles (measured: an unknown key inside one takes validate to + * exit 1 naming the key) but never checks that the NAME on a notify node + * matches one, so a typo is caught by no gate. At runtime the inbox channel + * classifies `TEMPLATE_NOT_FOUND` as PERMANENT — the delivery dead-letters + * while the flow run still reports success. That is strictly worse than the + * inline English this card replaced, and it is invisible. + * + * - **That the notification still arrives, with the right words in it.** The + * end-to-end leg below boots a real kernel with the real automation, job, + * messaging and email services, drives the REAL `TimeRelativeTrigger` + * sweep, and reads the row the inbox channel wrote. The assertion is on the + * resolved BODY, not on "a notification was emitted": a template that + * resolves to nothing emits a notification too. + * + * ── Why the plugin packages are devDependencies of this app ────────────── + * `email`, `messaging` and `job` are in `PLATFORM_ALWAYS_ON_CAPABILITIES` and + * `triggers` is declared in `objectstack.config.ts`, so `objectstack dev` + * mounts all five of these for real — but it is the CLI that owns that list, + * and `createStandaloneStack()` returns only the datasource/metadata/ObjectQL + * base. Mounting them here is what makes this suite exercise the delivery path + * the app actually runs on. They are dev-only: nothing in `src/` imports them. + */ + +type AnyRec = Record