From 8afded11c392b8908027a2b010933f266fc60900 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:19:12 +0000 Subject: [PATCH 1/8] wip(service-automation): notify delivers to an email-field recipient and refuses a computed non-user address --- .../services/service-automation/package.json | 2 +- .../src/builtin/notify-node.ts | 311 +++++++++++++++++- pnpm-lock.yaml | 6 +- 3 files changed, 306 insertions(+), 13 deletions(-) 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-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 2f4ea29ce00..b69b64066ab 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,220 @@ function resolveRecipients( }; } const recipient = toStr(item); - if (recipient !== undefined) recipients.push(recipient); + if (recipient === undefined) continue; + if (isExpressionEnvelopeShaped(raw) && readsAsAddress(recipient)) { + computed.push({ index: recipients.length, what, source: sourceOf(raw) }); + } + 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; +} + +/** + * True when the messaging service would read `recipient` as an email address + * rather than as a user id or a selector — the order its `RecipientResolver` + * tries: the `user:` / `role:` / `team:` / `owner_of:` prefixes first, then + * its linear email heuristic, mirrored here because this package takes no + * runtime dependency on `@objectstack/service-messaging`. + */ +function readsAsAddress(recipient: string): boolean { + if (/^(user|role|team|owner_of):/.test(recipient)) return false; + 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}). + * + * 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 = recipients[entry.index]; + 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. 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 that record no longer exists, so nothing was sent.` }; + 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 email address, so nothing was sent. The address must be ` + + 'exactly one mailbox, `name@example.com`: no list, display name or separator.', + }; + } + 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 +516,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 +599,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 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 +700,26 @@ 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 (`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 screened = await screenComputedAddresses(getData(), audience.value.recipients, audience.value.computed); + 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 +859,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/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 From 4f0c74971cf77c42abad857f8f1709a6f4737c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:29:30 +0000 Subject: [PATCH 2/8] feat(service-automation): notify mails an email-field recipient, limited per address, and refuses a computed non-user address Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude --- .../25571-notify-email-field-delivery.md | 17 ++ content/docs/automation/flows.mdx | 39 +++ ...-email-field-recipient.integration.test.ts | 278 ++++++++++++++++++ .../src/builtin/notify-node.ts | 8 +- 4 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 .changeset/25571-notify-email-field-delivery.md create mode 100644 packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts diff --git a/.changeset/25571-notify-email-field-delivery.md b/.changeset/25571-notify-email-field-delivery.md new file mode 100644 index 00000000000..06d805e0921 --- /dev/null +++ b/.changeset/25571-notify-email-field-delivery.md @@ -0,0 +1,17 @@ +--- +'@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 +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 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. 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. +- **Narrowed: a computed address must be a user's.** A recipient that a value expression (a CEL envelope) evaluates to and that reads as an email address now resolves to the user who holds it, and fails the node when no user holds it, 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. 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 whose recipient expression evaluates to an address that belongs to no user. Measured over `examples/**` at `47af3dd`: all 17 `recipients` envelopes read a user reference (`record.assignee`, `record.owner`, `task.owner`, `ownerId`, …), none an address, so no in-repo flow changes. 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. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index bf9aac488eb..38ff6065e20 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -356,6 +356,45 @@ 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 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 +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. `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. + +An address a **value expression computes** must belong to a user: 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. 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/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..3aa624ca089 --- /dev/null +++ b/packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts @@ -0,0 +1,278 @@ +// 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 { 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 update(_object: string, row: Record) { return row; }, + async find() { return []; }, + async findOne(object: string, query?: { where?: Record }) { + const where = query?.where ?? {}; + const hit = (tables[object] ?? []).find((r) => Object.entries(where).every(([k, v]) => 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 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' }; + 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('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); + }); +}); diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index b69b64066ab..37ef2799b23 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -417,7 +417,13 @@ async function resolveEmailFieldAddress( } 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 that record no longer exists, 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 { From da085e00da55e81451fc91150541f7670e8742a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:33:24 +0000 Subject: [PATCH 3/8] chore(changeset): state the ADR-0087 disposition of the computed-address refusal Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude --- .changeset/25571-notify-email-field-delivery.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/25571-notify-email-field-delivery.md b/.changeset/25571-notify-email-field-delivery.md index 06d805e0921..43c90ae3b1d 100644 --- a/.changeset/25571-notify-email-field-delivery.md +++ b/.changeset/25571-notify-email-field-delivery.md @@ -15,3 +15,5 @@ A `notify` node can now reach a person who is not a user, such as an applicant w - **Narrowed: a computed address must be a user's.** A recipient that a value expression (a CEL envelope) evaluates to and that reads as an email address now resolves to the user who holds it, and fails the node when no user holds it, 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. 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 whose recipient expression evaluates to an address that belongs to no user. Measured over `examples/**` at `47af3dd`: all 17 `recipients` envelopes read a user reference (`record.assignee`, `record.owner`, `task.owner`, `ownerId`, …), none an address, so no in-repo flow changes. 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. + + From 215662e5207f020d677c3e11bd5f085f610bc15a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:59:24 +0000 Subject: [PATCH 4/8] test(service-automation): the email-field fake engine routes findOne through the engine predicate, pinned Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude --- .../notify-email-field-recipient.integration.test.ts | 7 ++++--- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) 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 index 3aa624ca089..7d35d439b78 100644 --- 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 @@ -9,6 +9,7 @@ import { 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'; @@ -57,10 +58,10 @@ function dataEngine(opts: { records?: Record; users?: Row[] } = { written.push({ object, row: stored }); return stored; }, - async update(_object: string, row: Record) { return row; }, async find() { return []; }, - async findOne(object: string, query?: { where?: Record }) { - const where = query?.where ?? {}; + async findOne(object: string, query?: EngineFindOneQueryInput) { + assertEngineFindOnePredicate(object, query); + const where = (query as { where?: Record } | undefined)?.where ?? {}; const hit = (tables[object] ?? []).find((r) => Object.entries(where).every(([k, v]) => r[k] === v)); return hit ? { ...hit } : undefined; }, 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", From 1046549eae3496f81837dfcdbfb6343a4b98f300 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:25:43 +0000 Subject: [PATCH 5/8] test(service-automation): alias the plugin-auth counter to source and refuse combinators in the email-field double Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude --- .../notify-email-field-recipient.integration.test.ts | 8 +++++++- packages/services/service-automation/vitest.config.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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 index 7d35d439b78..d2964630518 100644 --- 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 @@ -62,7 +62,13 @@ function dataEngine(opts: { records?: Record; users?: Row[] } = { async findOne(object: string, query?: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, query); const where = (query as { where?: Record } | undefined)?.where ?? {}; - const hit = (tables[object] ?? []).find((r) => Object.entries(where).every(([k, v]) => r[k] === v)); + // 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; }, }; 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()`): From 945154b3d928304e5b80e7af10629d712de7cef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:32:44 +0000 Subject: [PATCH 6/8] chore(changeset): declare the narrowing arm beside Metadata-protocol narrowed Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB Co-authored-by: Claude --- .changeset/25571-notify-email-field-delivery.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/25571-notify-email-field-delivery.md b/.changeset/25571-notify-email-field-delivery.md index 43c90ae3b1d..4217d4ef093 100644 --- a/.changeset/25571-notify-email-field-delivery.md +++ b/.changeset/25571-notify-email-field-delivery.md @@ -2,9 +2,9 @@ '@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 +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 +Clause-②: no (narrowing: a recipient that a `notify` node's value expression 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. @@ -12,7 +12,7 @@ A `notify` node can now reach a person who is not a user, such as an applicant w - **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 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. 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. -- **Narrowed: a computed address must be a user's.** A recipient that a value expression (a CEL envelope) evaluates to and that reads as an email address now resolves to the user who holds it, and fails the node when no user holds it, 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. An address written out in the flow (`recipients: ['ops@example.com']`) is the author's constant and is sent as written, as before. +- **BREAKING (narrowed): a computed address must be a user's.** A recipient that a value expression (a CEL envelope) evaluates to and that reads as an email address now resolves to the user who holds it, and fails the node when no user holds it, 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. 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 whose recipient expression evaluates to an address that belongs to no user. Measured over `examples/**` at `47af3dd`: all 17 `recipients` envelopes read a user reference (`record.assignee`, `record.owner`, `task.owner`, `ownerId`, …), none an address, so no in-repo flow changes. 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. From 450832fb5256d3072b4f23ae33d219da77cdc791 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:18:52 +0000 Subject: [PATCH 7/8] fix(service-automation): screen a computed address only when the notify node mails The #25647 screen ran for every notify node, so an inbox-only node whose recipient expression evaluates to the owner's email (app-showcase's approval and task-done notices, `record.owner` holding an email) failed when the run had no readable user directory: Test Core (4/6) red in example-showcase. Only the `email` channel sends to the address itself; `inbox` (the messaging default) mails nobody. The screen now runs when `channels` include `email`, and the docs, Studio description and changeset say so. E5/E6 pin both sides. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB --- .../25571-notify-email-field-delivery.md | 8 ++--- content/docs/automation/flows.mdx | 16 +++++---- ...-email-field-recipient.integration.test.ts | 33 +++++++++++++++++++ .../src/builtin/notify-node.ts | 14 ++++++-- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/.changeset/25571-notify-email-field-delivery.md b/.changeset/25571-notify-email-field-delivery.md index 4217d4ef093..6045d67a53d 100644 --- a/.changeset/25571-notify-email-field-delivery.md +++ b/.changeset/25571-notify-email-field-delivery.md @@ -4,7 +4,7 @@ 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 a `notify` node's value expression evaluates to an email address no user holds now fails the node at run time instead of being mailed) +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. @@ -12,8 +12,8 @@ A `notify` node can now reach a person who is not a user, such as an applicant w - **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 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. 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. -- **BREAKING (narrowed): a computed address must be a user's.** A recipient that a value expression (a CEL envelope) evaluates to and that reads as an email address now resolves to the user who holds it, and fails the node when no user holds it, 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. 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 whose recipient expression evaluates to an address that belongs to no user. Measured over `examples/**` at `47af3dd`: all 17 `recipients` envelopes read a user reference (`record.assignee`, `record.owner`, `task.owner`, `ownerId`, …), none an address, so no in-repo flow changes. Fix: give the address its own `notify` node with `recipients: { emailField: 'FIELD' }` and `channels: 'email'`, or give the person a user. +- **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 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. - + diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 38ff6065e20..77de7e2089d 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -387,13 +387,15 @@ end. `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. -An address a **value expression computes** must belong to a user: 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. An address **written out** in the -flow (`recipients: ['ops@example.com']`) is the author's constant and is sent -as written. +In a node whose `channels` include `email`, an address a **value expression +computes** must belong to a user: 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` 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 index d2964630518..220686a41e6 100644 --- 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 @@ -282,4 +282,37 @@ describe('#25647 — a computed recipient address must be a user', () => { 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 37ef2799b23..d7dbc5574db 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -304,6 +304,11 @@ function readsAsAddress(recipient: string): boolean { * 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 @@ -605,7 +610,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, and 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.', + 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 @@ -710,7 +715,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // [#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 (`screenComputedAddresses`). + // 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[] = []; @@ -721,7 +726,10 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) } else { const audience = resolveRecipients(engine, cfg.recipients, variables, context); if (!audience.ok) return { success: false, error: audience.error }; - const screened = await screenComputedAddresses(getData(), audience.value.recipients, audience.value.computed); + 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; From d4fd5968a5a9002e3aa9c35ceadb1849a9e13f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:32:33 +0000 Subject: [PATCH 8/8] fix(service-automation): screen a computed user: address on the mail path The messaging resolver hands the remainder of a `user:` value on as the recipient id with no lookup, and the email channel sends to any address-shaped id, so a computed `user:stranger@x.y` (or `user:user:stranger@x.y`) reached the mail unscreened. The screen now takes the address the email channel would send to, bare or after one `user:`, and resolves or refuses it like a bare address; `owner_of:` reads a stored owner field and stays unscreened. E7/E8 pin both sides. Docs and changeset now declare that the per-address limit is best-effort on a counter fault and that the email-field address grammar is ASCII only (the refusal text says so), and name the new service-automation to plugin-auth workspace edge. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB --- .../25571-notify-email-field-delivery.md | 8 +-- content/docs/automation/flows.mdx | 16 +++--- ...-email-field-recipient.integration.test.ts | 35 ++++++++++++- .../src/builtin/notify-node.ts | 51 ++++++++++++++----- 4 files changed, 85 insertions(+), 25 deletions(-) diff --git a/.changeset/25571-notify-email-field-delivery.md b/.changeset/25571-notify-email-field-delivery.md index 6045d67a53d..0f07f6c9eee 100644 --- a/.changeset/25571-notify-email-field-delivery.md +++ b/.changeset/25571-notify-email-field-delivery.md @@ -9,11 +9,11 @@ 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 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. 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 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. -- **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 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. +- **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. +- **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 77de7e2089d..11f38e7d018 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -375,21 +375,25 @@ triggered the flow, with `channels: 'email'`: ``` The address is read from the **stored** record when the node runs, and it -must be exactly one 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 -empty field and a run with no trigger record. The title and message are +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. `objectstack validate` refuses a field the object does not have, a field +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: an envelope that evaluates to a user's -address reaches that user, and one that evaluates to an address no user holds +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 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 index 220686a41e6..f19c56d8076 100644 --- 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 @@ -176,7 +176,7 @@ describe('#25571 — a notify node mails the address in an email field of the tr 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 email address'); + 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); @@ -223,7 +223,16 @@ describe('#25571 — a notify node mails the address in an email field of the tr }); 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' }; + 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() { @@ -244,6 +253,28 @@ describe('#25647 — a computed recipient address must be a user', () => { 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( diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index d7dbc5574db..23d59800446 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -242,8 +242,9 @@ function resolveRecipients( } const recipient = toStr(item); if (recipient === undefined) continue; - if (isExpressionEnvelopeShaped(raw) && readsAsAddress(recipient)) { - computed.push({ index: recipients.length, what, source: sourceOf(raw) }); + const address = isExpressionEnvelopeShaped(raw) ? mailedAddressOf(recipient) : undefined; + if (address !== undefined) { + computed.push({ index: recipients.length, what, source: sourceOf(raw), address }); } recipients.push(recipient); } @@ -271,17 +272,37 @@ interface ComputedAddress { 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; } /** - * True when the messaging service would read `recipient` as an email address - * rather than as a user id or a selector — the order its `RecipientResolver` - * tries: the `user:` / `role:` / `team:` / `owner_of:` prefixes first, then - * its linear email heuristic, mirrored here because this package takes no - * runtime dependency on `@objectstack/service-messaging`. + * 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 (/^(user|role|team|owner_of):/.test(recipient)) return false; if (/\s/.test(recipient)) return false; const at = recipient.indexOf('@'); if (at <= 0 || at !== recipient.lastIndexOf('@') || at === recipient.length - 1) return false; @@ -322,7 +343,7 @@ async function screenComputedAddresses( if (computed.length === 0) return { ok: true, value: recipients }; const screened = [...recipients]; for (const entry of computed) { - const address = recipients[entry.index]; + const address = entry.address; const located = `notify: ${entry.what} an email address — source: \`${entry.source}\``; if (!data) { return { @@ -366,8 +387,10 @@ async function screenComputedAddresses( * `:`, `<`, `>` 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. Written as plain - * scans and single-class tests, linear in the input. + * (`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; @@ -440,8 +463,10 @@ async function resolveEmailFieldAddress( if (typeof value !== 'string' || !isOneAddress(value)) { return { ok: false, - error: `${lead}, and ${at} does not hold one email address, so nothing was sent. The address must be ` - + 'exactly one mailbox, `name@example.com`: no list, display name or separator.', + 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 };