---
title: "Rules"
description: "Every operation in this group: what it accepts, what it returns and the errors it can answer with."
url: "https://openemail.uk/docs/api/reference/rules"
area: "API"
category: "Reference"
---

# Rules

Every operation in this group: what it accepts, what it returns and the errors it can answer with.

## Operations

What should happen to mail before anybody reads it. Conditions over one inbound message, and what to do with anything matching them.

A rule is a FLAT list of conditions joined by `match: "all"` or `"any"`, with a per-condition `negate`. That is AND, OR and NOT, and what it cannot express in one rule is `(A AND B) OR C`, which is two rules, and two rules is how a person reads it back six months later anyway. The three vocabularies are published as enums on `RuleCondition` and `RuleAction`, derived from the schema that validates them, so a generated client can switch on them.

Rules run in `position` order and a matching rule carrying `stopProcessing` ends the pass, so ORDER is part of the meaning. `GET /rules` therefore comes back in evaluation order rather than newest-first, and `POST /rules/reorder` demands the whole order rather than accepting a partial one.

Two actions cost real money and are worth reading about before you use them. A `forward` does not relay the original message: it REBUILDS the mail through the send path, so the original DKIM signature and anything above the outbound size ceiling do not survive. It also needs consent from the destination. Until that address has agreed to receive mail forwarded from this mailbox, the action is recorded as refused rather than sent. A `reply` can loop: two mailboxes each with a reply rule will mail one another forever, so anything carrying `Auto-Submitted`, `Precedence: bulk`, a `List-Id`, a `List-Unsubscribe` or a null return-path is never answered, and no sender gets more than one automatic reply a day. `POST /rules/{id}/test` reports both as warnings.

A `reject` (a 550 at SMTP) may only be written against `envelope_from`. A refusal decided on a `From:` header is answered by the mailing list rather than by the sender, which reads it as a bouncing subscriber and unsubscribes the reader.

### `GET /rules`

List rules

In EVALUATION order (the order the rules actually run in), and not newest-first like every other list here. Rule 4 exists to run after rule 3, and a list sorted by time would show you a sequence that is not the sequence your mail goes through.

A key limited to particular addresses or domains lists only the rules that can act on mail delivered to them: every rule without a `delivered_to` condition, and a rule with one when it can match an address the key holds.

Requires the `rules:read` scope.

- Scopes: `rules:read`.

**Query parameters**

- `limit` (`integer`, at least 1, at most 100, default `25`): Rows per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`string`): The `nextCursor` you were handed, never one you build. It is opaque and holds where the last rule sat in `(position, createdAt, id)`, the same tuple the list is ordered by, because `position` alone is not unique. A rule deleted, moved or renamed between pages never breaks the walk: the next page starts at the first rule that sorts after the cursor. A value this list did not hand out is a 400 `invalid_cursor`.
- `enabled` (`string`, one of `"true"`, `"false"`): Send the word `true` or the word `false`. Worth stating, because the obvious coercion would make `?enabled=false` mean true and hand you back exactly the rules you were trying to exclude.

**Returns**

