Create a rule
Conditions on one side, actions on the other. Enabled unless you say otherwise.
Runs the real call against your workspace, with your own key.
POST /rules
Conditions on one side, actions on the other. Enabled unless you say otherwise.
Example
Needs rules:write. Returns 201. position is not accepted. A new rule appends to the end of the list, and moving it is POST /rules/reorder.
curl -X POST "$OE/rules" -H "$AUTH" -H "Content-Type: application/json" \ -d '{ "name": "Receipts to their own label", "match": "all", "conditions": [ { "field": "from_domain", "op": "matches", "value": "*.stripe.com" }, { "field": "subject", "op": "contains", "value": "receipt" } ], "actions": [ { "type": "label", "value": "USER_RECEIPTS" }, { "type": "archive" } ], "stopProcessing": true }'{ "object": "rule", "id": "rul_7f3a1c94e05d3862c1f0a44b", "name": "Receipts to their own label", "description": null, "enabled": true, "position": 3, "match": "all", "conditions": [ { "field": "from_domain", "op": "matches", "value": "*.stripe.com", "negate": false }, { "field": "subject", "op": "contains", "value": "receipt", "negate": false } ], "actions": [ { "type": "label", "value": "USER_RECEIPTS" }, { "type": "archive" } ], "stopProcessing": true, "lastMatchedAt": null, "matchCount": 0, "createdAt": "2026-08-30T10:41:02.000Z", "updatedAt": "2026-08-30T10:41:02.000Z"}A rule created here is ON, and starts acting on the next message. That is the right default for a call somebody made deliberately, and it is the opposite of the MCP createRule tool, which writes the same rule DISABLED because a model that decides to archive mail should not have it archiving before a person has read the rule back.
A duplicate name on the same connection is rule_name_taken, a 409. Names are how a rule is recognised in a run log and in the settings screen, so two rules called "Newsletters" is a report nobody can read.
The 101st rule is rule_limit_reached, a 422. The cap is a guard against a script in a loop rather than an accounting boundary, and it is not locked. Two creates racing at 99 can both succeed.
What a condition can ask
A condition is { field, op, value }, with an optional header naming which header to read and an optional negate. value is ALWAYS a string on the wire. Numeric fields are compared as numbers after Number(value), and the two boolean fields take the literal strings "true" and "false", because one field with one type is a schema an OpenAPI generator can describe and a union of three is not.
| Field | Reads | Operators |
|---|---|---|
| `from` | The From: header, normalised the way the blocklist normalises it. | text |
| `from_domain` | The domain of From: and its PARENTS, down to two labels: a message from mail.corp.example.com matches corp.example.com and example.com too, and matches nothing for com. | text |
| `envelope_from` | The SMTP MAIL FROM. Different from from on every mailing list, and the only identity a reject may be written against. | text |
| `to`, `cc`, `bcc` | Any one address in that header. | text |
| `recipient` | Any address in to, cc or bcc: the shorthand for all three. | text |
| `reply_to` | The Reply-To header. | text |
| `delivered_to` | The canonical address this copy was delivered to, plus-tag stripped and lower-cased, which is how a catch-all alias is matched. | text |
| `subject` | The subject line as it arrived. | text |
| `body` | The text part, or the HTML reduced to text. Capped, so a 20 MB body is not scanned in full. | text |
| `header` | Any header, named in the condition’s own header field. Required there and lower-cased before comparison. | text |
| `list_id` | The List-Id header: the handle a mailing list identifies itself by. | text |
| `attachment_name` | Any attachment’s filename. | text |
| `attachment_type` | Any attachment’s MIME type, e.g. application/pdf. | text |
| `has_attachment` | Whether there is one at all. | equals "true" / "false" |
| `spam` | The spam verdict the delivery path reached, before your rules ran. | equals "true" / "false" |
| `attachment_size` | An attachment’s size in bytes. A comparison matches when any one attachment satisfies it. | gt, lt, equals |
| `message_size` | The whole message on the wire, in bytes. | gt, lt, equals |
| `hour` | Hour of arrival, 0–23, UTC. | gt, lt, equals |
| `weekday` | Day of arrival, 0–6, Sunday is 0, UTC. | gt, lt, equals |
| Operator | What it does |
|---|---|
| `matches` | A glob, and only a glob: * for any run of characters, ? for one. No regular expressions. A pattern from an API client runs on the delivery path, and a catastrophically backtracking one there is a mailbox that stops receiving. |
| `contains` | Substring, case-insensitive. |
| `equals` | The whole value, case-insensitive. On a numeric field, numeric equality. |
| `starts_with` | Prefix, case-insensitive. |
| `ends_with` | Suffix, case-insensitive. |
| `gt`, `lt` | Numeric, on the four numeric fields only. A text field with gt never matches. |
A matches pattern has to carry at least two alphanumeric characters of its own, the same bar the blocklist applies. A bare * is refused at write time rather than accepted and then quietly matching every message that will ever arrive, which is an outage rather than a rule.
A condition the engine cannot answer (an unknown field from a newer client, a pattern that will not compile, contains "") is treated as a question that was never asked rather than as false, and negate does not flip it. That distinction is load-bearing: a negated broken condition treated as false would fire its rule on every message in the mailbox. equals "" is honoured, because "the subject line is empty" is a real question.
What a rule can do
| Action | `value` | What happens |
|---|---|---|
| `label` | a label id | Adds the label. USER_… ids come from GET /labels. |
| `remove_label` | a label id | Removes it. Naming the same label in both is resolved before the message is filed rather than left to whichever ran last. |
| `archive` | none | Files it out of the inbox. |
| `mark_read` | none | Drops UNREAD. |
| `star` | none | Adds STARRED. |
| `spam` | none | Files it under Spam. |
| `trash` | none | Files it under Trash, clearing the labels a trashed message does not keep. |
| `forward` | an address | Sends a copy on. Read the note below before you use it. |
| `reply` | a template id or slug | Auto-replies with a published template, subject to the loop guard below. |
| `block_sender` | none | Adds the sender to the blocklist, so the next message is refused at the door. |
| `reject` | none | Refuses the message at SMTP time with 550 5.7.1 Message refused by the recipient. Envelope only. See below. |
reject is refused at write time unless the same rule carries at least one envelope_from condition: reject_needs_envelope, a 422. A 550 answers whoever handed us the message, and on a mailing list that is the LIST, which reads the refusal as a bouncing subscriber and unsubscribes the reader from something they only wanted one person to stop posting to. Even with the condition written, a match that came only from the header identities downgrades to filing under Spam, because the envelope is the only identity a refusal can honestly be aimed at.
A rule-driven forward goes out through the send path, which REBUILDS the message: the original DKIM signature does not survive, and neither do exotic parts, unusual headers or anything past the outbound size ceiling, which a 25 MB message with attachments will exceed. It is a copy of what arrived rather than the message that arrived. The address is checked when the rule is written, so an unverified destination is a 422 on the call rather than a rule that silently drops every tenth message.
reply will not answer a machine. It is suppressed when the message carries Auto-Submitted (other than no), Precedence: bulk|list|junk, List-Id, List-Unsubscribe, X-Autoreply or X-Autorespond, when the envelope sender is empty (the shape every bounce takes) and when the headers could not be read at all. On top of that, one sender gets at most one auto-reply per 24 hours from a given mailbox. Two mailboxes with reply rules and no guard mail each other until somebody notices.