diff --git a/.changeset/25696-notify-recipients-describe.md b/.changeset/25696-notify-recipients-describe.md new file mode 100644 index 00000000000..3e6ce36e3df --- /dev/null +++ b/.changeset/25696-notify-recipients-describe.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +--- + +docs(spec): a `notify` node's `recipients` names an email address written out as an author constant, not an audience selector, and states the computed-address and email-field behaviour the runtime makes + +Clause-②: no +Metadata-protocol: semantic +Ruling-ref: 6386629237 + +The `recipients` text of a `notify` node config now says what an email address in `recipients` means, and what the notify node does with one. That text is the field's `describe()` (which the reference docs and the JSON Schema read), the union's description, and the sentence a refused `recipients` value leads with. + +- **Written out as a string, it is an author constant.** `recipients: 'ops@example.com'` is no audience selector. It is sent as written: to the user who holds that address, or else, on the `email` channel, to the address itself. Nothing about it changes; the text no longer calls it an audience selector. The audience selectors are the prefixed forms, such as `team:ID`. +- **An address a CEL envelope evaluates to is not one.** In a node whose `channels` include `email`, a computed recipient that reads as an address, bare or as `user:ADDRESS`, must belong to a user. One a user holds resolves to that user. One that no user holds fails the node, and the message points to `{ emailField }`. A node that does not name `email` (`inbox` alone, the default) mails nobody, so its computed recipients go to the messaging service as before. The screen is scoped to nodes that include the `email` channel (confirmed by the maintainer). +- **`{ emailField }` is delivered.** The text no longer says delivery is not live. The address is read from the stored trigger record when the node runs. It must be one plain ASCII mailbox, or the node fails and nothing is sent. One address receives at most 5 such emails per hour; the limit is best-effort. + +No key, shape, default or accepted value changes. Everything that parsed still parses, and authored metadata needs no change. diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index e877c00afb5..55d4c0ddccf 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -95,7 +95,7 @@ const result = HttpConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **recipients** | `string \| (string \| { dialect: 'cel'; source: string; ast?: any; meta?: object })[] \| { dialect: 'cel'; source: string; ast?: any; meta?: object } \| { emailField: string }` | ✅ | Who the notification goes to: a recipient (a user id, or an audience selector such as `team:ID`) as a string; a list of them, each 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 one that evaluates to null adds none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the whole value, `{ emailField: 'applicant_email' }`: the address in that `email`-typed field of the record that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. 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. | +| **recipients** | `string \| (string \| { dialect: 'cel'; source: string; ast?: any; meta?: object })[] \| { dialect: 'cel'; source: string; ast?: any; meta?: object } \| { emailField: string }` | ✅ | Who the notification goes to: a recipient (a user id, or an audience selector such as `team:ID`) as a string; a list of them, each 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 one that evaluates to null adds none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the whole value, `{ emailField: 'applicant_email' }`: the address in that `email`-typed field of the record that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. The address is read from the stored record when the node runs and must be one plain ASCII mailbox, or the node fails and nothing is sent; one address receives at most 5 such emails per hour, best-effort. An email address written out as a string is no audience selector but an author constant, sent as written: to the user who holds it, or else, on the `email` channel, to the address itself. An address an envelope evaluates to is not one: in a node whose `channels` include `email`, it must be a user's, written bare or as `user:ADDRESS` — one a user holds resolves to that user, and one that no user holds fails the node, pointing to `{ emailField }`. A node that does not name `email` mails nobody, and its computed recipients go to the messaging service as before. | | **title** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is rendered per run with `{{ }}` holes over the flow's variables — a variable path with an optional formatter (`{{ record.name }}`, `{{ record.amount \| currency }}`); a single-brace `{token}` is refused. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | | **message** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), rendered per run with `{{ }}` holes like `title`; not localizable. Only valid with inline `title`, never with `template`. | | **template** | `string` | optional | Email template name (an `email_template` item's `name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` among the email templates — built-in, declared by an app, or authored in Studio — at delivery time and renders subject/body from that member. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different members of the same bundle. The node's `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index f0cdbbfe67a..65a51335550 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -248,22 +248,27 @@ function notifyValuePhrase(value: unknown): string { // ─── notify email-field recipient (ruling 6377731715 on #25496, letter B) ── // -// The one recipient form that is an email ADDRESS rather than a user: the -// address held in an `email`-typed field of the record that triggered the -// flow — for a person who is not a user, such as an applicant who came in -// through a public form (ADR-0138 guest model). It NAMES the field, so a free -// string or an expression can never parse as it: a string in `recipients` is -// a user id or an audience selector, an envelope is a CEL value, and this arm -// is a closed object whose one key holds a field's machine name. Which field -// that is, and that it is `email`-typed, is judged where the flow's trigger -// and the object's fields meet — `@objectstack/lint`'s +// The recipient form for an email ADDRESS the RECORD supplies, rather than a +// user: the address held in an `email`-typed field of the record that +// triggered the flow — for a person who is not a user, such as an applicant +// who came in through a public form (ADR-0138 guest model). (An address the +// AUTHOR writes out as a string is the other way to one: an author constant, +// sent as written — ruling 6386629237 on #25647, letter B; see the +// `recipients` docblock below.) It NAMES the field, so a free string or an +// expression can never parse as it: a string in `recipients` is a user id, an +// audience selector or an address written out, an envelope is a CEL value, +// and this arm is a closed object whose one key holds a field's machine +// name. Which field that is, and that it is `email`-typed, is judged where +// the flow's trigger and the object's fields meet — `@objectstack/lint`'s // `validateNotifyEmailFieldRecipients`, a reference-integrity rule, so every // authoring command and the runtime publish door refuse it. // // It is the WHOLE `recipients` value, never a list entry, and the node names // `email` as its only channel (the `superRefine` of `NotifyConfigSchema`): an // address that no user holds has no inbox, so the email channel is the one -// that can deliver to it. +// that can deliver to it. The runtime delivers it since #25721 (`d1e329e96d`): +// service-automation's notify node reads the address off the STORED trigger +// row and limits sends per address (see the `recipients` docblock below). /** A field's machine name — the grammar of `FieldSchema.name`, which `emailField` names. */ const FIELD_MACHINE_NAME = /^[a-z_][a-z0-9_]*$/; @@ -321,9 +326,10 @@ function emailFieldRecipientRefusal(value: unknown): string { * recipient leads with. */ const NOTIFY_RECIPIENTS_RULE = - '`recipients` is who the notification goes to: a recipient — a user id, or an audience selector such as ' - + '`team:ID` — written as a string; a list of them, each a string or a CEL value envelope; one CEL value ' - + 'envelope, `{ dialect: \'cel\', source: \'…\' }`, that evaluates to a recipient or a list of them; or an ' + '`recipients` is who the notification goes to: a recipient — a user id, an audience selector such as ' + + '`team:ID`, or an email address written out, an author constant sent as written — as a string; a list of ' + + 'them, each a string or a CEL value envelope; one CEL value envelope, `{ dialect: \'cel\', source: \'…\' }`, ' + + 'that evaluates to a recipient or a list of them; or an ' + 'email-field recipient, `{ emailField: \'applicant_email\' }`, the address in an `email`-typed field of the ' + 'record that triggered the flow.'; @@ -352,7 +358,11 @@ function notifyRecipientsRefusal(input: unknown): string { * one value OR a list of them (#19939; the ledger's `eachElement`): * * - a **string** is one recipient as written — a user id, or an audience - * selector the messaging service resolves (`team:ID`, an email address); + * selector the messaging service resolves (such as `team:ID`); an email + * address written out as a string is no audience selector but an **author + * constant**, sent as written: to the user who holds it, or else, on the + * `email` channel, to the address itself (ruling 6386629237 on #25647, + * letter B); * - a **list** holds one entry per recipient, each a string as written or a * CEL value envelope evaluated per run — an entry that evaluates to a list * (a multi-user field, a list variable) adds EACH of its recipients, so a @@ -363,6 +373,27 @@ function notifyRecipientsRefusal(input: unknown): string { * the whole value only, names an `email`-typed field of the triggering * record whose address is the recipient. * + * An address a CEL value envelope EVALUATES to is not an author constant (the + * same ruling, made by #25721, `d1e329e96d`: `screenComputedAddresses` in + * service-automation's notify node). In a node whose `channels` include + * `email` — the one channel that sends to the address itself — a computed + * recipient that reads as an address, bare or as `user:ADDRESS`, must be a + * user's: one a user holds resolves to that user's id, and one that NO user + * holds fails the node with a located message pointing to `{ emailField }`, + * as does one that cannot be looked up (no data engine, a failing lookup). A + * node that does not name `email` (`inbox` alone, the messaging default) + * mails nobody, and its computed recipients reach the messaging resolver + * unscreened, as before. That channel scope is the confirmed reading of the + * ruling (ruling record 6393736930 on #25647: the maintainer chose to screen + * only nodes that include the `email` channel). + * + * The same change delivers an email-field recipient: the address is read from + * the STORED trigger row (never the run's `record` snapshot), must be one + * plain ASCII mailbox, and one address receives at most 5 such sends per hour + * across every flow — best-effort: a faulting `cache` counter admits the send + * and says so once at `warn`. Each way there is no address fails the node and + * sends nothing. + * * An envelope that evaluates to `null` or a blank string adds no recipient, * as a token that resolved to nothing did, and the executor's guard still * fails a node left with none, naming the sources that evaluated to nothing @@ -392,7 +423,9 @@ const NotifyRecipientsSlotSchema = z.union([ + 'string; a list of them, each 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); one envelope evaluating to a ' + 'recipient or a list; or `{ emailField }`, the address in an `email`-typed field of the triggering record ' - + '— a `{…}` template token is refused', + + '— a `{…}` template token is refused. An email address written out as a string is an author constant, ' + + 'sent as written; in a node whose `channels` include `email`, one an envelope evaluates to must be a ' + + 'user\'s: it resolves to that user, and one no user holds fails the node, pointing to `{ emailField }`', xExpression: 'value', }); @@ -635,8 +668,14 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ + 'none; or one envelope evaluating to a recipient or a list. A `{…}` template token is refused. Or, as the ' + 'whole value, `{ emailField: \'applicant_email\' }`: the address in that `email`-typed field of the record ' + 'that triggered the flow, for a person who is not a user — the node then names `email` as its only channel. ' - + '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.', + + 'The address is read from the stored record when the node runs and must be one plain ASCII mailbox, or the ' + + 'node fails and nothing is sent; one address receives at most 5 such emails per hour, best-effort. An email ' + + 'address written out as a string is no audience selector but an author constant, sent as written: to the ' + + 'user who holds it, or else, on the `email` channel, to the address itself. An address an envelope ' + + 'evaluates to is not one: in a node whose `channels` include `email`, it must be a user\'s, written bare or ' + + 'as `user:ADDRESS` — one a user holds resolves to that user, and one that no user holds fails the node, ' + + 'pointing to `{ emailField }`. A node that does not name `email` mails nobody, and its computed recipients ' + + 'go to the messaging service as before.', ), /** * Inline notification title — the NON-localizable content path, and a