- `200` `RuleList`: A page, in evaluation order.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.list()`](https://openemail.uk/docs/sdk/reference/rules#list), [`rules.listAll()`](https://openemail.uk/docs/sdk/reference/rules#listAll), [`rules.iterate()`](https://openemail.uk/docs/sdk/reference/rules#iterate); CLI [`openemail rules list`](https://openemail.uk/docs/cli/reference/rules#rules-list); MCP [`listRules`](https://openemail.uk/docs/mcp/tools/rules#listRules).

### `POST /rules`

Create a rule

The rule appends to the end of the list and is ON unless you say otherwise. `position` is not accepted here: a new rule goes last, and a caller who wants it somewhere else calls `POST /rules/reorder`, which is the only operation that can guarantee the result is the order somebody asked for.

A `reject` action is refused unless the rule also carries an `envelope_from` condition, and that refusal is not negotiable. A 550 answered on the strength of a `From:` header is read by a mailing list as a bouncing subscriber, and it unsubscribes the reader, so identity for the purposes of refusing mail means the SMTP envelope and nothing else.

No `Idempotency-Key`. There is nothing on this resource to claim one against, and a retry after a lost response mints a second rule unless the name collides.

A key limited to particular addresses or domains creates only a rule that acts on nothing but mail delivered to them, so the rule needs a `delivered_to` condition that no other address in the workspace matches. `equals` naming one of its addresses, or `matches` naming a whole domain it holds, does that. Anything else is a 422 `capability_unsupported` on `conditions`.

Requires the `rules:write` scope.

- Scopes: `rules:write`.
- Asks an OAuth access token for a verification code.

**Request body**

- `name` (`string`, required, 1 to 100 characters): Display name, 1 to 100 characters after trimming and unique per mailbox.
- `description` (`string`, nullable, up to 500 characters): Free text note, at most 500 characters.
- `enabled` (`boolean`, default `true`): Whether the rule runs on arriving mail. Defaults to true.
- `match` (`string`, one of `"all"`, `"any"`, default `"all"`): Whether every condition or any single one must hold. Defaults to `all`.
- `conditions` (`object[]`, required, 1 to 20 items): 1 to 20 conditions, each `{ field, op, value }` with optional `header` (at most 128 characters) and `negate`. `value` is at most 512 characters.
  - `field` (`string`, required, one of `"from"`, `"from_domain"`, `"envelope_from"`, `"to"`, `"cc"`, `"bcc"`, `"recipient"`, `"reply_to"`, `"delivered_to"`, `"subject"`, `"body"`, `"header"`, `"list_id"`, `"attachment_name"`, `"attachment_type"`, `"has_attachment"`, `"attachment_size"`, `"message_size"`, `"spam"`, `"hour"`, `"weekday"`)
  - `op` (`string`, required, one of `"matches"`, `"contains"`, `"equals"`, `"starts_with"`, `"ends_with"`, `"gt"`, `"lt"`)
  - `value` (`string`, required, up to 512 characters)
  - `header` (`string`, up to 128 characters)
  - `negate` (`boolean`, default `false`)
- `actions` (`object[]`, required, 1 to 10 items): 1 to 10 actions, each `{ type, value }`, with `value` at most 320 characters.
  - `type` (`string`, required, one of `"label"`, `"remove_label"`, `"archive"`, `"mark_read"`, `"star"`, `"spam"`, `"trash"`, `"forward"`, `"reply"`, `"block_sender"`, `"reject"`)
  - `value` (`string`, up to 320 characters)
- `stopProcessing` (`boolean`, default `false`): When true, no later rule runs on a message this rule matched. Defaults to false.

**Returns**

- `201` `Rule`: Created, at the end of the list.

**Errors**

- `403`: The key lacks the scope, or may not send as that address. `step_up_required`: the call was made with an OAuth access token that has not been verified in the last 60 minutes. Ask for a code with `POST /security/step-up`, send it to `POST /security/step-up/verify`, then repeat the call. The person can also choose Allow changes for 60 minutes on the app in Account settings, Connected apps, on the OpenEmail website. An API key is never asked for a code.
- `409`: `rule_name_taken`. Names are unique per mailbox.
- `422`: `invalid_rule` with `param` naming the offending path, `reject_needs_envelope`, `workspace_limit_reached` when the workspace is at its rule limit, or `capability_unsupported` on `conditions` when a key limited to particular addresses or domains creates a rule that could act on mail to an address outside them.
- The errors every operation can return: `400`, `401`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.create()`](https://openemail.uk/docs/sdk/reference/rules#create); CLI [`openemail rules create`](https://openemail.uk/docs/cli/reference/rules#rules-create); MCP [`createRule`](https://openemail.uk/docs/mcp/tools/rules#createRule).

### `GET /rules/runs`

What rules have actually done

The log of every rule that matched an inbound message, with what it did and what it was refused. Nothing here is deleted on a timer.

It survives the rule: `ruleName` and `actions` are copied onto the row when the rule runs, so a renamed or deleted rule still reads correctly here. Deleting a rule does not delete its history, because what happened does not stop being true.

A key limited to particular addresses or domains reads only the runs of rules it can list in `GET /rules`, so the runs of a rule deleted since, whose conditions are gone, are left out for such a key.

Requires the `rules:read` scope.

- Scopes: `rules:read`.

**Query parameters**

- `limit` (`integer`, at least 1, at most 100, default `25`): Rows per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`string`): The `nextCursor` from the previous page. Never build one yourself.
- `ruleId` (`string`): Narrow to one rule. Works for a rule that has since been deleted.
- `threadId` (`string`): The other question people ask: why did THIS message end up here.

**Returns**

