diff --git a/.changeset/25571-notify-email-field-delivery.md b/.changeset/25571-notify-email-field-delivery.md new file mode 100644 index 00000000000..0f07f6c9eee --- /dev/null +++ b/.changeset/25571-notify-email-field-delivery.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-automation': minor +--- + +feat(service-automation)!: a `notify` node mails the address in an email field of the trigger record, and refuses a computed address no user holds + +Clause-②: no (narrowing: a recipient that the value expression of a `notify` node whose `channels` include `email` evaluates to an email address no user holds now fails the node at run time instead of being mailed) +Metadata-protocol: narrowed + +A `notify` node can now reach a person who is not a user, such as an applicant who came in through a public form. `recipients: { emailField: 'applicant_email' }` with `channels: 'email'` (the form `@objectstack/spec` declared earlier in this release, which until now failed the run at the node) mails the address held in that `email`-typed field of the record that triggered the flow. + +- **The address.** It is read from the stored record by id when the node runs, never from the record snapshot the run carries. It must be exactly one plain ASCII mailbox, `name@example.com`. A list, a display name, a separator, a line break or an audience prefix such as `role:` fails the node and nothing is sent, and so does an address the `email` field type accepts but this node does not: a non-ASCII character, a quoted local part or an IP-literal domain. So do an empty field, a record that is not stored and a run with no trigger record. The failure names the field and the record, never the value. +- **The send.** The title and message render as for any notify, the email goes through the deployment's outbound mail, and it is recorded like every notification: a `sys_notification` event and a `sys_notification_delivery` row whose recipient is the address. +- **Per-address limit.** One address receives at most 5 email-field sends per hour (`EMAIL_FIELD_SENDS_PER_WINDOW`, `EMAIL_FIELD_SEND_WINDOW_SECONDS`), across every flow, counted case-insensitively in the kernel `cache` service (per process when none is registered, said once). A sixth fails the node before anything is written. The limit is best-effort: when the counter faults, the send is admitted and a warning is logged once. +- **BREAKING (narrowed): a computed address the node mails must be a user's.** In a `notify` node whose `channels` include `email`, a recipient that a value expression (a CEL envelope) evaluates to and that reads as an email address, bare or as `user:ADDRESS`, now resolves to the user who holds it, and fails the node when no user holds it (or when no user directory can be read), with a message that points to `{ emailField }`. Before, such an address was mailed as it stood, so an expression over record text let whoever typed that text choose who the flow mailed. A node that does not name `email` (`inbox` alone, the messaging default) mails nobody and is unchanged. An address written out in the flow (`recipients: ['ops@example.com']`) is the author's constant and is sent as written, as before. +- **Who moves.** A flow that mails (`channels` include `email`) a recipient its expression evaluates to an address no user holds. Measured over `examples/**` at `47af3dd`: no `notify` node names the `email` channel, so no in-repo flow changes. (Of the 17 `recipients` envelopes, the app-showcase ones over `record.owner` / `ownerId` do evaluate to addresses, since that field holds the owner's email, but those nodes are `inbox` only.) Fix: give the address its own `notify` node with `recipients: { emailField: 'FIELD' }` and `channels: 'email'`, or give the person a user. +- **Dependency.** `@objectstack/plugin-auth` moves from a dev dependency to a dependency of `@objectstack/service-automation`: the limit counts through its `rate-limit-storage` fixed-window counter, the repository's one counter, as `@objectstack/service-sms` does. This is a new workspace edge, `service-automation` → `plugin-auth`, and no cycle: neither `plugin-auth` nor what it depends on (`service-messaging`, `rest`) depends on `service-automation`. + + diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index bf9aac488eb..11f38e7d018 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -356,6 +356,51 @@ there — write the value itself (`sourceObject: 'crm_case'`). A `{…}` token there is refused, since it would reach the messaging service with its braces, and so is an envelope. The node's `actionUrl` is a text slot (below). +**A `notify` node mails a person who is not a user through an email field.** +An applicant who came in through a public form has no account, so no user id +reaches them. Write `recipients: { emailField: 'applicant_email' }` — the whole +value, never a list entry — naming an `email`-typed field of the record that +triggered the flow, with `channels: 'email'`: + +```ts +{ + id: 'tell_applicant', type: 'notify', label: 'Tell the applicant', + config: { + recipients: { emailField: 'applicant_email' }, + channels: 'email', + title: 'We received your application, {{ record.company_name }}', + message: 'We will reply within five working days.', + }, +} +``` + +The address is read from the **stored** record when the node runs, and it +must be exactly one plain ASCII mailbox (`name@example.com`): a list, a +display name, a separator or a line break fails the node and nothing is sent, +and so do an address the `email` field type accepts but this node does not (a +non-ASCII character, a quoted local part, an IP-literal domain), an empty field +and a run with no trigger record. The title and message are +rendered as for any notify, the send goes through the deployment's outbound +mail, and it is recorded like every notification: a `sys_notification` event +and a `sys_notification_delivery` row whose recipient is the address. One +address receives at most 5 such emails per hour, across every flow; a sixth +fails the node, so a form a visitor fills in cannot mail one inbox without +end. The limit is best-effort: when its counter in the kernel `cache` service +faults, the send is admitted and a warning is logged once. `objectstack validate` refuses a field the object does not have, a field +that is not `email`-typed, another channel, and a flow with no triggering +record. + +In a node whose `channels` include `email`, an address a **value expression +computes** must belong to a user, written bare or as `user:ADDRESS`: an +envelope that evaluates to a user's address reaches that user, and one that +evaluates to an address no user holds +fails the node, pointing to `{ emailField }` — an expression over record text +would otherwise let whoever typed that text choose who the flow mails. A node +that does not name `email` (`inbox` alone, the default) mails nobody, so its +computed recipients resolve as before. An address **written out** in the flow +(`recipients: ['ops@example.com']`) is the author's constant and is sent as +written. + **A CRUD node's filter: each operand computes before the query, and the query placeholders stay.** In a `get_record`, `update_record` or `delete_record` `filter`, each **operand** — a field's value, an operator's comparand, each diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 532e5afa720..33de92bac33 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -30,6 +30,7 @@ "@objectstack/metadata-core": "workspace:*", "@objectstack/metadata-protocol": "workspace:*", "@objectstack/platform-objects": "workspace:*", + "@objectstack/plugin-auth": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*" }, @@ -38,7 +39,6 @@ "@objectstack/metadata-core": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-audit": "workspace:*", - "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/refd-timer-testkit": "workspace:*", diff --git a/packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts b/packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts new file mode 100644 index 00000000000..f19c56d8076 --- /dev/null +++ b/packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts @@ -0,0 +1,349 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + MessagingService, + MemoryNotificationOutbox, + NotificationDispatcher, + NotificationTemplateStore, + createEmailChannel, + NOTIFICATION_EVENT_OBJECT, +} from '@objectstack/service-messaging'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { AutomationEngine } from '../engine.js'; +import { EMAIL_FIELD_SENDS_PER_WINDOW, registerNotifyNode } from './notify-node.js'; + +/** + * [#25571, ruling 6377887204 letter B] A `notify` node mails a person who is + * not a user through an email-field recipient, `{ emailField }`: the address + * in an `email`-typed field of the record that triggered the flow, templated, + * limited per address, and logged. [#25647, ruling 6386629237 letter B] Any + * other address a value envelope COMPUTES must be a user's. + * + * Wired through the REAL `MessagingService` (outbox-backed), the REAL + * `NotificationDispatcher` and the REAL email channel, over a data engine + * that stores the business record, the directory and the notification rows: + * the acceptance is a send visible in the messaging log, so the assertions + * read the log's two records — the `sys_notification` event and the + * `sys_notification_delivery` row — and what the email service was handed. + * + * A. The address is read from the STORED record and mailed, with the title + * rendered; the event and a `success` delivery row record it. + * B. Everything that is not one address in the field fails the node and + * sends nothing. + * C. No trigger record, a record not stored, an empty field: the node fails, + * naming which. + * D. At most `EMAIL_FIELD_SENDS_PER_WINDOW` sends per address per window. + * E. A computed address no user holds fails the node pointing to + * `{ emailField }`; a computed address a user holds resolves to that user; + * an address written out in the flow is sent as written. + */ + +function silentLogger(): any { + const l: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + l.child = () => l; + return l; +} + +interface Row { [key: string]: unknown; id: string } + +/** A data engine holding the business records and users, and recording every row the stack writes. */ +function dataEngine(opts: { records?: Record; users?: Row[] } = {}) { + const written: Array<{ object: string; row: Record }> = []; + let seq = 0; + const tables: Record = { ...(opts.records ?? {}), sys_user: opts.users ?? [] }; + const engine = { + async insert(object: string, row: Record) { + const stored = { ...row, id: row.id != null ? String(row.id) : `row_${++seq}` }; + written.push({ object, row: stored }); + return stored; + }, + async find() { return []; }, + async findOne(object: string, query?: EngineFindOneQueryInput) { + assertEngineFindOnePredicate(object, query); + const where = (query as { where?: Record } | undefined)?.where ?? {}; + // Equality on plain fields only: a combinator or operator is refused, never read as a field name. + const hit = (tables[object] ?? []).find((r) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$') || (typeof v === 'object' && v !== null)) { + throw new Error(`this double matches equality on plain fields only: ${JSON.stringify(where)}`); + } + return r[k] === v; + })); + return hit ? { ...hit } : undefined; + }, + }; + return { engine, written }; +} + +/** The whole stack: messaging + outbox + dispatcher + email channel, and the notify node over them. */ +function boot(opts: { records?: Record; users?: Row[] } = {}) { + const { engine: data, written } = dataEngine(opts); + const mailed: Array<{ to: unknown; subject: string }> = []; + const outbox = new MemoryNotificationOutbox(1); + const messaging = new MessagingService({ logger: silentLogger(), outbox, getData: () => data as any }); + messaging.registerChannel(createEmailChannel({ + getEmail: () => ({ + async send(input) { + mailed.push({ to: input.to, subject: input.subject }); + return { id: `mail_${mailed.length}` }; + }, + }), + getData: () => data as any, + store: new NotificationTemplateStore({ getData: () => undefined }), + })); + const dispatcher = new NotificationDispatcher({ + nodeId: 'node-test', + outbox, + channels: messaging, + channelContext: { logger: silentLogger() }, + partitionCount: 1, + intervalMs: 10_000, // ticks are driven manually + }); + const engine = new AutomationEngine(silentLogger()); + registerNotifyNode(engine, { + logger: silentLogger(), + getService: (name: string) => (name === 'messaging' ? messaging : name === 'data' ? data : undefined), + } as any); + return { engine, outbox, dispatcher, mailed, written }; +} + +const CEL = (source: string) => ({ dialect: 'cel', source }); + +function notifyFlow(config: Record): any { + return { + name: 'applicant_notice', + label: 'Applicant notice', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'notify', type: 'notify', label: 'Notify', config: { title: 'Received: {{ record.name }}', ...config } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'end' }, + ], + }; +} + +const EMAIL_FIELD = { recipients: { emailField: 'applicant_email' }, channels: 'email' }; + +/** The context a record-change trigger hands a run about `id`. */ +function onCreate(id: string, record: Record = {}): any { + return { event: 'on_create', object: 'hr_applicant', record: { id, ...record }, tenantId: 'org_1' }; +} + +async function runOnce(stack: ReturnType, config: Record, context: any) { + stack.engine.registerFlow('applicant_notice', notifyFlow(config)); + const run = await stack.engine.execute('applicant_notice', context); + const runs = await stack.engine.listRuns('applicant_notice'); + const step = runs[0]?.steps.find((s) => s.nodeId === 'notify'); + return { run, step }; +} + +const APPLICANT = { id: 'app_1', name: 'Ann', applicant_email: 'ann@applicant.example' }; + +describe('#25571 — a notify node mails the address in an email field of the trigger record', () => { + it('A: reads the STORED address, renders the title, and the send is in the messaging log', async () => { + const stack = boot({ records: { hr_applicant: [APPLICANT] } }); + // The run's snapshot carries a different address: the stored row decides. + const { run } = await runOnce(stack, EMAIL_FIELD, onCreate('app_1', { name: 'Ann', applicant_email: 'other@elsewhere.example' })); + expect(run.success).toBe(true); + + const events = stack.written.filter((w) => w.object === NOTIFICATION_EVENT_OBJECT); + expect(events).toHaveLength(1); + const [pending] = await stack.outbox.list(); + expect(pending).toMatchObject({ channel: 'email', recipientId: 'ann@applicant.example', status: 'pending' }); + + await stack.dispatcher.tick(); + expect(stack.mailed).toEqual([{ to: 'ann@applicant.example', subject: 'Received: Ann' }]); + const [delivered] = await stack.outbox.list(); + expect(delivered).toMatchObject({ channel: 'email', recipientId: 'ann@applicant.example', status: 'success' }); + expect(delivered.notificationId).toBe(events[0].row.id); + }); + + it.each([ + ['a list', 'ann@applicant.example,bob@applicant.example'], + ['two addresses separated by a semicolon', 'ann@applicant.example;bob@applicant.example'], + ['a display name', 'Ann '], + ['a comma in the local part', 'ann,bob@applicant.example'], + ['an audience prefix', 'role:admin@applicant.example'], + ['a header break', 'ann@applicant.example\r\nBcc: bob@applicant.example'], + ['no domain dot', 'ann@localhost'], + ['a number', 42], + ])('B: fails the node and sends nothing when the field holds %s', async (_label, value) => { + const stack = boot({ records: { hr_applicant: [{ ...APPLICANT, applicant_email: value }] } }); + const { run, step } = await runOnce(stack, EMAIL_FIELD, onCreate('app_1')); + expect(run.success).toBe(false); + expect(step?.error?.message).toContain("`recipients: { emailField: 'applicant_email' }`"); + expect(step?.error?.message).toContain('does not hold one plain ASCII email address'); + expect(step?.error?.message).not.toContain(String(value)); + expect(await stack.outbox.list()).toHaveLength(0); + expect(stack.written.filter((w) => w.object === NOTIFICATION_EVENT_OBJECT)).toHaveLength(0); + }); + + it.each([ + ['the run has no trigger record', undefined, 'this run has no trigger record'], + ['the record is not stored', onCreate('app_gone'), "hr_applicant 'app_gone' is not stored"], + ['the field is empty', onCreate('app_blank'), "`applicant_email` on hr_applicant 'app_blank' is empty"], + ])('C: fails the node when %s', async (_label, context, expected) => { + const stack = boot({ records: { hr_applicant: [APPLICANT, { id: 'app_blank', name: 'B', applicant_email: '' }] } }); + const { run, step } = await runOnce(stack, EMAIL_FIELD, context); + expect(run.success).toBe(false); + expect(step?.error?.message).toContain(expected as string); + expect(await stack.outbox.list()).toHaveLength(0); + }); + + it(`D: admits ${EMAIL_FIELD_SENDS_PER_WINDOW} sends to one address per window and refuses the next`, async () => { + const stack = boot({ + records: { + hr_applicant: [ + APPLICANT, + { id: 'app_2', name: 'Ann again', applicant_email: 'ANN@Applicant.Example' }, + { id: 'app_3', name: 'Cid', applicant_email: 'cid@applicant.example' }, + ], + }, + }); + stack.engine.registerFlow('applicant_notice', notifyFlow(EMAIL_FIELD)); + for (let i = 0; i < EMAIL_FIELD_SENDS_PER_WINDOW; i++) { + expect((await stack.engine.execute('applicant_notice', onCreate('app_1'))).success).toBe(true); + } + // The same mailbox in other case is the same address. + const refused = await stack.engine.execute('applicant_notice', onCreate('app_2')); + expect(refused.success).toBe(false); + const runs = await stack.engine.listRuns('applicant_notice'); + const step = runs.find((r) => r.status === 'failed')?.steps.find((s) => s.nodeId === 'notify'); + expect(step?.error?.message).toContain(`per-address limit of ${EMAIL_FIELD_SENDS_PER_WINDOW} emails`); + expect(step?.error?.message).toContain('nothing was sent'); + expect(await stack.outbox.list()).toHaveLength(EMAIL_FIELD_SENDS_PER_WINDOW); + // Another address keeps its own budget. + expect((await stack.engine.execute('applicant_notice', onCreate('app_3'))).success).toBe(true); + expect(await stack.outbox.list()).toHaveLength(EMAIL_FIELD_SENDS_PER_WINDOW + 1); + }); +}); + +describe('#25647 — a computed recipient address must be a user', () => { + const LEAD = { + id: 'lead_1', + name: 'Lead', + contact_text: 'stranger@outside.example', + owner_email: 'owen@company.example', + // Text a visitor typed with the `user:` prefix: the resolver hands the remainder on with no lookup. + prefixed_text: 'user:stranger@outside.example', + doubled_text: 'user:user:stranger@outside.example', + prefixed_owner: 'user:owen@company.example', + }; + const OWEN = { id: 'usr_owen', email: 'owen@company.example' }; + + function bootLead() { + return boot({ records: { hr_applicant: [LEAD] }, users: [OWEN] }); + } + + it('E1: an envelope that evaluates to an address no user holds fails the node, pointing to { emailField }', async () => { + const stack = bootLead(); + const { run, step } = await runOnce( + stack, + { recipients: [CEL('record.contact_text')], channels: 'email' }, + onCreate('lead_1', LEAD), + ); + expect(run.success).toBe(false); + expect(step?.error?.message).toContain('recipients[0] evaluated to an email address — source: `record.contact_text`'); + expect(step?.error?.message).toContain('no user holds it'); + expect(step?.error?.message).toContain("`recipients: { emailField: 'FIELD' }`"); + expect(await stack.outbox.list()).toHaveLength(0); + }); + + it.each([ + ['user:ADDRESS', 'record.prefixed_text'], + ['user:user:ADDRESS', 'record.doubled_text'], + ])('E7: an envelope that evaluates to %s no user holds fails the node, and nothing is enqueued', async (_label, source) => { + const stack = bootLead(); + const { run, step } = await runOnce(stack, { recipients: [CEL(source)], channels: 'email' }, onCreate('lead_1', LEAD)); + expect(run.success).toBe(false); + expect(step?.error?.message).toContain(`recipients[0] evaluated to an email address — source: \`${source}\``); + expect(step?.error?.message).toContain('no user holds it'); + expect(await stack.outbox.list()).toHaveLength(0); + await stack.dispatcher.tick(); + expect(stack.mailed).toHaveLength(0); + }); + + it('E8: an envelope that evaluates to user: plus a user\'s address resolves to that user', async () => { + const stack = bootLead(); + const { run } = await runOnce(stack, { recipients: CEL('record.prefixed_owner'), channels: 'email' }, onCreate('lead_1', LEAD)); + expect(run.success).toBe(true); + const [row] = await stack.outbox.list(); + expect(row).toMatchObject({ channel: 'email', recipientId: 'usr_owen' }); + }); + + it('E2: an envelope that evaluates to a user\'s address resolves to that user', async () => { + const stack = bootLead(); + const { run } = await runOnce( + stack, + { recipients: CEL('record.owner_email'), channels: 'email' }, + onCreate('lead_1', LEAD), + ); + expect(run.success).toBe(true); + const [row] = await stack.outbox.list(); + expect(row).toMatchObject({ channel: 'email', recipientId: 'usr_owen' }); + }); + + it('E3: an address written out in the flow is the author\'s constant and is sent as written', async () => { + const stack = bootLead(); + const { run } = await runOnce( + stack, + { recipients: ['ops@company.example'], channels: 'email' }, + onCreate('lead_1', LEAD), + ); + expect(run.success).toBe(true); + await stack.dispatcher.tick(); + expect(stack.mailed.map((m) => m.to)).toEqual(['ops@company.example']); + }); + + it('E4: with no data engine to check it, a computed address is not sent', async () => { + const emitted: unknown[] = []; + const engine = new AutomationEngine(silentLogger()); + registerNotifyNode(engine, { + logger: silentLogger(), + getService: (name: string) => (name === 'messaging' + ? { async emit(n: unknown) { emitted.push(n); return { notificationId: 'evt_1', delivered: 1, failed: 0 }; } } + : undefined), + } as any); + engine.registerFlow('applicant_notice', notifyFlow({ recipients: [CEL('record.contact_text')], channels: 'email' })); + const run = await engine.execute('applicant_notice', onCreate('lead_1', LEAD)); + expect(run.success).toBe(false); + expect(emitted).toHaveLength(0); + }); + + /** A notify node over a messaging double and NO data engine, so any user lookup would fail the node. */ + function bootUnchecked() { + const emitted: Array<{ audience?: unknown }> = []; + const engine = new AutomationEngine(silentLogger()); + registerNotifyNode(engine, { + logger: silentLogger(), + getService: (name: string) => (name === 'messaging' + ? { async emit(n: any) { emitted.push(n); return { notificationId: 'evt_1', delivered: 1, failed: 0 }; } } + : undefined), + } as any); + return { engine, emitted }; + } + + it.each([ + ['names no channel (inbox, the default)', undefined], + ['names inbox only', ['inbox']], + ])('E5: a node that %s mails nobody, so its computed address goes to messaging as before', async (_label, channels) => { + const { engine, emitted } = bootUnchecked(); + engine.registerFlow('applicant_notice', notifyFlow({ recipients: [CEL('record.contact_text')], channels })); + const run = await engine.execute('applicant_notice', onCreate('lead_1', LEAD)); + expect(run.success).toBe(true); + expect(emitted).toHaveLength(1); + expect(emitted[0].audience).toEqual(['stranger@outside.example']); + }); + + it('E6: a node whose channel list includes email screens its computed address', async () => { + const { engine, emitted } = bootUnchecked(); + engine.registerFlow('applicant_notice', notifyFlow({ recipients: [CEL('record.contact_text')], channels: ['inbox', 'email'] })); + const run = await engine.execute('applicant_notice', onCreate('lead_1', LEAD)); + expect(run.success).toBe(false); + expect(emitted).toHaveLength(0); + }); +}); diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 2f4ea29ce00..23d59800446 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -3,8 +3,13 @@ import type { PluginContext } from '@objectstack/core'; import type { AutomationContext, IDataEngine } from '@objectstack/spec/contracts'; import { createRecordWallOrganizationResolver, type RecordOrganizationResolver } from '@objectstack/metadata-core'; +import { + createLazyCounterStore, + incrementFixedWindow, + type CounterStore, +} from '@objectstack/plugin-auth/rate-limit-storage'; import { defineActionDescriptor, isExpressionEnvelopeShaped, NotifyConfigSchema } from '@objectstack/spec/automation'; -import type { NotifyConfigParsed } from '@objectstack/spec/automation'; +import type { NotifyConfigParsed, NotifyEmailFieldRecipientParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; import { renderTextSlot, type VariableMap } from './template.js'; import { parseNodeConfig } from './parse-config.js'; @@ -202,16 +207,19 @@ type Resolved = { readonly ok: true; readonly value: T } | { readonly ok: fal * one — fails the node, naming what it evaluated to and its source; it is * never `String(value)`. A `{…}` token never arrives: the contract parse * refused it. `empty` names each envelope that evaluated to no recipient, for - * the guard that fails a node left with none. + * the guard that fails a node left with none. `computed` names each recipient + * an envelope evaluated to that reads as an email address, for the check that + * a user holds it ({@link screenComputedAddresses}). */ function resolveRecipients( engine: AutomationEngine, value: unknown, variables: VariableMap, context: AutomationContext, -): Resolved<{ recipients: string[]; empty: string[] }> { +): Resolved<{ recipients: string[]; empty: string[]; computed: ComputedAddress[] }> { const recipients: string[] = []; const empty: string[] = []; + const computed: ComputedAddress[] = []; const entries: Array<[string, unknown]> = Array.isArray(value) ? value.map((entry, index) => [`recipients[${index}]`, entry]) : value === undefined ? [] : [['recipients', value]]; @@ -233,13 +241,256 @@ function resolveRecipients( }; } const recipient = toStr(item); - if (recipient !== undefined) recipients.push(recipient); + if (recipient === undefined) continue; + const address = isExpressionEnvelopeShaped(raw) ? mailedAddressOf(recipient) : undefined; + if (address !== undefined) { + computed.push({ index: recipients.length, what, source: sourceOf(raw), address }); + } + recipients.push(recipient); } if (isExpressionEnvelopeShaped(raw) && recipients.length === before) empty.push(sourceOf(raw)); } - return { ok: true, value: { recipients, empty } }; + return { ok: true, value: { recipients, empty, computed } }; +} + +/** The user identity object an address is checked against — the messaging resolver's `USER_OBJECT`. */ +const USER_OBJECT = 'sys_user'; + +/** + * The elevated context of the directory and record reads below — the posture + * the messaging service's own recipient reads take (`FAN_OUT_SYSTEM_CONTEXT`): + * each read projects only what it needs, and what it returns leaves this file + * only as a recipient. + */ +const RECIPIENT_READ = { context: { isSystem: true } } as const; + +/** A recipient a value envelope evaluated to that reads as an email address. */ +interface ComputedAddress { + /** Its position in the resolved recipient list. */ + readonly index: number; + /** Where it came from, as a failure names it (`recipients[1] evaluated to`). */ + readonly what: string; + /** The envelope's `source`. */ + readonly source: string; + /** The address the email channel would send to (`a@b.c`, also for `user:a@b.c`). */ + readonly address: string; +} + +/** + * The address the email channel would send `recipient` to, or `undefined` + * when it sends to none — mirrored from `@objectstack/service-messaging`, + * which this package takes no runtime dependency on. The chain reads an + * address in TWO places: the `RecipientResolver` keeps an address-shaped + * value verbatim when no user holds it, and it hands the remainder of a + * `user:` value on as the recipient id with no lookup at all; the email + * channel then sends to any recipient id that is address-shaped. So both + * `a@b.c` and `user:a@b.c` (and `user:user:a@b.c`, whose remainder is itself + * address-shaped) reach the mail as an address. `role:` and `team:` yield + * user ids. `owner_of:` reads a stored owner field, which record writes put + * there, not the expression, so it is not screened. + */ +function mailedAddressOf(recipient: string): string | undefined { + if (recipient.startsWith('user:')) { + const rest = recipient.slice('user:'.length); + return readsAsAddress(rest) ? rest : undefined; + } + if (/^(role|team|owner_of):/.test(recipient)) return undefined; + return readsAsAddress(recipient) ? recipient : undefined; +} + +/** + * The resolver's and the email channel's linear "looks like an email" test: + * no whitespace, one `@` not at either end, a dot inside the domain. + */ +function readsAsAddress(recipient: string): boolean { + if (/\s/.test(recipient)) return false; + const at = recipient.indexOf('@'); + if (at <= 0 || at !== recipient.lastIndexOf('@') || at === recipient.length - 1) return false; + const domain = recipient.slice(at + 1); + const dot = domain.indexOf('.'); + return dot > 0 && dot < domain.length - 1; +} + +/** + * [#25647, ruling 6386629237 letter B] A recipient a value envelope COMPUTED + * must be a user. An envelope that evaluates to an address a user holds + * resolves to that user's id here; one that evaluates to an address no user + * holds fails the node, pointing to `{ emailField }`. An address WRITTEN OUT + * in the metadata is the author's constant and is not looked at: it never + * reaches `computed`. + * + * Why the line falls there: an expression can build an address from record + * text a visitor typed, so letting it reach the mail channel lets whoever + * typed that text choose who this flow mails. The one way to mail a person + * who is not a user is the email-field recipient, which reads an `email`-typed + * field and carries the per-address limit ({@link registerNotifyNode}). + * + * So the screen runs only for a node whose `channels` name `email`, the one + * channel that sends to the address itself. Any other node (`inbox` alone, + * the messaging default) mails nobody, and its recipients go to the messaging + * resolver as before, which maps an address a user holds to that user. + * + * The lookup is the one the messaging resolver makes (`sys_user` by `email`, + * the id projected), so "a user holds it" means here what it means there. No + * data engine, or a lookup that fails, fails the node: an address that could + * not be checked is not sent. + */ +async function screenComputedAddresses( + data: IDataEngine | undefined, + recipients: string[], + computed: readonly ComputedAddress[], +): Promise> { + if (computed.length === 0) return { ok: true, value: recipients }; + const screened = [...recipients]; + for (const entry of computed) { + const address = entry.address; + const located = `notify: ${entry.what} an email address — source: \`${entry.source}\``; + if (!data) { + return { + ok: false, + error: `${located}. A computed recipient must be a user, and there is no data engine to look up ` + + 'whether a user holds that address, so nothing was sent.', + }; + } + let user: Record | null | undefined; + try { + user = await data.findOne(USER_OBJECT, { where: { email: address }, fields: ['id'] }, RECIPIENT_READ); + } catch (err) { + return { + ok: false, + error: `${located}. Looking up the user who holds it failed (${(err as Error).message}), so nothing was sent.`, + }; + } + const id = toStr(user?.id); + if (id === undefined) { + return { + ok: false, + error: `${located}, and no user holds it. A value expression names a user, never builds an ` + + 'address to mail: an address built from record text lets whoever typed that text choose who ' + + 'this flow mails. To mail a person who is not a user, give that address its own `notify` node ' + + 'with `recipients: { emailField: \'FIELD\' }`, naming an `email`-typed field of the record that ' + + 'triggered the flow, and `channels: \'email\'`. An address written out in the flow is sent as ' + + 'written.', + }; + } + screened[entry.index] = id; + } + return { ok: true, value: screened }; +} + +/** + * True when `value` is ONE email address and nothing else — the check the + * address an email-field recipient reads must pass before it is mailed. + * + * Stricter than the `email` field type's write check, on purpose: that check + * (`^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$`) admits a local part holding `,`, `;`, + * `:`, `<`, `>` or quotes, and a visitor types this value. Here the local part + * is dot-separated RFC 5322 atoms and the domain is dot-separated hostname + * labels, so no list separator, display name, header break or audience prefix + * (`role:` reads as one to the messaging resolver) can pass. ASCII only, on + * purpose too: an internationalised address, a quoted local part or an + * IP-literal domain the field type stored is refused, and the refusal says + * so. Written as plain scans and single-class tests, linear in the input. + */ +function isOneAddress(value: string): boolean { + if (value.length === 0 || value.length > 254) return false; + const at = value.indexOf('@'); + if (at <= 0 || at !== value.lastIndexOf('@')) return false; + const local = value.slice(0, at); + if (local.length > 64) return false; + if (!local.split('.').every((atom) => /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+$/.test(atom))) return false; + const labels = value.slice(at + 1).split('.'); + return labels.length >= 2 && labels.every((label) => label.length <= 63 + && /^[A-Za-z0-9-]+$/.test(label) && !label.startsWith('-') && !label.endsWith('-')); +} + +/** True when the parsed `recipients` is an email-field recipient, `{ emailField }`. */ +function isEmailFieldRecipient(value: unknown): value is NotifyEmailFieldRecipientParsed { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && typeof (value as { emailField?: unknown }).emailField === 'string'; } +/** + * [#25571, ruling 6377887204 letter B] The address an email-field recipient, + * `{ emailField }`, names: the value of that field on the run's trigger + * record, read back from the STORED row by id — never off the `record` a run + * carries, which a trigger door may hand over as its caller wrote it — and + * checked to be one address ({@link isOneAddress}). + * + * Every way there is no address fails the node and says which: a run with no + * trigger record, no data engine, a row that no longer exists, an empty field, + * a value that is not one address. The value itself is never echoed into the + * failure: a visitor typed it. + */ +async function resolveEmailFieldAddress( + data: IDataEngine | undefined, + context: AutomationContext, + field: string, +): Promise> { + const lead = `notify: \`recipients: { emailField: '${field}' }\` mails the address in that field of the record ` + + 'that triggered the flow'; + const trigger = triggerRecordOf(context); + if (!trigger) { + return { + ok: false, + error: `${lead}, and this run has no trigger record, so nothing was sent. An email-field recipient ` + + 'belongs to a record-triggered flow.', + }; + } + const at = `\`${field}\` on ${trigger.object} '${trigger.id}'`; + if (!data) return { ok: false, error: `${lead}, and there is no data engine to read ${at}, so nothing was sent.` }; + let row: Record | null | undefined; + try { + row = await data.findOne(trigger.object, { where: { id: trigger.id }, fields: ['id', field] }, RECIPIENT_READ); + } catch (err) { + return { ok: false, error: `${lead}; reading ${at} failed (${(err as Error).message}), so nothing was sent.` }; + } + if (!row) { + return { + ok: false, + error: `${lead}, and ${trigger.object} '${trigger.id}' is not stored (deleted, or not written yet), so ` + + 'nothing was sent. The address is read from the stored record.', + }; + } + const value = row[field]; + if (value == null || value === '') { + return { + ok: false, + error: `${lead}, and ${at} is empty, so there is no address to mail. Route around this node with a ` + + '`decision` where the field may be blank.', + }; + } + if (typeof value !== 'string' || !isOneAddress(value)) { + return { + ok: false, + error: `${lead}, and ${at} does not hold one plain ASCII email address, so nothing was sent. The address ` + + 'must be exactly one mailbox, `name@example.com`, in ASCII letters, digits and the usual mailbox ' + + 'punctuation: no list, display name, separator, quoted local part, IP-literal domain or ' + + 'non-ASCII character.', + }; + } + return { ok: true, value }; +} + +/** + * [#25571] The per-address limit on email-field recipients: at most + * {@link EMAIL_FIELD_SENDS_PER_WINDOW} sends to one address per + * {@link EMAIL_FIELD_SEND_WINDOW_SECONDS}, across every flow and node. + * + * The address came from a record a visitor may have filled in, so without a + * limit a public form becomes a way to mail any inbox as often as it can be + * submitted. The number is a constant, not a setting: it covers the outcomes + * one applicant is told about ("received", "returned for documents", + * "rejected") several times over, and nothing in the tree asks for more. + */ +export const EMAIL_FIELD_SENDS_PER_WINDOW = 5; + +/** The window {@link EMAIL_FIELD_SENDS_PER_WINDOW} counts over, in seconds. */ +export const EMAIL_FIELD_SEND_WINDOW_SECONDS = 60 * 60; + +/** Key prefix of the per-address counter; the address follows it, lowercased. */ +const EMAIL_FIELD_COUNTER_PREFIX = 'notify-email-field-sends:'; + /** * [#19939] The notify value slot holding one string — `sourceId`, the * click-through record's id: the string as written, or a CEL value envelope @@ -301,6 +552,53 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) return undefined; } }; + // [#25571] The store the per-address email-field counter counts in: the + // kernel `cache` service, resolved when a send is COUNTED (it registers + // async, possibly after this plugin), else a per-process fallback that + // announces itself — the one resolution every fleet-wide counter shares. + const resolveCounterStore = createLazyCounterStore({ + resolveCache: async (): Promise => { + let cache: any; + try { + cache = await (ctx as { getServiceAsync?: (name: string) => Promise }).getServiceAsync?.('cache'); + } catch { + return undefined; + } + return cache && typeof cache.get === 'function' && typeof cache.set === 'function' ? cache : undefined; + }, + logger: ctx.logger, + logPrefix: '[notify]', + subject: 'per-address email-field send limit', + degradedImpact: + 'The limit is still enforced, but PER NODE: an N-node deployment admits up to N× the limit to one address', + }); + let counterFaultWarned = false; + /** + * Admit one email-field send to `address`, or refuse it. A counter that + * cannot be read or written admits, said once at `warn`: a cache outage + * must not stop every form notification, and the loss is the limit, not + * a message. + */ + const admitEmailFieldSend = async (address: string): Promise => { + try { + const store = await resolveCounterStore(); + const { count } = await incrementFixedWindow( + store, + EMAIL_FIELD_COUNTER_PREFIX + address.toLowerCase(), + EMAIL_FIELD_SEND_WINDOW_SECONDS, + ); + return count <= EMAIL_FIELD_SENDS_PER_WINDOW; + } catch (err) { + if (!counterFaultWarned) { + counterFaultWarned = true; + ctx.logger.warn( + `[notify] the per-address email-field send counter failed (${(err as Error).message}); ` + + 'email-field sends are admitted without the limit until it answers again', + ); + } + return true; + } + }; let wallResolver: { readonly data: IDataEngine; readonly resolver: RecordOrganizationResolver } | undefined; const wallResolverFor = (data: IDataEngine): RecordOrganizationResolver => { if (wallResolver?.data !== data) { @@ -337,7 +635,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // Studio form keeps its recipients picker, and the marker // rides `NotifyConfigSchema` (`LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`). recipients: { - description: 'Who the notification goes to: a user id or an audience selector (such as team:ID) as a string, or a list of them — each entry a string or a CEL value envelope { dialect: \'cel\', source } evaluated per run; an entry that evaluates to a list adds each of its recipients. A single-brace {token} is refused.', + description: 'Who the notification goes to: a user id or an audience selector (such as team:ID) as a string, or a list of them — each entry a string or a CEL value envelope { dialect: \'cel\', source } evaluated per run; an entry that evaluates to a list adds each of its recipients, and with channels: email an address it evaluates to must be a user\'s. A single-brace {token} is refused. To mail a person who is not a user, use { emailField: \'FIELD\' } as the whole value, naming an email field of the record that triggered the flow, with channels: email; at most 5 such emails go to one address per hour.', }, // The form edits the bare-string spelling of these two // template slots; the contract (`NotifyConfigSchema`) also @@ -438,9 +736,29 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // [#19939] `recipients` is a VALUE slot holding one value or a list: // each envelope is evaluated in the run's scope, a list it // evaluates to adds each of its recipients (see `resolveRecipients`). - const audience = resolveRecipients(engine, cfg.recipients, variables, context); - if (!audience.ok) return { success: false, error: audience.error }; - const { recipients, empty: emptyRecipients } = audience.value; + // + // [#25571] An email-field recipient, `{ emailField }`, is the whole + // value: the address in that field of the trigger record. [#25647] + // Any other recipient an envelope computed that reads as an address + // must be a user's when the node mails (`screenComputedAddresses`). + const emailField = isEmailFieldRecipient(cfg.recipients) ? cfg.recipients.emailField : undefined; + let recipients: string[]; + let emptyRecipients: string[] = []; + if (emailField !== undefined) { + const address = await resolveEmailFieldAddress(getData(), context, emailField); + if (!address.ok) return { success: false, error: address.error }; + recipients = [address.value]; + } else { + const audience = resolveRecipients(engine, cfg.recipients, variables, context); + if (!audience.ok) return { success: false, error: audience.error }; + const mails = toStringList(cfg.channels).includes('email'); + const screened = mails + ? await screenComputedAddresses(getData(), audience.value.recipients, audience.value.computed) + : { ok: true as const, value: audience.value.recipients }; + if (!screened.ok) return { success: false, error: screened.error }; + recipients = screened.value; + emptyRecipients = audience.value.empty; + } // `title`/`message` are template slots (`templateExpressionInput`, // the input `TemplateExpressionInputSchema` is built from): the parse // above normalizes a bare string to the @@ -580,6 +898,20 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // predict a NULL row here is gone with the NULL row. let organizationId = toStr(context.tenantId); + // [#25571] Counted when the send is admitted, before anything is + // written — so a refused send writes no notification at all, and a + // send the messaging service then fails still spent its unit (the + // conservative direction for a limit on mail a visitor can cause). + if (emailField !== undefined && !(await admitEmailFieldSend(recipients[0]))) { + return { + success: false, + error: `notify: \`recipients: { emailField: '${emailField}' }\` — the address in that field has ` + + `reached the per-address limit of ${EMAIL_FIELD_SENDS_PER_WINDOW} emails through email-field ` + + `recipients per ${EMAIL_FIELD_SEND_WINDOW_SECONDS / 60} minutes, so this one was refused and ` + + 'nothing was sent. The limit stops a form a visitor fills in from mailing one inbox without end.', + }; + } + try { if (!organizationId) { const data = getData(); diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index e3be7f2489b..903ec636486 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -72,6 +72,16 @@ export default defineConfig({ find: /^@objectstack\/metadata-protocol$/, replacement: path.resolve(__dirname, '../../metadata-protocol/src/index.ts'), }, + { + // [#25571] `notify-node.ts` counts the per-address email-field limit + // with `@objectstack/plugin-auth`'s fixed-window counter, and + // `notify-email-field-recipient.integration.test.ts` asserts the Nth + // send is refused. Unaliased, the subpath resolves to `dist/`, so the + // verdict would depend on build state rather than on the source in this + // checkout. Same anchored-regex rule as the entries above. + find: /^@objectstack\/plugin-auth\/rate-limit-storage$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-auth/src/rate-limit-storage.ts'), + }, { // The two integration suites that boot the real `SecurityPlugin` mount // plugin-auth's identity preset (`createIdentityObjectsPlugin()`): diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05c6e984678..d5919640d2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2533,6 +2533,9 @@ importers: '@objectstack/platform-objects': specifier: workspace:* version: link:../../platform-objects + '@objectstack/plugin-auth': + specifier: workspace:* + version: link:../../plugins/plugin-auth '@objectstack/spec': specifier: workspace:* version: link:../../spec @@ -2549,9 +2552,6 @@ importers: '@objectstack/plugin-audit': specifier: workspace:* version: link:../../plugins/plugin-audit - '@objectstack/plugin-auth': - specifier: workspace:* - version: link:../../plugins/plugin-auth '@objectstack/plugin-security': specifier: workspace:* version: link:../../plugins/plugin-security diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 12e707eb64b..4875e0369d9 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4471,6 +4471,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/builtin/notify-organization-stamp.integration.test.ts", "verb": "findOne",