Repository navigation
Commit d1e329e
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
- .changeset
- content/docs/automation
- packages/services/service-automation
- src/builtin
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
356 | 356 | | |
357 | 357 | | |
358 | 358 | | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
359 | 404 | | |
360 | 405 | | |
361 | 406 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
38 | 39 | | |
39 | 40 | | |
40 | 41 | | |
41 | | - | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| |||
0 commit comments