- `200` `RuleRunList`: The audit, newest first.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.listRuns()`](https://openemail.uk/docs/sdk/reference/rules#listRuns), [`rules.listAllRuns()`](https://openemail.uk/docs/sdk/reference/rules#listAllRuns), [`rules.iterateRuns()`](https://openemail.uk/docs/sdk/reference/rules#iterateRuns); CLI [`openemail rules list-runs`](https://openemail.uk/docs/cli/reference/rules#rules-list-runs); MCP [`listRuleRuns`](https://openemail.uk/docs/mcp/tools/rules#listRuleRuns).

### `POST /rules/reorder`

Set the whole order

The body IS the order, so it must name every rule on the mailbox. Applied in one statement inside a transaction: either the whole order lands or none of it does, and there is no window in which half the rules have moved.

A key limited to particular addresses or domains names every rule `GET /rules` lists for it, and nothing else. It may move the rules that act on nothing but mail delivered to its addresses, anywhere among the others. Every other rule has to stay in the order it is in, and the rules it cannot list keep their places around them, so mail to any other address meets the same rules in the same order as before. The answer lists only the rules the key can see.

Idempotent by construction (applying the same body twice lands in the same place), which is why this is the one write on the resource that is safe to retry.

Requires the `rules:write` scope.

- Scopes: `rules:write`.

**Request body**

- `ruleIds` (`string[]`, required, 1 to 100 items): EVERY rule on the mailbox, in the order you want them evaluated. A list that omits one is refused with `incomplete_order` and nothing is written. The omitted rules would have to be put somewhere and there is no right answer for where.

**Returns**

- `200` `RuleList`: Every rule, in its new order.

**Errors**

- `422`: `incomplete_order`, or `capability_unsupported` when a key limited to particular addresses or domains moves a rule that can act on mail to an address outside them. An id that is not on this mailbox, or that the key cannot list, is a 404 and nothing moves.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.reorder()`](https://openemail.uk/docs/sdk/reference/rules#reorder); CLI [`openemail rules reorder`](https://openemail.uk/docs/cli/reference/rules#rules-reorder); MCP [`reorderRules`](https://openemail.uk/docs/mcp/tools/rules#reorderRules).

### `GET /rules/{id}`

Retrieve a rule

A key limited to particular addresses or domains gets a 404 for a rule that cannot act on mail delivered to them, the same rules `GET /rules` leaves out for it.

Requires the `rules:read` scope.

- Scopes: `rules:read`.

**Path parameters**

- `id` (`string`, required): A `rul_` id. Rules have no slug. The name is editable and not a handle.

**Returns**

- `200` `Rule`: The rule.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.get()`](https://openemail.uk/docs/sdk/reference/rules#get); CLI [`openemail rules get`](https://openemail.uk/docs/cli/reference/rules#rules-get); MCP [`getRule`](https://openemail.uk/docs/mcp/tools/rules#getRule).

### `PATCH /rules/{id}`

Update a rule

`conditions` and `actions` are REPLACE-WHOLE. Sending `conditions` replaces all of them; there is no way to add or remove one, and there will not be. Both arrays are ordered, `match` and `stopProcessing` are read against all of them at once, and an index-addressed patch would be a lost update the moment two tabs are open.

The patch is merged onto the stored rule and the WHOLE result revalidated, so an omitted field keeps its stored value rather than collecting a default. A `PATCH` that only renames a rule cannot switch a disabled one back on. It is also the only way the cross-field checks can run at all: a patch carrying nothing but a `reject` action has no conditions of its own to check it against.

A key limited to particular addresses or domains changes only the rules that act on nothing but mail delivered to them: a rule it creates, and a rule after it patches one, needs a `delivered_to` condition that no other address in the workspace matches, and anything else is a 422 `capability_unsupported`. A rule the key cannot list in `GET /rules` is a 404.

Requires the `rules:write` scope.

- Scopes: `rules:write`.
- Asks an OAuth access token for a verification code.

**Path parameters**

- `id` (`string`, required): A `rul_` id. Rules have no slug. The name is editable and not a handle.

**Request body**

- `name` (`string`, 1 to 100 characters): Replacement name, 1 to 100 characters and unique per mailbox.
- `description` (`string`, nullable, up to 500 characters): Replacement note of at most 500 characters, or null to clear it.
- `enabled` (`boolean`, default `true`): Turns the rule on or off without moving it.
- `match` (`string`, one of `"all"`, `"any"`, default `"all"`): Whether every condition or any single one must hold.
- `conditions` (`object[]`, 1 to 20 items): Complete replacement list of 1 to 20 conditions.
  - `field` (`string`, required, one of `"from"`, `"from_domain"`, `"envelope_from"`, `"to"`, `"cc"`, `"bcc"`, `"recipient"`, `"reply_to"`, `"delivered_to"`, `"subject"`, `"body"`, `"header"`, `"list_id"`, `"attachment_name"`, `"attachment_type"`, `"has_attachment"`, `"attachment_size"`, `"message_size"`, `"spam"`, `"hour"`, `"weekday"`)
  - `op` (`string`, required, one of `"matches"`, `"contains"`, `"equals"`, `"starts_with"`, `"ends_with"`, `"gt"`, `"lt"`)
  - `value` (`string`, required, up to 512 characters)
  - `header` (`string`, up to 128 characters)
  - `negate` (`boolean`, default `false`)
- `actions` (`object[]`, 1 to 10 items): Complete replacement list of 1 to 10 actions.
  - `type` (`string`, required, one of `"label"`, `"remove_label"`, `"archive"`, `"mark_read"`, `"star"`, `"spam"`, `"trash"`, `"forward"`, `"reply"`, `"block_sender"`, `"reject"`)
  - `value` (`string`, up to 320 characters)
- `stopProcessing` (`boolean`, default `false`): When true, no later rule runs on a message this rule matched.
- `position` (`integer`, at least 0, at most 1000000): Move this rule without renumbering the others. Positions are not unique and the order is total on `(position, createdAt, id)`, so landing on another rule leaves the tie broken by age. Use `POST /rules/reorder` when you care about the exact sequence.

**Returns**

- `200` `Rule`: Saved.

**Errors**

- `403`: The key lacks the scope, or may not send as that address. `step_up_required`: the call was made with an OAuth access token that has not been verified in the last 60 minutes. Ask for a code with `POST /security/step-up`, send it to `POST /security/step-up/verify`, then repeat the call. The person can also choose Allow changes for 60 minutes on the app in Account settings, Connected apps, on the OpenEmail website. An API key is never asked for a code.
- `409`: `rule_name_taken`.
- `422`: `invalid_rule` or `reject_needs_envelope`, with `param` naming the field, or `capability_unsupported` when a key limited to particular addresses or domains patches a rule that can act on mail to an address outside them, or would leave it able to.
- The errors every operation can return: `400`, `401`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.update()`](https://openemail.uk/docs/sdk/reference/rules#update); CLI [`openemail rules update`](https://openemail.uk/docs/cli/reference/rules#rules-update); MCP [`setRuleEnabled`](https://openemail.uk/docs/mcp/tools/rules#setRuleEnabled), [`updateRule`](https://openemail.uk/docs/mcp/tools/rules#updateRule).

### `DELETE /rules/{id}`

Delete a rule

There is no undo, and mail already filed stays where the rule put it. `PATCH` with `enabled: false` is the reversible version of this, and it keeps the rule where it is in the order.

A key limited to particular addresses or domains deletes only a rule that acts on nothing but mail delivered to them. Another rule it can list is a 422 `capability_unsupported`, and one it cannot list is a 404.

The run history is NOT deleted with it. `GET /rules/runs` still shows what this rule did, under the name it had at the time.

A tombstone rather than a 204, matching the rest of the API: the id comes back so a log line can name what went.

Requires the `rules:write` scope.

- Scopes: `rules:write`.

**Path parameters**

- `id` (`string`, required): A `rul_` id. Rules have no slug. The name is editable and not a handle.

**Returns**

- `200` `object`: Deleted.
  - `object` (`string`, one of `"rule"`)
  - `id` (`string`)
  - `deleted` (`boolean`, one of `true`)

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.delete()`](https://openemail.uk/docs/sdk/reference/rules#delete); CLI [`openemail rules delete`](https://openemail.uk/docs/cli/reference/rules#rules-delete); MCP [`deleteRule`](https://openemail.uk/docs/mcp/tools/rules#deleteRule).

### `POST /rules/{id}/test`

See what a rule would catch

A dry run. It changes nothing, sends nothing and files nothing, which is the whole promise of the endpoint, and why it takes `rules:read` rather than `rules:write`. Works on a disabled rule, which is the intended order of operations: write it, test it, then turn it on.

A key limited to particular addresses or domains tests only a rule it can list, and only against conversations delivered to its addresses: `threadIds` naming any other conversation are skipped.

The body is optional; posting none means the defaults. Read `warnings` before reading `matched`. A stored message no longer carries the SMTP envelope or any header, so a rule leaning on those is being tested against a question that was never asked, and under `negate` that reads as a match rather than as a miss. A rule reading the `body` can hit the same trap by a different road: mail that arrived already encrypted is scanned like any other message, but its body is withheld from the engine here exactly as it is at delivery, so `body_encrypted` means "this could not be evaluated against those messages" rather than "this would not have matched them".

It is a sample of what is currently in the inbox, not a replay of history: a message an existing rule already archived is not in the inbox and will not appear here. `GET /rules/runs` is the record of what actually happened.

There is deliberately no `POST /rules/{id}/run`. Applying a rule retroactively across a mailbox is unbounded, irreversible and has no undo, and the filing path it would go through rewrites each thread it touches and bumps it to the top of the inbox, so "tidy up my old receipts" would present as every old receipt arriving again.

Requires the `rules:read` scope.

- Scopes: `rules:read`.

**Path parameters**

- `id` (`string`, required): A `rul_` id. Rules have no slug. The name is editable and not a handle.

**Request body**

- `threadIds` (`string[]`, up to 50 items): Specific threads to try, instead of the recent window. A thread that will not load is skipped rather than failing the request, so an id that no longer exists costs nothing.
- `days` (`integer`, at least 1, at most 365, default `30`): How far back the window reaches. Ignored when `threadIds` is given.
- `limit` (`integer`, at least 1, at most 200, default `50`): How many threads to read. Bounded because every one is a round trip and a Worker has a wall-clock budget.

**Returns**

- `200` `RuleTest`: What it would have caught, and what it would do.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`rules.test()`](https://openemail.uk/docs/sdk/reference/rules#test); CLI [`openemail rules test`](https://openemail.uk/docs/cli/reference/rules#rules-test); MCP [`testRule`](https://openemail.uk/docs/mcp/tools/rules#testRule).

### Objects

#### `Rule`

`object`

- `object` (`string`, one of `"rule"`)
- `id` (`string`): The durable handle, `rul_` + 24 hex.
- `name` (`string`): Unique per mailbox. Creating a second rule with the same name is a 409, not a silent duplicate. It is also what the audit records, so a rule renamed after the fact keeps the old name on the runs it already produced.
- `description` (`string`, nullable)
- `enabled` (`boolean`): A disabled rule is skipped at delivery and keeps its position. `POST /rules/{id}/test` still works on one, which is the point: write it, see what it would have caught, then turn it on.
- `position` (`integer`): Where it sits in the pass. Not unique and not necessarily contiguous. The order is total on `(position, createdAt, id)`, so two rules sharing a position are broken by age rather than arbitrarily. Move one with `PATCH`, set them all with `POST /rules/reorder`.
- `match` (`string`, one of `"all"`, `"any"`): AND or OR across the conditions. NOT is per condition, as `negate`.
- `conditions` (`RuleCondition[]`)
- `actions` (`RuleAction[]`)
- `stopProcessing` (`boolean`): End the pass here when this rule matches. Later rules do not run at all, which is how a catch-all at the bottom of the list stays a catch-all.
- `lastMatchedAt` (`string`, nullable, format `date-time`)
- `matchCount` (`integer`): How many messages this rule has caught, ever. The cheap answer to "is this rule doing anything": a count still at zero after a month is a rule whose conditions never hold, and finding that out should not cost a walk through `GET /rules/runs`.
- `createdAt` (`string`, format `date-time`)
- `updatedAt` (`string`, format `date-time`)

#### `RuleAction`

`object`

- `type` (`string`, required, one of `"label"`, `"remove_label"`, `"archive"`, `"mark_read"`, `"star"`, `"spam"`, `"trash"`, `"forward"`, `"reply"`, `"block_sender"`, `"reject"`)
- `value` (`string`, up to 320 characters)

#### `RuleCondition`

`object`

- `field` (`string`, required, one of `"from"`, `"from_domain"`, `"envelope_from"`, `"to"`, `"cc"`, `"bcc"`, `"recipient"`, `"reply_to"`, `"delivered_to"`, `"subject"`, `"body"`, `"header"`, `"list_id"`, `"attachment_name"`, `"attachment_type"`, `"has_attachment"`, `"attachment_size"`, `"message_size"`, `"spam"`, `"hour"`, `"weekday"`)
- `op` (`string`, required, one of `"matches"`, `"contains"`, `"equals"`, `"starts_with"`, `"ends_with"`, `"gt"`, `"lt"`)
- `value` (`string`, required, up to 512 characters)
- `header` (`string`, up to 128 characters)
- `negate` (`boolean`, default `false`)

#### `RuleList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Rule[]`)
- `hasMore` (`boolean`): True when another page follows. Pass `nextCursor` back as `cursor` to read it.
- `nextCursor` (`string`, nullable): An opaque cursor for the next page, or null on the last page. Pass it back unchanged.

#### `RuleRun`

`object`

- `object` (`string`, one of `"rule_run"`)
- `id` (`string`): `rrun_` + 24 hex.
- `ruleId` (`string`): The rule as it was then. It may since have been deleted; this is not a link.
- `ruleName` (`string`): The rule's name AT THE TIME, not its name now.
- `threadId` (`string`)
- `messageId` (`string`, nullable)
- `sender` (`string`): Lower-cased and trimmed, so it can be counted on.
- `subject` (`string`): Truncated at 500 characters. A header is unbounded.
- `actions` (`string[]`): What was APPLIED, as `type` or `type:value`. Only the actions that actually took effect.
- `failures` (`string[]`): What was refused, as `type: reason`. Read this before concluding a rule did nothing. The commonest entry is an auto-reply the loop guard declined to send, and without this column "the rule replied" and "the rule was not allowed to reply" look identical from outside.
- `createdAt` (`string`, format `date-time`)

#### `RuleRunList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`RuleRun[]`)
- `hasMore` (`boolean`)
- `nextCursor` (`string`, nullable)

