Skip to content

Commit a0c68ae

Browse files
feat(approvals,messaging): approval requests, reminders and outcomes also go by e-mail, linking to the request page (#30567)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg86P83jwb8r8DKyzSynqPiY)_ Fixes #30509 domain:server#6 · os-bill · session_01Utp6CXCS98uhsFQYk64epE Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none — `git grep` of `skills/**` for approval notification / approval e-mail / channels / `registerBuiltinTemplates` / notification template returns 0 hits on `b8ab4808`; no metadata an author writes changes ## 维护者速读 - 改了什么:审批的四种提醒(有新审批、催办、通过、驳回)除了站内信,现在同时发一封邮件。邮件标题和正文就是站内信那段话,末尾多一行「打开审批」链接,直接进应用里的审批页。站内信一个字没变,其他审批提醒仍只发站内信。 - 为什么改:cloud#2549 裁定 B 的第 ② 项。裁决第 2 条两处落点均撞约束,按 #30509 裁定走第三处落点:在消息服务里加一级「代码内置模板」,不写进数据库,组织自己在 Setup 写的模板优先。 - 风险与回滚:邮件链接要用部署地址。没配 OS_BASE_URL 或 OS_AUTH_URL 时链接退回相对路径,启动时警告一次,不会编造地址。没装邮件服务的环境里,每条审批提醒会多一行「email 渠道未注册」的告警,投递照旧走站内信。回滚就是 revert 本 PR,没有数据要迁。 - 先看:`packages/services/service-messaging/src/template-renderer.ts`(模板仓的逐级查找:同一级组织行优先,内置其次);`packages/plugins/plugin-approvals/src/approval-service.ts` 的 `notify()`(四个主题请求 inbox+email,只有这四个带 `approvalUrl`);`packages/plugins/plugin-approvals/src/public-base-url.ts`(地址从哪来,为什么不取 localhost)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-approvals exec vitest run src/approval-email-notification.integration.test.ts`,预期 5 个全绿。发起、催办、通过、驳回各有一行 `email` 投递记录且状态为 success,邮件末行是 `Open the approval: https://app.example.test/system/approvals?request=...`。 - 需要你做的:无,席位落地。 ## What changes Approval notices for the request's own lifecycle now go by e-mail as well as to the in-app inbox. The ruling on objectstack-ai/cloud#2549 is **B** (「B 只让租户自配邮箱」), and this is its item ②. 「裁决第 2 条两处落点均撞约束,按 #30509 裁定走第三处落点」: a seeded `sys_notification_template` row is organization-owned and ADR-0131 §1.4 rules out seeding one per organization, and a plugin-email template named through `payload.template` would also re-render the inbox (`inbox-channel.ts` reads `payload.template` too) and fail the inbox delivery on a kernel with no email service. The ruling recorded on #30509 (comment 6660948916) chose a third location, a code-declared template tier in messaging, with fixed parameters. This PR builds it. - **`@objectstack/plugin-approvals`** - `notify()` asks for `channels: ['inbox', 'email']` on `approval.requested`, `approval.reminder`, `approval.approved` and `approval.rejected`, in one emit. Every other `approval.*` topic still names no channel, so it stays inbox-only. Delivery preferences filter the e-mail as they filter any channel. The payload carries no `template`, so the inbox renders exactly as before. - Those four notices also carry a new payload member, `approvalUrl`: the same request page as `actionUrl` (`/system/approvals?request=ID`), under the deployment's public origin. `actionUrl` stays relative because the Console routes on it. `approvalUrl` opens the request page for a signed-in reader and decides nothing. It is not an ADR-0043 action link and holds no token. - `approval-email-templates.ts` declares the e-mail for each of the four topics in `en`, `zh-CN`, `ja-JP` and `es-ES` (the approval catalog's locales). The subject is `{{title}}`. The body is `{{body}}`, a blank line, then one localized line with `{{approvalUrl}}`. Format `text`. The plugin registers them at start through `messaging.registerBuiltinTemplates` when the messaging service offers it (`ApprovalMessagingSurface` gains the optional member and `channels`). - `public-base-url.ts`: an unset `publicBaseUrl` option resolves from `OS_AUTH_URL` (legacy `BETTER_AUTH_URL`), then `OS_BASE_URL`, with `??` as plugin-email's template `baseUrl` reads them. ⛔ It never falls back to a guessed `http://localhost:PORT`. With nothing set, or a value that is not an absolute `http(s)` URL, links stay relative and the plugin warns once at start, naming the variables. The composition point is `ApprovalsServicePlugin.start` in `approvals-plugin.ts`, inside `plugin-approvals`. - **`@objectstack/service-messaging`** - `NotificationTemplateStore` reads a code-declared tier (`BuiltinNotificationTemplates`) at every rung of its existing locale ladder: the organization's row at that rung, else the built-in at that rung, else the next rung, else the generic title and body. So an organization that authors a row overrides a built-in with nothing to migrate. A delivery with no organization, or a store with no engine, still reads the built-in (it is no organization's). A failed row read still answers `null`, never a built-in in place of a row it could not see. - `MessagingService.registerBuiltinTemplates(templates)` is the seam, and `MessagingServicePlugin` hands the service's tier to the email and SMS channels' shared store. A malformed template, or a `(topic, channel, locale)` already declared, is refused with a `TypeError`, and nothing from that call is kept. `BuiltinNotificationTemplate` is exported as a type. The wiring key is module-private (`TEMPLATE_BUILTINS`), like the declared fallback locale, so `NotificationTemplateStoreOptions` is unchanged. - No `packages/spec` change: approvals reaches messaging through its own local `ApprovalMessagingSurface`. `email-channel.ts`, `email-plugin.ts`, `mail.manifest.ts` and `packages/rest/**` are untouched (#30510 owns the channel's "not configured" answer). Files: `packages/plugins/plugin-approvals/src/{approval-service.ts, approvals-plugin.ts, approval-email-templates.ts, public-base-url.ts}`, `packages/services/service-messaging/src/{template-renderer.ts, messaging-service.ts, messaging-service-plugin.ts, index.ts}`, their tests, one changeset. ## Tests Run at head `9751540c` (the branch with `origin/main` merged at `b8ab4808`) unless a line says otherwise. - **New: `approval-email-notification.integration.test.ts`** (plugin-approvals). It runs the real composition: a real `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory), the real `MessagingServicePlugin` with its durable outbox and dispatcher, the real `ApprovalsServicePlugin`, `OS_BASE_URL` set, and an `email` service whose transport records what it is handed. - Opening a step leaves an `email` delivery row in `success` beside the `inbox` one. The sent message's subject is the inbox title, and its text is the inbox body plus `Open the approval: https://app.example.test/system/approvals?request=ID`. The inbox row keeps the relative `action_url`. - The reminder, the approval and the rejection (with the decider's comment) each do the same for their recipient. - A comment (`approval.comment`) stays inbox-only. - ⛔ No stored row of any table, and no message sent, matches the ADR-0043 action-link shape (the regex `reminder-action-link-at-rest` uses). This is not vacuous: three mails went, and the stored notification rows carry the request link. - **Red on main:** the new file run against `origin/main`'s sources at `1fd0c32c` (the six changed source files restored from that commit in the tree, then `git checkout HEAD --` them back, `git diff HEAD` empty): 4 failed, 1 passed. The inbox-only control is the one that passes. - **New messaging pins** in the existing harness files, as top-level `describe` blocks: `template-renderer.test.ts` (the tier's ladder, overrides, the no-organization and no-engine reads, failed-read `null`, and every registration refusal) and `template-declared-fallback.test.ts` (the plugin composition: the email channel renders a template registered after `kernel:ready`, and an organization row overrides it). - **New unit pins:** `public-base-url.test.ts` and `approval-email-templates.test.ts` (four topics × the catalog's four locales, holes exactly `body` then `approvalUrl`). - **Pins updated for the new payload member.** Each adds `approvalUrl` beside the relative `actionUrl` on one of the four emailed topics. None loosens a token or link assertion: - `approval-service.test.ts`: the remind pin also asserts `channels: ['inbox','email']`; - `approval-notification-body.integration.test.ts`: the declared-field set is per topic, with `approvalUrl` declared only for the emailed four; - `approval-requested-notification.integration.test.ts`; - `approval-decision-outcome-notification.integration.test.ts` (two pins); - `reminder-action-link-at-rest.integration.test.ts`: the exact-payload assertion gains `approvalUrl`. Its storage scan, positive control and no-mint assertions are unchanged and green. - **ADR-0043 `actionLinkUrl` pins:** none. `git grep` finds `actionLinkUrl` only at its definition in `approval-service.ts`, with no caller and no test asserting its relative shape. Wiring the address makes it absolute when an origin resolves, which is what its option always documented. - `pnpm --filter @objectstack/plugin-approvals test`: 114 files, 1482 tests passed. `typecheck` (with `check:test-typecheck`): exit 0. - `pnpm --filter @objectstack/service-messaging test`: 66 files, 828 tests passed. `typecheck`: exit 0. (Run at `b8ab4808`. The later commit touches only plugin-approvals files.) - **Ablation** (one time, not committed). With `scripts/ablation-replace.mjs`, the `channels` request in `notify()` was disabled (anchor hit once). The new integration file went 4 failed, 1 passed. The file was restored to the HEAD blob `a372f16a` and `git diff HEAD` was empty. plugin-approvals' vitest aliases `@objectstack/service-messaging` to source, so no build sits between the mutation and the run. ## Acceptance notes - **No mail transport.** On a kernel with no `email` service the `email` channel is not mounted, so messaging refuses that leg as it refuses any unregistered channel. It writes no row, reports a failed outcome to the caller, and logs one `warn` per emit (`channel 'email' is not registered`). The inbox delivery is unaffected. The honest "not configured" reading for a mounted channel over a LogTransport is #30510's, and `email-channel.ts` is not touched here. - **The docs** (`content/docs/automation/approvals.mdx`) describe these notices without naming a channel, so no sentence there became false. A sentence saying they also go by e-mail, and that `OS_BASE_URL` makes the link absolute, would help a reader. It is outside "sentences this PR makes false", so it is left for the seat. - **Locale coverage:** built-ins are keyed by the catalog's tags (`en`, `zh-CN`, `ja-JP`, `es-ES`). A recipient whose locale is a bare `zh` walks `zh`, then the declared fallback, then `en`, so the link line reads in English while the body may be Chinese. That is the same ladder rows already walk. - **Order of the body's first lines:** the dispatch order puts the signature line before `Fixes`. `.claude/agents/os-dev.md` (which wins on conflict) puts `Fixes` immediately after the attribution block. This body follows os-dev. ## Verification <details><summary>Derived gates (dispatch-gates --commands, 64 commands, at 9751540)</summary> All 64 commands that `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives for this diff exit 0 at head `9751540c`. Each exit code was captured before any pipe. `dispatch-gates --ran` reconciles them: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - Commands 1 to 34 were run again in full after the last commit, so their readings are from `9751540c`. - Commands 35 to 64 ran after that commit landed. - Three scans timed out at a 240 s cap on the first pass and exited 0 when re-run with no cap: `check-comment-mask-corpus` (20837 files, 0 disagree), `check:query-options-erasure` (ratchet holds, none new) and `check:slot-lookup` (ratchet holds, none new). - Two gates first answered PREREQUISITE NOT MET (exit 3). `check:i18n` (10 packages in sync) exited 0 once its printed build closure was built. `check-plugin-teardown-shape --self-test` exited 0 once its pinned fixture commit was fetched into this shallow clone. Neither 3 was a finding. Changed-file eslint (`eslint --no-inline-config` over every changed `.ts` file): exit 0. The repo-wide `pnpm lint` belongs to CI's `Lint & Repo Gates`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CX9BC4BTFLAJJaaAHh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c133cfe commit a0c68ae

19 files changed

Lines changed: 953 additions & 22 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
'@objectstack/service-messaging': patch
4+
---
5+
6+
feat(approvals,messaging): approval requests, reminders and outcomes also go by e-mail, linking to the request page; messaging gains a code-declared notification template tier
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/plugin-approvals`: `approval.requested`, `approval.reminder`, `approval.approved` and `approval.rejected` request the `inbox` and `email` channels** in one emit. Before, every approval notice named no channel, so messaging delivered it to the in-app inbox alone and nobody who does not watch the Console bell heard of it. Every other `approval.*` topic stays inbox-only, and delivery preferences filter the e-mail as they filter any channel. The inbox is unchanged. On a deployment with no mail transport the e-mail is whatever the `email` channel answers there.
12+
- **The approval e-mail** is the notice's own title as its subject and its body (in the recipient's language, as the inbox renders it) followed by one line that opens the request, in `en`, `zh-CN`, `ja-JP` and `es-ES`. The link is a new payload member, `approvalUrl`: the request page (`/system/approvals?request=ID`) under the deployment's public origin. `actionUrl` stays relative, as the Console routes on it. It opens the request for a signed-in reader and decides nothing; no ADR-0043 action link or token is in any payload.
13+
- **`publicBaseUrl` now resolves when the option is unset**: `OS_AUTH_URL` (legacy `BETTER_AUTH_URL`), then `OS_BASE_URL`, never a guessed `localhost`. With none of them set, or a value that is not an absolute `http(s)` URL, links stay relative and the plugin warns once at start, naming the variable to set. The same origin also prefixes ADR-0043's `actionLinkUrl`, as its option always documented. To give approval e-mails a working link, set `OS_BASE_URL` (or `OS_AUTH_URL`) to the deployment's address.
14+
- **`@objectstack/service-messaging`: `MessagingService.registerBuiltinTemplates(templates)`** (and the `BuiltinNotificationTemplate` type) declares `(topic, channel, locale)` notification templates in code. The channels' template store reads them at every rung of its locale ladder after the recipient organization's own `sys_notification_template` row: the rung's row, else the rung's built-in, else the next rung, else the generic title and body. Nothing is written to the database (ADR-0131 §1.4), so an organization that authors a row overrides the built-in with nothing to migrate. A malformed template, or a `(topic, channel, locale)` already declared, is refused with a `TypeError`, and nothing from that call is kept.

‎packages/plugins/plugin-approvals/src/approval-decision-outcome-notification.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ describe('the decision that ends an approval tells its submitter (approval.appro
236236
title: 'Request approved',
237237
body: 'Your Deal won was approved.',
238238
actionUrl: `/system/approvals?request=${encodeURIComponent(req.id)}`,
239+
// [#30509] The approval e-mail's link (relative: no public origin here).
240+
approvalUrl: `/system/approvals?request=${encodeURIComponent(req.id)}`,
239241
});
240242
expect(told[0].source).toEqual({ object: 'sys_approval_request', id: req.id });
241243
expect(told[0].actorId).toBe('u_alice');
@@ -253,6 +255,8 @@ describe('the decision that ends an approval tells its submitter (approval.appro
253255
title: 'Request rejected',
254256
body: `Your Deal lost was rejected.\n\n${REJECT_NOTE}`,
255257
actionUrl: `/system/approvals?request=${encodeURIComponent(req.id)}`,
258+
// [#30509] The approval e-mail's link (relative: no public origin here).
259+
approvalUrl: `/system/approvals?request=${encodeURIComponent(req.id)}`,
256260
});
257261
expect(told[0].actorId).toBe('u_alice');
258262
});
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30509] An approval's own notices also go by e-mail, linking to the
5+
* request page (objectstack-ai/cloud#2549 ruling B, item ②; the template's
6+
* location is the ruling recorded on #30509).
7+
*
8+
* ## The defect
9+
*
10+
* `notify()` emitted with no `channels`, and messaging defaults that to the
11+
* in-app inbox alone, so an approval request, its reminder and its outcome
12+
* reached nobody who does not watch the Console bell.
13+
*
14+
* ## What this pins
15+
*
16+
* The real composition, end to end: a real `ObjectQL` over `SqlDriver`
17+
* (better-sqlite3, in-memory, real DDL), the real `MessagingServicePlugin`
18+
* with its durable outbox and dispatcher, the real `ApprovalsServicePlugin`,
19+
* and an `email` service whose transport records what it is handed — a
20+
* configured (test) transport. The deployment's address comes from
21+
* `OS_BASE_URL`, as an unconfigured `publicBaseUrl` option resolves it.
22+
*
23+
* - opening a step, the submitter's reminder and the outcome each leave an
24+
* `email` delivery row that reached `success` beside the `inbox` one, and
25+
* a sent message whose subject is the notice's title and whose text is its
26+
* body plus the request page's ABSOLUTE link;
27+
* - the inbox row still links relatively (`actionUrl`), as the Console routes;
28+
* - ⛔ no stored row of any table, and no message sent, holds an ADR-0043
29+
* action link or token (#24564's storage axis, the same reading
30+
* `reminder-action-link-at-rest` takes);
31+
* - an approval topic outside the four (a comment) stays inbox-only.
32+
*/
33+
34+
import { describe, it, expect, afterEach, vi } from 'vitest';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { MessagingServicePlugin } from '@objectstack/service-messaging';
38+
import type { ApprovalService } from './approval-service.js';
39+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
40+
import { SysApprovalRequest } from './sys-approval-request.object.js';
41+
import { SysApprovalAction } from './sys-approval-action.object.js';
42+
import { SysApprovalApprover } from './sys-approval-approver.object.js';
43+
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
44+
import { SysApprovalToken } from './sys-approval-token.object.js';
45+
import { installDefaultOrganization } from './default-organization.testkit.js';
46+
47+
const OBJECT = 'mailrest_invoice';
48+
const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any;
49+
const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any;
50+
const SUBMITTER = 'u_submitter';
51+
const APPROVER = 'u_approver';
52+
const ORIGIN = 'https://app.example.test';
53+
/** What an ADR-0043 action-page link looks like in any spelling a row could hold it. */
54+
const ACTION_LINK = /\/approvals\/act|[?&]token=/;
55+
56+
interface Rig {
57+
engine: ObjectQL;
58+
driver: SqlDriver;
59+
svc: ApprovalService;
60+
sent: Array<Record<string, any>>;
61+
requestId: string;
62+
stop: () => Promise<void>;
63+
}
64+
65+
let rig: Rig | undefined;
66+
67+
afterEach(async () => {
68+
try { await rig?.stop(); } catch { /* noop */ }
69+
rig = undefined;
70+
vi.unstubAllEnvs();
71+
});
72+
73+
async function boot(): Promise<Rig> {
74+
vi.stubEnv('OS_AUTH_URL', undefined);
75+
vi.stubEnv('BETTER_AUTH_URL', undefined);
76+
vi.stubEnv('OS_BASE_URL', ORIGIN);
77+
78+
const engine = new ObjectQL();
79+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
80+
engine.registerDriver(driver, true);
81+
await engine.init();
82+
83+
const sysUser = {
84+
name: 'sys_user',
85+
label: 'User',
86+
fields: {
87+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
88+
name: { name: 'name', label: 'Name', type: 'text' as const },
89+
email: { name: 'email', label: 'Email', type: 'text' as const },
90+
locale: { name: 'locale', label: 'Locale', type: 'text' as const },
91+
},
92+
};
93+
const invoice = {
94+
name: OBJECT,
95+
label: 'Invoice',
96+
fields: {
97+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
98+
title: { name: 'title', label: 'Title', type: 'text' as const },
99+
},
100+
};
101+
const register = (def: any) => engine.registry.registerObject(def, 'approvals-email-test', 'approvals-email-test');
102+
for (const def of [sysUser, invoice, SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalDelegation, SysApprovalToken]) {
103+
register(def);
104+
}
105+
106+
const sent: Array<Record<string, any>> = [];
107+
const logger = { info: () => {}, debug: () => {}, warn: () => {}, error: () => {}, child: () => logger };
108+
const services: Record<string, unknown> = {
109+
objectql: engine,
110+
data: engine,
111+
// The messaging plugin declares its objects through the manifest service.
112+
manifest: { register: (m: any) => { for (const def of m.objects ?? []) register(def); } },
113+
// A configured transport: what it is handed is what was sent.
114+
email: { async send(input: any) { sent.push(input); return { id: `mail_${sent.length}` }; } },
115+
};
116+
const readyHooks: Array<() => Promise<void> | void> = [];
117+
let collectReady = true;
118+
const ctx: any = {
119+
getService: (name: string) => {
120+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
121+
return services[name];
122+
},
123+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
124+
hook: (event: string, fn: () => Promise<void> | void) => { if (collectReady && event === 'kernel:ready') readyHooks.push(fn); },
125+
logger,
126+
};
127+
128+
const messagingPlugin = new MessagingServicePlugin({ dispatchIntervalMs: 10, dispatchMaxIdleIntervalMs: 20 });
129+
await messagingPlugin.init(ctx);
130+
await engine.syncSchemas();
131+
await installDefaultOrganization(engine);
132+
for (const fn of readyHooks) await fn();
133+
collectReady = false;
134+
135+
for (const [id, name] of [[SUBMITTER, 'Sam Submitter'], [APPROVER, 'Ada Approver']]) {
136+
await engine.insert('sys_user', { id, name, email: `${id}@example.com`, locale: 'en' }, { context: SYSTEM } as any);
137+
}
138+
await engine.insert(OBJECT, { id: 'inv_1', title: 'March invoice' }, { context: SYSTEM } as any);
139+
140+
await new ApprovalsServicePlugin().start(ctx);
141+
const svc = services.approvals as ApprovalService;
142+
143+
const opened: any = await svc.openNodeRequest({
144+
object: OBJECT, recordId: 'inv_1', runId: 'run_1', nodeId: 'signoff', flowName: 'mail_signoff',
145+
config: { approvers: [{ type: 'user', value: APPROVER }], behavior: 'first_response' } as any,
146+
submitterId: SUBMITTER, record: { id: 'inv_1', title: 'March invoice' },
147+
}, asUser(SUBMITTER));
148+
149+
return {
150+
engine, driver, svc, sent, requestId: String(opened.id),
151+
stop: async () => {
152+
await messagingPlugin.destroy?.();
153+
await engine.destroy();
154+
},
155+
};
156+
}
157+
158+
async function deliveries(r: Rig, topic: string): Promise<Array<Record<string, any>>> {
159+
return (await r.driver.getKnex()('sys_notification_delivery').where({ topic }).select('*')) as Array<Record<string, any>>;
160+
}
161+
162+
/** The topic's deliveries once every one of them has left the outbox's queue. */
163+
async function settled(r: Rig, topic: string): Promise<Array<Record<string, any>>> {
164+
let rows: Array<Record<string, any>> = [];
165+
await vi.waitFor(async () => {
166+
rows = await deliveries(r, topic);
167+
expect(rows.length).toBeGreaterThan(0);
168+
expect(rows.filter((d) => d.status === 'pending' || d.status === 'in_flight')).toEqual([]);
169+
}, { timeout: 5000, interval: 25 });
170+
return rows;
171+
}
172+
173+
const mailTo = (r: Rig, userId: string, subject: string) =>
174+
r.sent.filter((m) => m.to === `${userId}@example.com` && m.subject === subject);
175+
176+
describe('approval notices also go by e-mail, linking to the request page (#30509)', () => {
177+
it('opening a step e-mails each approver: an email delivery row, the title, the body and the absolute link', async () => {
178+
rig = await boot();
179+
const link = `${ORIGIN}/system/approvals?request=${encodeURIComponent(rig.requestId)}`;
180+
181+
const rows = await settled(rig, 'approval.requested');
182+
expect(rows.map((d) => `${d.channel}:${d.recipient_id}:${d.status}`).sort()).toEqual([
183+
`email:${APPROVER}:success`,
184+
`inbox:${APPROVER}:success`,
185+
]);
186+
187+
const mails = mailTo(rig, APPROVER, 'Approval requested');
188+
expect(mails).toHaveLength(1);
189+
const text = String(mails[0].text);
190+
// The inbox keeps the relative deep link the Console routes on, and the
191+
// e-mail says what the inbox says, then links to the same request.
192+
const inbox = (await rig.driver.getKnex()('sys_inbox_message').where({ user_id: APPROVER }).select('*')) as any[];
193+
expect(inbox.map((m) => m.action_url)).toEqual([`/system/approvals?request=${encodeURIComponent(rig.requestId)}`]);
194+
expect(inbox[0].title).toBe('Approval requested');
195+
expect(inbox[0].body_md).toMatch(/^A decision on Invoice .+ is waiting on you\.$/);
196+
expect(text).toBe(`${inbox[0].body_md}\n\nOpen the approval: ${link}`);
197+
});
198+
199+
it("the submitter's reminder and the outcome go by e-mail too, with the same link", async () => {
200+
rig = await boot();
201+
const link = `${ORIGIN}/system/approvals?request=${encodeURIComponent(rig.requestId)}`;
202+
await settled(rig, 'approval.requested');
203+
204+
await rig.svc.remind(rig.requestId, { actorId: SUBMITTER }, asUser(SUBMITTER));
205+
const reminders = await settled(rig, 'approval.reminder');
206+
expect(reminders.map((d) => `${d.channel}:${d.recipient_id}:${d.status}`).sort()).toEqual([
207+
`email:${APPROVER}:success`,
208+
`inbox:${APPROVER}:success`,
209+
]);
210+
const reminderMail = mailTo(rig, APPROVER, 'Approval reminder');
211+
expect(reminderMail).toHaveLength(1);
212+
expect(String(reminderMail[0].text).endsWith(`Open the approval: ${link}`)).toBe(true);
213+
214+
await rig.svc.decide(rig.requestId, { decision: 'approve', actorId: APPROVER }, asUser(APPROVER));
215+
const outcomes = await settled(rig, 'approval.approved');
216+
expect(outcomes.map((d) => `${d.channel}:${d.recipient_id}:${d.status}`).sort()).toEqual([
217+
`email:${SUBMITTER}:success`,
218+
`inbox:${SUBMITTER}:success`,
219+
]);
220+
const outcomeMail = mailTo(rig, SUBMITTER, 'Request approved');
221+
expect(outcomeMail).toHaveLength(1);
222+
expect(String(outcomeMail[0].text).endsWith(`Open the approval: ${link}`)).toBe(true);
223+
});
224+
225+
it('a rejection e-mails the submitter, with the decider\'s comment in the body', async () => {
226+
rig = await boot();
227+
await settled(rig, 'approval.requested');
228+
await rig.svc.decide(rig.requestId, { decision: 'reject', actorId: APPROVER, comment: 'Wrong cost centre' }, asUser(APPROVER));
229+
const rows = await settled(rig, 'approval.rejected');
230+
expect(rows.map((d) => `${d.channel}:${d.recipient_id}:${d.status}`).sort()).toEqual([
231+
`email:${SUBMITTER}:success`,
232+
`inbox:${SUBMITTER}:success`,
233+
]);
234+
const mail = mailTo(rig, SUBMITTER, 'Request rejected');
235+
expect(mail).toHaveLength(1);
236+
expect(String(mail[0].text)).toContain('Wrong cost centre');
237+
});
238+
239+
it('an approval topic outside the four stays inbox-only', async () => {
240+
rig = await boot();
241+
await settled(rig, 'approval.requested');
242+
await rig.svc.comment(rig.requestId, { actorId: SUBMITTER, comment: 'Any news?' } as any, asUser(SUBMITTER));
243+
const rows = await settled(rig, 'approval.comment');
244+
expect(new Set(rows.map((d) => d.channel))).toEqual(new Set(['inbox']));
245+
});
246+
247+
it('⛔ no stored row of any table, and no message sent, holds an ADR-0043 action link or token', async () => {
248+
rig = await boot();
249+
await settled(rig, 'approval.requested');
250+
await rig.svc.remind(rig.requestId, { actorId: SUBMITTER }, asUser(SUBMITTER));
251+
await settled(rig, 'approval.reminder');
252+
await rig.svc.decide(rig.requestId, { decision: 'approve', actorId: APPROVER }, asUser(APPROVER));
253+
await settled(rig, 'approval.approved');
254+
255+
// Not vacuous: three e-mails went, and the stored notification rows carry the request link.
256+
expect(rig.sent).toHaveLength(3);
257+
const knex = rig.driver.getKnex();
258+
const tables = (await knex('sqlite_master').where({ type: 'table' }).select('name')) as Array<{ name: string }>;
259+
const leaks: string[] = [];
260+
let linkRows = 0;
261+
for (const { name } of tables) {
262+
if (name.startsWith('sqlite_')) continue;
263+
for (const row of (await knex(name).select('*')) as Array<Record<string, unknown>>) {
264+
const text = JSON.stringify(row);
265+
if (text.includes('/system/approvals?request=')) linkRows++;
266+
if (ACTION_LINK.test(text)) leaks.push(`${name}: ${text}`);
267+
}
268+
}
269+
expect(linkRows).toBeGreaterThan(0);
270+
expect(leaks).toEqual([]);
271+
expect(rig.sent.filter((m) => ACTION_LINK.test(JSON.stringify(m)))).toEqual([]);
272+
});
273+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
APPROVAL_EMAIL_CHANNELS,
6+
APPROVAL_EMAIL_LINK_LINES,
7+
APPROVAL_EMAIL_TEMPLATES,
8+
EMAILED_APPROVAL_TOPICS,
9+
} from './approval-email-templates.js';
10+
import { approvalMessages } from './translations/messages.js';
11+
12+
/**
13+
* [#30509] The approval e-mail's templates: every emailed topic in every
14+
* language the approval catalog carries, each the notice's own title and
15+
* body plus one line linking to the request.
16+
*/
17+
describe('approval e-mail templates', () => {
18+
it('the four lifecycle topics request the inbox and e-mail', () => {
19+
expect([...EMAILED_APPROVAL_TOPICS].sort()).toEqual(['approval.approved', 'approval.rejected', 'approval.reminder', 'approval.requested']);
20+
expect(APPROVAL_EMAIL_CHANNELS).toEqual(['inbox', 'email']);
21+
});
22+
23+
it('carries a link line in exactly the locales the approval catalog carries', () => {
24+
expect(Object.keys(APPROVAL_EMAIL_LINK_LINES).sort()).toEqual(Object.keys(approvalMessages).sort());
25+
});
26+
27+
it('declares one e-mail template per topic and locale: the title, the body, then the link', () => {
28+
const keys = APPROVAL_EMAIL_TEMPLATES.map((t) => `${t.topic}|${t.channel}|${t.locale}`);
29+
expect(new Set(keys).size).toBe(keys.length);
30+
expect(keys).toHaveLength(EMAILED_APPROVAL_TOPICS.size * Object.keys(APPROVAL_EMAIL_LINK_LINES).length);
31+
for (const t of APPROVAL_EMAIL_TEMPLATES) {
32+
expect(t.channel).toBe('email');
33+
expect(t.format).toBe('text');
34+
expect(t.subject).toBe('{{title}}');
35+
expect(t.body).toBe(`{{body}}\n\n${APPROVAL_EMAIL_LINK_LINES[t.locale as keyof typeof APPROVAL_EMAIL_LINK_LINES]}`);
36+
expect([...t.body!.matchAll(/\{\{\s*([\w.$]+)\s*\}\}/g)].map((m) => m[1])).toEqual(['body', 'approvalUrl']);
37+
}
38+
});
39+
});

0 commit comments

Comments
 (0)