Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/25571-notify-email-field-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/service-automation': minor
---

feat(service-automation)!: a `notify` node mails the address in an email field of the trigger record, and refuses a computed address no user holds

Clause-②: no (narrowing: a recipient that the value expression of a `notify` node whose `channels` include `email` evaluates to an email address no user holds now fails the node at run time instead of being mailed)
Metadata-protocol: narrowed

A `notify` node can now reach a person who is not a user, such as an applicant who came in through a public form. `recipients: { emailField: 'applicant_email' }` with `channels: 'email'` (the form `@objectstack/spec` declared earlier in this release, which until now failed the run at the node) mails the address held in that `email`-typed field of the record that triggered the flow.

- **The address.** It is read from the stored record by id when the node runs, never from the record snapshot the run carries. It must be exactly one plain ASCII mailbox, `name@example.com`. A list, a display name, a separator, a line break or an audience prefix such as `role:` fails the node and nothing is sent, and so does an address the `email` field type accepts but this node does not: a non-ASCII character, a quoted local part or an IP-literal domain. So do an empty field, a record that is not stored and a run with no trigger record. The failure names the field and the record, never the value.
- **The send.** The title and message render as for any notify, the email goes through the deployment's outbound mail, and it is recorded like every notification: a `sys_notification` event and a `sys_notification_delivery` row whose recipient is the address.
- **Per-address limit.** One address receives at most 5 email-field sends per hour (`EMAIL_FIELD_SENDS_PER_WINDOW`, `EMAIL_FIELD_SEND_WINDOW_SECONDS`), across every flow, counted case-insensitively in the kernel `cache` service (per process when none is registered, said once). A sixth fails the node before anything is written. The limit is best-effort: when the counter faults, the send is admitted and a warning is logged once.
- **BREAKING (narrowed): a computed address the node mails must be a user's.** In a `notify` node whose `channels` include `email`, a recipient that a value expression (a CEL envelope) evaluates to and that reads as an email address, bare or as `user:ADDRESS`, now resolves to the user who holds it, and fails the node when no user holds it (or when no user directory can be read), with a message that points to `{ emailField }`. Before, such an address was mailed as it stood, so an expression over record text let whoever typed that text choose who the flow mailed. A node that does not name `email` (`inbox` alone, the messaging default) mails nobody and is unchanged. An address written out in the flow (`recipients: ['ops@example.com']`) is the author's constant and is sent as written, as before.
- **Who moves.** A flow that mails (`channels` include `email`) a recipient its expression evaluates to an address no user holds. Measured over `examples/**` at `47af3dd`: no `notify` node names the `email` channel, so no in-repo flow changes. (Of the 17 `recipients` envelopes, the app-showcase ones over `record.owner` / `ownerId` do evaluate to addresses, since that field holds the owner's email, but those nodes are `inbox` only.) Fix: give the address its own `notify` node with `recipients: { emailField: 'FIELD' }` and `channels: 'email'`, or give the person a user.
- **Dependency.** `@objectstack/plugin-auth` moves from a dev dependency to a dependency of `@objectstack/service-automation`: the limit counts through its `rate-limit-storage` fixed-window counter, the repository's one counter, as `@objectstack/service-sms` does. This is a new workspace edge, `service-automation` → `plugin-auth`, and no cycle: neither `plugin-auth` nor what it depends on (`service-messaging`, `rest`) depends on `service-automation`.

<!-- 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). -->
45 changes: 45 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,51 @@ there — write the value itself (`sourceObject: 'crm_case'`). A `{…}` token
there is refused, since it would reach the messaging service with its braces,
and so is an envelope. The node's `actionUrl` is a text slot (below).

**A `notify` node mails a person who is not a user through an email field.**
An applicant who came in through a public form has no account, so no user id
reaches them. Write `recipients: { emailField: 'applicant_email' }` — the whole
value, never a list entry — naming an `email`-typed field of the record that
triggered the flow, with `channels: 'email'`:

```ts
{
id: 'tell_applicant', type: 'notify', label: 'Tell the applicant',
config: {
recipients: { emailField: 'applicant_email' },
channels: 'email',
title: 'We received your application, {{ record.company_name }}',
message: 'We will reply within five working days.',
},
}
```

The address is read from the **stored** record when the node runs, and it
must be exactly one plain ASCII mailbox (`name@example.com`): a list, a
display name, a separator or a line break fails the node and nothing is sent,
and so do an address the `email` field type accepts but this node does not (a
non-ASCII character, a quoted local part, an IP-literal domain), an empty field
and a run with no trigger record. The title and message are
rendered as for any notify, the send goes through the deployment's outbound
mail, and it is recorded like every notification: a `sys_notification` event
and a `sys_notification_delivery` row whose recipient is the address. One
address receives at most 5 such emails per hour, across every flow; a sixth
fails the node, so a form a visitor fills in cannot mail one inbox without
end. The limit is best-effort: when its counter in the kernel `cache` service
faults, the send is admitted and a warning is logged once. `objectstack validate` refuses a field the object does not have, a field
that is not `email`-typed, another channel, and a flow with no triggering
record.

In a node whose `channels` include `email`, an address a **value expression
computes** must belong to a user, written bare or as `user:ADDRESS`: an
envelope that evaluates to a user's address reaches that user, and one that
evaluates to an address no user holds
fails the node, pointing to `{ emailField }` — an expression over record text
would otherwise let whoever typed that text choose who the flow mails. A node
that does not name `email` (`inbox` alone, the default) mails nobody, so its
computed recipients resolve as before. An address **written out** in the flow
(`recipients: ['ops@example.com']`) is the author's constant and is sent as
written.

**A CRUD node's filter: each operand computes before the query, and the query
placeholders stay.** In a `get_record`, `update_record` or `delete_record`
`filter`, each **operand** — a field's value, an operator's comparand, each
Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
},
Expand All @@ -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:*",
Expand Down
Loading
Loading