#### `RuleTest`

`object`

- `object` (`string`, one of `"rule_test"`)
- `ruleId` (`string`)
- `scanned` (`integer`): Messages actually examined, one per thread. Lower than `limit` when the window held fewer threads, or when a thread would not load and was skipped rather than failing the run.
- `matched` (`integer`)
- `wouldApply` (`string[]`): The rule's actions as `type` or `type:value`, which is what it would do to each match, in declaration order. The same strings the MCP `testRule` tool prints, from the same function, so the two cannot describe one rule differently.
- `messages` (`object[]`): The matches, newest first.
  - `threadId` (`string`)
  - `from` (`string`)
  - `subject` (`string`)
  - `receivedAt` (`string`, nullable, format `date-time`)
- `warnings` (`object[]`): Read these before reading `matched`. Treat an unrecognised code as a warning.
  - `code` (`string`, one of `"field_unevaluable"`, `"field_approximate"`, `"body_encrypted"`, `"forward_unverified"`, `"forward_loop"`): `field_unevaluable`: the rule asks about something a STORED message no longer carries (the SMTP envelope, any header, which alias it arrived at, the size on the wire), so that condition was not answered here and the dry run is quieter than delivery would be. Worse in the other direction: an unanswered condition under `negate: true` matches everything in the sample and a fraction of real mail. `field_approximate`: the condition WAS evaluated, and answered off stored mail rather than off the wire, so the number beside it can differ from what real delivery does. Deliberately a separate code rather than a second meaning for `field_unevaluable`: that one says the condition was SKIPPED, and a caller told a condition was skipped when it was in fact answered discounts the count in the wrong direction. Two things read differently. Images embedded in a message body are dropped from the stored attachment list and counted by delivery, so `has_attachment`, `attachment_name`, `attachment_type` and `attachment_size` can report lower here. A rule tested over a month of HTML newsletters whose only part is a `cid:` signature logo matches nothing here and then archives every one of them. And `hour` and `weekday` are scored from the sender-supplied `Date:` header, which is the only time a stored message keeps, where delivery scores them from our own receipt clock. Neither is recoverable from a stored message, so the field is named rather than silently answered wrongly. `body_encrypted`: the rule reads the `body`, and at least one message in the sample arrived already encrypted. A third statement again, and not a weaker version of either of the two above: a sealed body is withheld from the engine HERE AND AT DELIVERY ALIKE, so the condition really was evaluated and the answer really is the one real mail will get. What it was evaluated against is nothing. A `contains` cannot hold on such a message however plainly the word is written inside it, and a `negate: true` holds on every one of them. Fires only when both halves were measured: the rule asks about the body, and the scan met a sealed message. A SIGNED message is not one of these; `pgp-signed` and `smime-signed` carry their body in the clear and are read normally. `forward_unverified`: the destination is not an address we host, so the copy is REBUILT by the send path rather than relayed and the original DKIM signature does not survive. `forward_loop`: following the chain comes back to this mailbox.
  - `value` (`string`): The field name, or the address, depending on the code.
