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
33 changes: 33 additions & 0 deletions .changeset/body-mail-activity-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/spec': minor
'@objectstack/core': patch
'@objectstack/objectql': patch
'@objectstack/runtime': patch
'@objectstack/cli': patch
'@objectstack/rest': patch
'@objectstack/plugin-email': patch
'@objectstack/plugin-audit': patch
'@objectstack/plugin-security': patch
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(runtime): a script body sends mail and adds a timeline entry through platform doors that stamp who sent it

Clause-②: yes (widening)
Metadata-protocol: added

**BREAKING** (`@objectstack/plugin-email`): a `queued` row an app inserts into the mail log (`sys_email`, `sys_identity_email`) is sent from the configured sender, with no `From`, `Sender` or `Return-Path` header of its own, whatever sender the row names; a deployment with no configured sender refuses such a row. Migration: FROM inserting a mail-log row that names its own sender TO `ctx.email.send({ to, subject, text })` in a body that declares `email.send`, which sends from the configured sender with the calling user named in its display name. A row inserted with no sender of its own is delivered as before.

<!-- adr-0087: not-required (no-migration-prescription) no key, export or field is removed and no stored body changes shape; the change is which sender delivers a row an app inserted, and moving that app to the mail door is a code change no conversion can make for it -->

- **`@objectstack/spec`:** two new body capabilities, `email.send` and `activity.append`, and their inputs `BodyMailSendInputSchema` and `BodyActivityAppendInputSchema` (`@objectstack/spec/data`). The sender, the sending user, the organization, the actor and the origin are stamped by the platform and are not inputs: a key outside either shape is refused. Every string either input takes has an upper bound, and the limits that read across fields (`BODY_MAIL_RECIPIENT_LIMIT`, `BODY_MAIL_ADDRESS_LIST_LIMIT`, `BODY_MAIL_ADDRESS_LIMIT`, `BODY_ACTIVITY_METADATA_LIMIT`) are stated in each schema's description. `SYS_ACTIVITY_PLATFORM_TYPES` names the timeline types only the platform records. `SendEmailInput.requireLogRow` (`@objectstack/spec/contracts`) asks the mail service to send a message only if its log records it. A hook whose body is granted `email.send` is refused at parse (`HookSchema`): a hook body sends no mail.
- **`@objectstack/runtime`:** an action or a job body that declares the capability calls `ctx.email.send({ to, subject, text })`, and a hook, action or job body `ctx.activity.append({ object, recordId, type, summary })`. A message is sent from the configured sender, as the calling user, under the caller's organization; a timeline entry is written with the calling user as its actor and the package and body as its origin. A record the call points at must be one the caller can read, in the caller's own scope: for a hook, the user of the write that fired it, never the system. A message names at most 50 recipients, each recipient list and `replyTo` fits the mail log as recorded, a message is sent only if the mail log records it, and an organization's mail is held to `OS_EMAIL_APP_SEND_HOURLY_LIMIT` messages an hour (default 200) before the door refuses another. A message cannot be taken back, so a hook body cannot send mail: a hook body granted `email.send` is not bound, and a call is refused (`403 PERMISSION_DENIED`); an action or a job body sends it, and is refused with `409 RESOURCE_CONFLICT` while it runs inside a transaction. A timeline entry commits or rolls back with the write that runs the body. A door call inside the body's own `ctx.api.transaction` is refused with `409 RESOURCE_CONFLICT`. An entry's origin names the package that holds the body. A body with no calling user (a job) sends and writes with no user recorded.
- **`@objectstack/cli`:** the build infers both capabilities from a handler's calls, under a renamed or destructured context too. `ExtractedBody.capabilities` on `@objectstack/cli/hook-body` may now hold `'email.send'` and `'activity.append'`. A hook handler that calls `ctx.email.send` fails the build, which refuses the inferred grant.
- **`@objectstack/objectql`:** `seedHookUserGrants` and `seedHookUnreadMembership`, the second step of a `runAs: 'user'` derivation and its fail-safe, are exported beside `deriveHookApi`. `openTransactionKind` answers whether a write runs inside a transaction that a write beside it joins (`'joined'`), one it cannot reach (`'separate'`), or none.
- **`@objectstack/core`:** the bounds a caller-sent message is held to (the sending user, the organization, the configured sender) are one function, `boundCallerMail`, shared by the REST mail door and the body door.
- **`@objectstack/rest`:** the REST mail door reads its bounds from `boundCallerMail`; its answers are unchanged.
- **`@objectstack/plugin-email`:** a mail-log row the mail service did not write is delivered from the configured sender, with no sender header of its own (see BREAKING above). `send()` honours `requireLogRow`: when the row cannot be written, it refuses with `503 SERVICE_UNAVAILABLE` and delivers nothing.
- **`@objectstack/plugin-audit`:** the activity feed owns the door's writer, `appendAppActivity` on the `audit` service. An app's entry may not use a platform-only type or a platform metadata key, and may not be about an object the feed does not show or an append-only object. `sys_activity` gains a read-only `origin` column, empty on an entry not written through the activity door.
- **`@objectstack/plugin-security`:** inserting into an append-only object from app automation that runs as system is deprecated, and says so once per kind and object, naming the two doors.
- **`@object-ui/app-shell`:** the script capability picker lists both capabilities with their calls; on a hook it does not offer `email.send`, and flags a stored grant. `@object-ui/i18n` carries the flag's text in the studio catalogue (en, zh).
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ The script sees only what the surrounding `ctx` object exposes:
| `ctx.title('<lookup field>')` | The related record's title, through a lookup / master_detail / user / tree column. Costs one `findOne` (plus the lookups that record's own title reads through). | `api.read` |
| `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` |
| `ctx.log.{info,warn,error}` | Structured logging. | `log` |
| `ctx.email.send({ to, subject, text })` | Sends mail through the mail service, from the configured sender, as the calling user and under the caller's organization. An action or a job body only: a hook body cannot send mail, because a message cannot be taken back if the write that runs the hook rolls back. A hook whose body is granted `email.send` is refused when it is saved, validated, built or bound, and the Console does not offer the grant on a hook. Send mail on a record change from an action, a job or a flow. See [Email service](/docs/kernel/runtime-services/email-service#from-a-script-body). | `email.send` |
| `ctx.activity.append({ object, recordId, type, summary })` | Adds an entry to a record's activity timeline. The platform stamps the calling user as the actor and the package and body as the entry's `origin`. `type` may not be one only the platform records (`SYS_ACTIVITY_PLATFORM_TYPES`), the record must be one the caller can read, and `metadata` takes at most 8,000 characters as JSON. The entry commits or rolls back with the write that runs the body. Refused inside the body's own `ctx.api.transaction`. | `activity.append` |
| `ctx.connector(name).<method>(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |

### Write provenance — `ctx.provenance`
Expand Down
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ Link previews read the same values. The console document served at `/_console/`
| `OS_EMAIL_SMTP_SECURE` | boolean | `true` | Require TLS. On port `465` that means implicit TLS; on any other port a **required** STARTTLS upgrade (a server that will not upgrade fails the send instead of leaking credentials in the clear). `false` connects in the clear and upgrades only when STARTTLS is offered. |
| `OS_EMAIL_SMTP_USER` | string | — | SMTP AUTH username. Omit for servers that accept unauthenticated relay. |
| `OS_EMAIL_SMTP_PASSWORD` | string | — | SMTP AUTH password. |
| `OS_EMAIL_APP_SEND_HOURLY_LIMIT` | number | `200` | The most messages an organization's mail log may record in one hour before a script body's `ctx.email.send` is refused with `429 RATE_LIMIT_EXCEEDED`. A whole number, `0` or more; any other value fails the call. The count is read before each send, so it bounds volume rather than holding an exact number. See [Email service](/docs/kernel/runtime-services/email-service#from-a-script-body). |

> SMTP delivery ships in `@objectstack/plugin-email` (ADR-0012) and is
> implemented with `nodemailer`, imported lazily so deployments that never
Expand Down
22 changes: 22 additions & 0 deletions content/docs/kernel/runtime-services/email-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ services.email.sendTemplate(input: SendTemplateInput): Promise<SendEmailResult>

`replyTo` is not bounded. It is the caller's choice of Reply-To address and makes no claim about the sender.

## From a script body

An action or a job body sends mail with `ctx.email.send(input)` when it declares the `email.send` capability (`os build` infers it from the call). The input is `BodyMailSendInputSchema` (`@objectstack/spec/data`): `to`, `cc`, `bcc`, `replyTo`, `subject`, `text` and/or `html`, and `related`, the record the message is about.

- The message is sent from the configured sender. Its display name names the calling user beside the organization, for example `Ann Lee via Acme`. Replies go to `replyTo`.
- It is recorded as sent by the calling user and under the caller's organization, with the same bounds as the HTTP door. `from`, `sentBy`, `organizationId`, `headers` and `identityMail` are not inputs, and a call that names one is refused with `400 VALIDATION_ERROR`.
- `related` must be a record the caller can read, in the caller's own scope. Otherwise the call is refused with `404 RECORD_NOT_FOUND` and nothing is sent.
- A message names at most 50 recipients across `to`, `cc` and `bcc`. Each of the three takes at most 4,000 characters as the mail log records it (every recipient as `"Name" <address>`, joined by `, `), and a `replyTo` at most 320. The subject takes at most 998 characters, and `text` and `html` at most 1,000,000 each. A message over any of these is refused with `400 VALIDATION_ERROR`.
- While the organization's mail log records `OS_EMAIL_APP_SEND_HOURLY_LIMIT` messages (default 200) in the last hour, the call is refused with `429 RATE_LIMIT_EXCEEDED`. A caller with no organization is counted against the whole log. The count is read before the send, so two calls racing for the last message of the hour can both be sent.
- The message is sent only if the mail log records it. When the row cannot be written, the call is refused with `503 SERVICE_UNAVAILABLE` and nothing is sent.
- A message cannot be taken back, so a hook body cannot send mail: the write that runs the hook may still roll back. A hook whose body is granted `email.send` is refused when it is saved, validated, built or bound, and a call from a hook body is refused with `403 PERMISSION_DENIED`. Send mail on a record change from an action, a job or a flow.
- A call made while an action or a job runs inside a transaction, or inside the body's own `ctx.api.transaction`, is refused with `409 RESOURCE_CONFLICT`.
- A job body has no calling user, so its message is recorded with no `sentBy`.

The call answers `{ id, status }`. A deployment with no mail service answers `501 NOT_IMPLEMENTED`.

A row written into the mail log (`sys_email`, `sys_identity_email`) at `queued` by anything other than the mail service is sent from the configured sender, with no `From`, `Sender` or `Return-Path` header of its own, whatever the row said. Where no sender is configured, the mail log refuses such a row.

<Callout type="warn">
**Breaking for direct inserts.** Before this release, a `queued` row an app inserted into the mail log was sent from the `from_address` the row named. It is now sent from the configured sender. Send through `ctx.email.send`, which names the calling user in the sender's display name, instead of inserting the row.
</Callout>

## Returns

`SendEmailResult`:
Expand Down
6 changes: 4 additions & 2 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,16 @@ One of the largest consumers of the flag;

### 6. Reads that only carry the flag onward

Not behaviours: these four sites read the flag solely to copy it into another
context or envelope. They are listed because the census counts them, and because
Not behaviours: these five sites read the flag solely to copy it into another
context or envelope, or to take it off one. They are listed because the census counts them, and because
a reader tracing where elevation travels needs them.

| # | Symbol | Package | What it does |
|:--|:---|:---|:---|
| 63 | `buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 64 | `isSystem` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 65 | `executionContextFromHook` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
| 65b | `callerScopeOf` | runtime | Takes a script body's own elevation off its caller's envelope (the app-automation mark and the flag it came with), so `ctx.email.send` and `ctx.activity.append` read the record a body points at in the caller's scope. It removes the flag and grants nothing |
| 66 | `toolCallPrincipalOf` | mcp | Projects the host's resolved `ExecutionContext` into the frozen principal an installed MCP tool-call interceptor reads, carrying `isSystem: true` only for a system caller. It decides nothing: the tool still runs under its own context |

---
Expand Down Expand Up @@ -681,6 +682,7 @@ and CI refuses one that differs from what the tree produces.
| `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` | 2 | 50 |
| `packages/runtime/src/domains/security.ts#handleSecurityRequest` | 1 | 52 |
| `packages/runtime/src/flow-start-admission.ts#flowStartRefusal` | 1 | 57 |
| `packages/runtime/src/sandbox/body-doors.ts#callerScopeOf` | 1 | 65b |
| `packages/runtime/src/sandbox/body-runner.ts#executionContextFromHook` | 1 | 65 |
| `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner` | 1 | 60 |
| `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` | 1 | 46 |
Expand Down
Loading
Loading