Skip to content

Commit d1e329e

Browse files
feat(service-automation)!: notify mails an email-field recipient, limited per address, and refuses a computed address no user holds (#25721)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9qrU1uBiZrE9tU8kmzwSr1)_ Fixes #25571 Fixes #25647 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 ## What changes A `notify` node can now mail a person who is not a user. `recipients: { emailField: 'applicant_email' }` with `channels: 'email'` (the form PR #25645 declared in the spec, which until now failed the run at the node) mails the address in that `email`-typed field of the record that triggered the flow. Ruling 6377887204 (letter B): "recipient = a record field of type email only; templated; rate-limited per address; logged". - **Where the address comes from.** `resolveEmailFieldAddress` in `packages/services/service-automation/src/builtin/notify-node.ts` reads the field off the STORED trigger row by id (system context, projection `id` + the field), never off the `record` snapshot the run carries. Every way there is no address fails the node and says which: no trigger record, no data engine, a row that is not stored, an empty field, a value that is not one address. The failure names the field and the record, never the value, because a visitor typed it. - **One address, nothing else.** The `email` field type's write check is `^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$` (`packages/objectql/src/validation/record-validator.ts`). It admits a local part holding a comma, semicolon, colon, angle brackets or quotes, and `role:x@y.z` reads as an audience selector to the messaging resolver. `isOneAddress` requires dot-separated RFC 5322 atoms and hostname labels, linear scans only. A list, a display name, a separator, a line break and an audience prefix each fail the node (pinned case by case). The grammar is plain ASCII on purpose: a non-ASCII character, a quoted local part or an IP-literal domain the email field type stored is refused, and the refusal says "does not hold one plain ASCII email address"; flows.mdx and the changeset declare it. - **Templated and logged.** The address goes to `messaging.emit` as the audience with `channels: ['email']`. The existing chain does the rest: the recipient resolver keeps an address no user holds verbatim, the email channel sends a literal address as is, and the send is recorded as a `sys_notification` event plus a `sys_notification_delivery` row whose recipient is the address. Title and message render as for any notify. No messaging-side change was needed, so `packages/services/service-messaging/**` is untouched. - **Rate-limited per address.** At most `EMAIL_FIELD_SENDS_PER_WINDOW` (5) sends per `EMAIL_FIELD_SEND_WINDOW_SECONDS` (1 hour) to one address, lowercased, across every flow. Measured first: service-messaging has no limiter. The repo's one fixed-window counter is `incrementFixedWindow` / `createLazyCounterStore` in `@objectstack/plugin-auth/rate-limit-storage`, counted in the kernel `cache` service and resolved at count time, which `@objectstack/service-sms` already uses. service-messaging cannot import it (plugin-auth depends on service-messaging, a cycle), so the limit lives in the node, which is also the only place that knows the address came from an email field. `@objectstack/plugin-auth` moves from a dev dependency to a dependency of service-automation (a new workspace edge, service-automation → plugin-auth; no cycle: neither plugin-auth nor service-messaging or rest depends on service-automation). A refused send fails the node before anything is written. A counter fault admits, said once at `warn`, matching the SMS quota's fail-open. The limit is declared best-effort in flows.mdx and the changeset. ## #25647, ruling 6386629237 letter B: a computed address must be a user's Verbatim: "`notify` 收件人若由表达式(CEL 信封)求值得到一个不属于任何用户的邮箱地址,节点失败并给出定位提示指向 `{ emailField }`;求值得到某个用户的地址仍解析为该用户。元数据里**写死**的地址是作者常量,照常发出。" - **Scope: nodes whose `channels` include `email`.** Only the email channel delivers to the raw address; a node on `inbox` alone (the messaging default) mails nobody, so its recipients pass to messaging unscreened, as before. Whether this reading of the ruling stands is put to the maintainer as a confirmation (open question 1 of the os-dev-report `6392322238`); the seat lands on it meanwhile, and screening every channel would be an additive follow-up. - `resolveRecipients` now marks each recipient an envelope evaluated to that the messaging resolver would read as an address (the address the email channel would send to: a bare address, or the remainder after one `user:`, which the resolver hands on with no lookup; `role:` and `team:` yield user ids). `screenComputedAddresses` looks each one up the way the resolver does (`sys_user` by `email`, the id projected): a user holds it, the recipient becomes that user's id; no user holds it, the node fails, located (`recipients[0] evaluated to an email address — source: …`) and pointing to `recipients: { emailField: 'FIELD' }`. No data engine, or a failing lookup, fails the node: an address that could not be checked is not sent. - `owner_of:` is not screened: its address comes from a stored owner field written through record writes, not from a string the expression builds (seat ruling on F1). - A written-out address (`recipients: ['ops@example.com']`) never reaches the check and is sent as before (pinned). - In-repo producers, re-checked per the card: `git grep -nE "recipients\s*:\s*\[?\s*\{\s*dialect" origin/main -- examples` at `47af3dd` gives 17 envelopes. The app-showcase ones over `record.owner` / `ownerId` do evaluate to addresses (that field holds the owner's email), but those nodes are `inbox` only, and no `notify` node in `examples/**` names the `email` channel, so no in-repo flow starts failing. HotCRM at `a4b3560`: 0 hits. ## Docs `content/docs/automation/flows.mdx` gains the recipients paragraph PR #25645 left to this card: the email-field form with an example, what fails, the limit, the log, and the computed-versus-written-out rule. ## Left to #25696 (the spec card) - The sentence "Delivery to an email-field recipient is not live yet: until the messaging service resolves the form, a run that reaches the node fails there." in the `recipients` describe of `packages/spec/src/automation/io-node-config.zod.ts` (and the generated `content/docs/references/automation/io-node-config.mdx` row) is now false and is #25696's to remove. - Ruling B's describe half (name a written-out address as the author's constant; drop "an email address" from the audience-selector list) is #25696's. - This PR does not touch `packages/spec/**` or `packages/services/service-automation/src/engine.ts`. ## Tests New `packages/services/service-automation/src/builtin/notify-email-field-recipient.integration.test.ts`, through the real `MessagingService` (outbox-backed), `NotificationDispatcher` and email channel: - A: the stored address is mailed (a different address in the run's snapshot is ignored), the title renders, and the event row plus a `success` delivery row record it. - B: eight values that are not one address each fail the node, write nothing, and the failure does not echo the value. - C: no trigger record, a row not stored, an empty field. - D: 5 sends admitted, the 6th to the same mailbox in other case refused with nothing written, another address unaffected. - E1–E4: computed non-user address refused naming `{ emailField }`; computed user address resolves to the user id; written-out address sent as written; no data engine, computed address not sent. - E5 (×2): an `inbox`-only node, and a node with no `channels`, pass a computed address to messaging unscreened. E6: `channels: ['inbox', 'email']` is screened. - E7 (×2): a computed `user:ADDRESS` and `user:user:ADDRESS` no user holds under `channels: email` fail the node, nothing enqueued or mailed. E8: a computed `user:` plus a user's address resolves to that user. Readings at `d4fd5968`: the file 23/23; service-automation 202/202 files (2,659 tests); example-showcase 39/39; plugin-approvals 96/96; `pnpm --filter @objectstack/service-automation typecheck` exit 0; eslint over the package exit 0; dogfood battery 295 files / 2,520 tests passed, 0 failed. **Ablations** (each through `scripts/ablation-replace.mjs`, mutation proven on disk, restored to the HEAD blob with `git diff HEAD` empty): | Put back | Red | |:--|:--| | the limit check (`if (false)`) | D | | `isOneAddress` (type check only) | B, 7 of 8 (the number value is still refused by the type check, so its pin stays green) | | the computed-address screen (return recipients unscreened) | E1, E4 | | the snapshot read (`context.record[field] ?? row[field]`) | A | | the channel gate forced true | E5 ×2 | | the channel gate forced false | E1, E4, E6 | | the `user:` branch (old prefix refusal) | E7 ×2, E8 | ## Acceptance notes - The limit is a constant, not a setting: 5 an hour covers the outcomes one applicant hears about several times over, and no producer asks for more. - Gate sweep: the `dispatch-gates --commands` union at the final head, with every exit code, is in the os-dev-report comment on #25571; CI's check-runs are the authority for the full farm. `check:dual-build-cjs-loads` and `check:skill-examples` exit 0 after a full build (at `450832fb` and `d4fd5968`). - A narrowing (`Metadata-protocol: narrowed`) owes one contract-review-tier review before the queue. --- _Generated by [Claude Code](https://claude.ai/code/session_01CmnfU7vRbNtSFtBQwyyKpB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c61f75d commit d1e329e

8 files changed

Lines changed: 773 additions & 13 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
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
6+
7+
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)
8+
Metadata-protocol: narrowed
9+
10+
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.
11+
12+
- **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.
13+
- **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.
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **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`.
18+
19+
<!-- adr-0087: not-required (no-migration-prescription) a run-time refusal in a `notify` node that mails (`channels` include `email`) of a recipient that a value expression evaluates to an email address no user holds: no authorable key, spelling or stored shape moves, the parse is unchanged, and no stored flow is read, rewritten or converted. The repair is the author's choice between an email-field recipient naming a field of the trigger record and giving the person a user, which no ledger entry can derive, and no in-repo flow mails a computed address (no `notify` node in `examples/**` at `47af3dd` names the `email` channel). The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

‎content/docs/automation/flows.mdx‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,51 @@ there — write the value itself (`sourceObject: 'crm_case'`). A `{…}` token
356356
there is refused, since it would reach the messaging service with its braces,
357357
and so is an envelope. The node's `actionUrl` is a text slot (below).
358358

359+
**A `notify` node mails a person who is not a user through an email field.**
360+
An applicant who came in through a public form has no account, so no user id
361+
reaches them. Write `recipients: { emailField: 'applicant_email' }` — the whole
362+
value, never a list entry — naming an `email`-typed field of the record that
363+
triggered the flow, with `channels: 'email'`:
364+
365+
```ts
366+
{
367+
id: 'tell_applicant', type: 'notify', label: 'Tell the applicant',
368+
config: {
369+
recipients: { emailField: 'applicant_email' },
370+
channels: 'email',
371+
title: 'We received your application, {{ record.company_name }}',
372+
message: 'We will reply within five working days.',
373+
},
374+
}
375+
```
376+
377+
The address is read from the **stored** record when the node runs, and it
378+
must be exactly one plain ASCII mailbox (`name@example.com`): a list, a
379+
display name, a separator or a line break fails the node and nothing is sent,
380+
and so do an address the `email` field type accepts but this node does not (a
381+
non-ASCII character, a quoted local part, an IP-literal domain), an empty field
382+
and a run with no trigger record. The title and message are
383+
rendered as for any notify, the send goes through the deployment's outbound
384+
mail, and it is recorded like every notification: a `sys_notification` event
385+
and a `sys_notification_delivery` row whose recipient is the address. One
386+
address receives at most 5 such emails per hour, across every flow; a sixth
387+
fails the node, so a form a visitor fills in cannot mail one inbox without
388+
end. The limit is best-effort: when its counter in the kernel `cache` service
389+
faults, the send is admitted and a warning is logged once. `objectstack validate` refuses a field the object does not have, a field
390+
that is not `email`-typed, another channel, and a flow with no triggering
391+
record.
392+
393+
In a node whose `channels` include `email`, an address a **value expression
394+
computes** must belong to a user, written bare or as `user:ADDRESS`: an
395+
envelope that evaluates to a user's address reaches that user, and one that
396+
evaluates to an address no user holds
397+
fails the node, pointing to `{ emailField }` — an expression over record text
398+
would otherwise let whoever typed that text choose who the flow mails. A node
399+
that does not name `email` (`inbox` alone, the default) mails nobody, so its
400+
computed recipients resolve as before. An address **written out** in the flow
401+
(`recipients: ['ops@example.com']`) is the author's constant and is sent as
402+
written.
403+
359404
**A CRUD node's filter: each operand computes before the query, and the query
360405
placeholders stay.** In a `get_record`, `update_record` or `delete_record`
361406
`filter`, each **operand** — a field's value, an operator's comparand, each

‎packages/services/service-automation/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@objectstack/metadata-core": "workspace:*",
3131
"@objectstack/metadata-protocol": "workspace:*",
3232
"@objectstack/platform-objects": "workspace:*",
33+
"@objectstack/plugin-auth": "workspace:*",
3334
"@objectstack/spec": "workspace:*",
3435
"@objectstack/types": "workspace:*"
3536
},
@@ -38,7 +39,6 @@
3839
"@objectstack/metadata-core": "workspace:*",
3940
"@objectstack/objectql": "workspace:*",
4041
"@objectstack/plugin-audit": "workspace:*",
41-
"@objectstack/plugin-auth": "workspace:*",
4242
"@objectstack/plugin-security": "workspace:*",
4343
"@objectstack/plugin-sharing": "workspace:*",
4444
"@objectstack/refd-timer-testkit": "workspace:*",

0 commit comments

Comments
 (0)