---
title: "Email Rules"
description: "Describe in plain English which received mail a rule should pick out, test it against recent mail, share exactly what it matches with an API key, the key's webhooks or a colleague, and keep sensitive mail from being shared with do-not-share rules."
canonical: "https://docs.primitive.dev/docs/email-rules"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Email Rules

An **email rule** picks out received mail by what it is, not only by the address it was sent to. You describe the mail in plain English, for example "invoices from vendors that come with a PDF". Primitive compiles that description into a typed rule over facts about each email, and you test the rule against your own recent mail before it does anything.

A rule that is turned on can **grant** what it matches: an API key limited to the rule reads exactly that mail, the key's webhooks receive it, and a colleague the rule is shared with can read it. A **do-not-share** rule works the other way: while it is on, no rule shares an email it matches.

All paths below are on `https://api.primitive.dev`. Almost all of them also exist as commands in the `primitive email-rules` CLI topic, named after their operation, for example `primitive email-rules compile-email-rule`. The [endpoint reference](#endpoint-reference) maps each one.

## Availability and credentials

Email rules are enabled per organization. If they are not enabled for yours, the rule routes answer `403 feature_not_enabled`, and webhook payloads carry no `understanding` block. Listing the shares you have given and ending a share need neither the feature nor `mail:read`, so you can always find and stop a share.

A rule runs with its author's own access to mail, so the rule routes only accept a credential that acts as a person with that person's full access:

- your dashboard session,

- the Primitive CLI logged in as yourself (`primitive login`), or

- an OAuth token that acts with your full access.

The credential also needs `mail:read`. API keys, connected agent keys and narrowed tokens (such as a CLI login narrowed with `--permission`) are refused. An API key can **hold** rules, as described in [Keys and webhooks for a rule](#keys-and-webhooks-for-a-rule), but it cannot write them.

## How rules work

### Classification

Once email rules are enabled, Primitive classifies the mail your organization receives. Classification reads the message and records a fixed set of **facts** about it, each with a confidence from 0 to 1: for example, whether it is cold outreach, what the sender wants, and what kind of document it is. The facts and the values they can take form a versioned **vocabulary**. The current vocabulary is `v1`.

The facts recorded for an email are published on its webhook payload and on `GET /v1/emails/{id}`. See [The understanding block](#the-understanding-block).

### The rule tree

A rule is a tree over the vocabulary's **columns**: conditions joined by `all`, `any` and `not`, plus up to three yes/no **questions** of its own.


```
{
  "version": 1,
  "vocabulary": "v1",
  "when": {
    "all": [
      { "column": "doc_type", "op": "contains", "value": "invoice", "min_confidence": 0.8 },
      { "column": "attachment_types", "op": "contains", "value": "application/pdf" },
      { "not": { "column": "from_domain", "op": "eq", "value": "example.com" } }
    ]
  },
  "questions": ["Does the email ask for payment by a specific date?"]
}
```


A condition is `{column, op, value, min_confidence}`, where `value` and `min_confidence` are present only when the operator or column takes them. A group is `{all: [...]}`, `{any: [...]}` or `{not: ...}`. Columns come from two sources: **email** columns are read from the message itself (sender, recipients, subject, attachments and so on), and **fact** columns are read from its classification. [The column vocabulary](#the-column-vocabulary) lists them all.

### When a rule runs

Each email is evaluated once, shortly after it is classified, against every rule that is in `observe` or `active` at that moment, at the rule's current version. The conditions are checked first. A rule's questions are asked only when its conditions match, and the rule matches only when every question is answered yes. Each outcome is recorded as a **decision** that names the rule version that made it.

- A rule decides only emails that were classified successfully.

- An email first classified by a rule test is not evaluated by live rules.

- A grant rule that reads a fact the classification left out does not match that email. A do-not-share rule counts such an email as matched (see [Do-not-share rules](#do-not-share-rules)).

- A rule only ever runs over mail its author can read. See [Scopes](#scopes).

### Versions

Creating a rule saves version 1. Saving a new `english` and `tree` (they are always sent together) adds the next version, and earlier versions are kept unchanged. Changing a rule's scope also saves a new version, with the same conditions. Decisions and results name the version that decided, so a past decision can be read in its own version's words with `GET /v1/email-rules/{id}/versions?versions=1,2` (up to 100 version numbers at once).

### States

StateEvaluatedWhat it does`draft`NoNothing. The rule is off.`observe`YesRecords a decision for each email it runs on. Grants nothing and blocks nothing.`active`YesRecords decisions. A grant rule grants what it matches; a do-not-share rule keeps what it matches from being shared.

A new rule starts in `observe` unless you create it as `draft`, so you can see what it would match on live mail before it has any effect.

Only the rule's author can rename it, add a version, change its scope, or move it to `observe` or `active`. Rules are visible to their author and to members who read all of the organization's mail. Anyone else who can see a rule can only turn it off (state `draft`) or delete it, and while it is shared with another member, only if they manage members and act from a dashboard session. Deleting a rule removes its versions, its decisions and the access it granted.

### Kinds

A rule's `kind` is set when it is created and never changes:

- `grant` (the default): what the rule matches can be shared.

- `do_not_share`: while the rule is active, no rule shares an email it matches. See [Do-not-share rules](#do-not-share-rules).

Rule responses carry `kind` only on a do-not-share rule. On a grant rule it is absent.

### What granted means

When an `active` grant rule matches an email, the email is **granted** to that rule. A grant makes the email readable by:

- API keys that list the rule, and the webhooks those keys own ([Keys and webhooks for a rule](#keys-and-webhooks-for-a-rule)), and

- a member the rule is shared with through an accepted rule request ([Rule requests and shares](#rule-requests-and-shares)).

Grants are forward only:

- A rule grants an email only when it matches while the rule is `active`. Turning a rule on does not go back over mail that was already evaluated: an email the rule matched while it was in `observe` is not granted when you later activate it.

- Saving a new version keeps what the rule granted.

- Access through a grant holds only while the rule is `active`. Moving the rule to `draft` or `observe` pauses it, and moving it back to `active` restores it. Deleting the rule removes its grants.

A grant only adds readers; it never takes access away from anyone. It gives read access and nothing more: a reader that has an email only through a grant never gets its raw message or how it was routed, and cannot replay the email's own delivery with `POST /v1/emails/{id}/replay`.

### The understanding block

For an organization with email rules, `email.received` webhook payloads carry `email.analysis.understanding`: the facts classification recorded for the email. `GET /v1/emails/{id}` returns the same block as `understanding`, without the `rules` list. Other event types (bounces and reports) never carry it. The full payload is documented on [Webhook Payload](/docs/webhook-payload).


```
{
  "version": 1,
  "status": "ready",
  "classification_id": "4f1c2b9e-7d3a-4e8b-9a61-2c5d8f0e3b17",
  "vocabulary": "v1",
  "classified_at": "2026-11-10T16:04:12.000Z",
  "facts": {
    "is_cold_outreach": { "value": false, "confidence": 0.97 },
    "intent": { "value": ["payment"], "confidence": 0.91 },
    "doc_type": { "value": ["invoice"], "confidence": 0.94 },
    "sender_relationship": { "value": "vendor", "confidence": 0.88 }
  },
  "rules": [
    {
      "id": "3b0f6a1e-6c7a-4f43-9d8e-2a1b5c4d3e2f",
      "version": 2,
      "name": "Vendor invoices",
      "description": "Invoices from vendors that come with a PDF",
      "explanation": "Matches emails classified as invoices with a PDF attachment.",
      "questions": [
        { "question": "Does the email ask for payment by a specific date?", "answer": true, "confidence": 0.86 }
      ]
    }
  ]
}
```

FieldPresentMeaning`version`alwaysThe block's shape version.`status`always`ready` (classified), `pending` (not classified yet, or a failed classification that may still be retried) or `unavailable` (it will not be classified). Treat an unfamiliar value as one added after your client was built.`classification_id`when `ready`The classification the facts come from.`vocabulary`when `ready`The vocabulary version, such as `v1`.`classified_at`when `ready`When the email was classified.`facts`when `ready`Facts by name, each a `value` and a `confidence` from 0 to 1. A fact the classification did not produce is absent. Ignore names you do not recognize.`rules`rule-granted deliveries onlyThe rules that matched the email and granted this delivery, as the receiving key lists them. A delivery through a rule share carries no `rules`. See [Keys and webhooks for a rule](#keys-and-webhooks-for-a-rule).

Each entry in `rules` has the rule's `id`, the `version` that matched, its `name`, its `description` (the English the version was written from), its `explanation`, and its `questions`, each with the `question`, the `answer` (true for yes) and the `confidence` of that answer from 0 to 1. A question that was not answered is left out.

The first delivery of an email is built without waiting for classification, so it usually carries `status: "pending"`. Read the facts later from `GET /v1/emails/{id}`, or rely on rule-granted deliveries, which are sent after classification. The block is kept under 16 KB: facts that would not fit make the status `unavailable`, and a rule that would not fit is left out of `rules`.

## Writing a rule

### Compile from English

`POST /v1/email-rules/compile` turns a description of up to 2,000 characters into a rule tree. Nothing is saved. Send `kind: "do_not_share"` when the rule is to be a do-not-share rule (see [Do-not-share rules](#do-not-share-rules)); the default is a grant rule.


```
primitive email-rules compile-email-rule \
  --english "Invoices from vendors that come with a PDF" \
  --json > compiled.json
```

FieldMeaning`tree`The compiled rule tree.`explanation`What the rule matches, in plain English.`assumptions`Readings the compiler chose where the description was open to more than one. When the rule holds a condition, question or confidence bar that no words of the description call for, a line starting `Added without the description asking for it:` names it.`warnings`Parts of the description the columns cannot express, and how the rule handles them. When the rule reads `automated` or `from_mailing_list`, a note says what that column reads and what it misses. When the rule has no questions and its conditions hold for all of your recent classified mail, or for at least 90% of at least 20 emails, the first warning says so: such a rule shares (or, as a do-not-share rule, blocks) nearly everything in its scope. That count reads your own recent mail as a test of the rule would. It is not made when your access shows headers only or a test of the rule could not run as you, and may also be skipped when the compile ran long or the count fails.`compiled_by`An opaque name for the compiler version. Send it back when you save the rule.`sql`Only with `include_sql: true`: the parameterized SQL expression the rule renders to.

Read `explanation`, `assumptions` and `warnings` before you save: they say where the rule differs from what you wrote. A description that cannot be turned into a valid rule answers `422 email_rule_not_compiled` with `details.reason` `refused`, `invalid` or `truncated`. If the compiler is not available, the answer is `503 email_rule_compiler_unavailable`.

You can also write a tree yourself. `GET /v1/email-rules/vocabulary` returns the column catalog a hand-written tree is checked against: every column with its type, source, meaning, operators and the value each takes, its options, whether it takes `min_confidence`, and the tree's limits.

### The column vocabulary

Vocabulary `v1` has these columns. Text list values are lowercase.

ColumnTypeSourceReads`from_address``text`emailSender address, lowercase.`from_domain``text`emailSender domain, lowercase.`to_addresses``text_list`emailTo addresses, lowercase.`cc_addresses``text_list`emailCc addresses, lowercase.`subject``text`emailSubject line.`received_at``timestamp`emailWhen the email arrived.`attachment_types``text_list`emailAttachment content types, lowercase.`from_mailing_list``bool`emailThe email carries mailing-list headers (`List-Id` or `List-Unsubscribe`). Some newsletters carry neither, so false does not mean it is not a newsletter.`automated``bool`emailThe headers or envelope mark it as sent automatically: a bounce or delivery report, an auto-reply (`Auto-Submitted`), bulk or mailing-list headers (`Precedence`, `List-Id`, `List-Unsubscribe`) or a mailer-daemon sender. Mail an address sends to itself also counts. Many alerts and notifications carry none of these, so false does not mean a person wrote it.`spam_score``number`emailSpam score from inbound filtering.`is_cold_outreach``bool`factUnsolicited first contact pitching, selling, recruiting or asking for something.`intent``multi`factWhat the sender wants, all that apply: `request`, `offer`, `scheduling`, `payment`, `recruiting`, `support`, `sales_outreach`, `informational`, `personal`, `other`.`doc_type``multi`factWhat kind of document the email is, all that apply: `correspondence`, `invoice`, `receipt`, `contract`, `resume`, `calendar_invite`, `newsletter`, `notification`, `other`.`sender_relationship``choice`factWho the sender is to the mailbox owner: `vendor`, `customer`, `candidate`, `investor`, `advisor`, `internal`, `community`, `unknown`.

### Operators

Column typeOperatorValue`bool``is_true`, `is_false`none`text``eq`, `contains`, `starts_with`, `ends_with`one text`text``in`a list of texts`text``is_null`, `not_null`none`text_list``contains`one text`text_list``contains_any`a list of texts`text_list``is_empty`, `not_empty`none`number``eq`, `gt`, `gte`, `lt`, `lte`a number`number``between``[low, high]``timestamp``before`, `after`an ISO 8601 date-time with an offset from `-12:00` to `+14:00`, such as `2026-11-01T09:00:00Z``choice``eq`one of the column's options`choice``in`a list of the column's options`multi``contains`one of the column's options`multi``contains_any`, `contains_all`a list of the column's options

There is no "not equal" operator: negation is always a `not` group. How conditions compare:

- Text comparisons ignore case.

- Apart from `is_null` and `is_empty`, which test for a missing value, a condition on a value the email does not have (no subject, for example) never holds, so a `not` over it does.

- `between` includes both ends. `before` and `after` are strict.

- On `choice` and `multi` columns, every value must be one of the column's options.

### Confidence bars

A condition on a fact column (`is_cold_outreach`, `intent`, `doc_type`, `sender_relationship`) can set `min_confidence`, from 0 to 1. The condition then holds only when it is true and the fact's confidence is at least that bar. On a `multi` column the bar applies to each value the condition names, by that value's own confidence: `contains` needs its value at least that sure, `contains_any` needs at least one listed value the email has, and `contains_all` needs every listed value. So `doc_type contains invoice` with `min_confidence: 0.8` asks how sure the classifier is that the email is an invoice, whatever else it might also be.

A fact condition without `min_confidence` (or with `min_confidence: 0`, which is no bar) holds when the classifier gives the value it names a probability above 0.5. For `in` on `sender_relationship`, it holds when the classifier's pick is one of the values listed and the listed values together are above 0.5. When that sits at exactly 0.5, when the classifier picked the value only as the likeliest of options none of which reached 0.5, or when the listed values together hold half or more without the pick, the fact is undecided, and the rule's kind settles it: a grant rule does not count it, and a do-not-share rule does. Under a `not` this is reversed, so an undecided fact never makes a grant rule match and never stops a do-not-share rule from matching. A condition with `min_confidence` above 0 is held to its bar instead, in either kind of rule.

A condition with `min_confidence` above 0 cannot sit under an odd number of `not`s, because it would then match exactly the emails the classifier was unsure about. Saving such a tree answers `400 validation_error`. Write the positive form with the bar instead, for example `is_false`, or `in` over the other options.

### Questions

A rule can carry up to three yes/no questions, each 5 to 300 characters. They are answered from the email's content, so they cover what the columns cannot express, such as "Does the email ask for payment by a specific date?". The compiler may add them where the columns fall short.

Questions are asked about an email only after the rule's conditions match it, and a grant rule matches only when every answer is yes: an answer that could not be given does not count as yes. A do-not-share rule treats an unanswered question as a match instead (see [Do-not-share rules](#do-not-share-rules)). Each decision records the answers, and a rule-granted delivery carries them in `understanding.rules`.

### Limits

LimitValueNesting depth (a condition counts as one level)6Conditions30Values in a list50Characters in a text value200Questions3Characters in a question5 to 300Stored tree size32,768 bytesCharacters in `english`2,000Characters in a rule name200

### Save the rule

`POST /v1/email-rules` saves a rule as version 1. The compile response (requested without `include_sql`) holds the version fields, so pass it as the body and add a name and the English it came from:


```
primitive email-rules create-email-rule --body-file compiled.json \
  --name "Vendor invoices" \
  --english "Invoices from vendors that come with a PDF"
```


The tree is checked against the column contract on save. Without an `explanation`, the server writes one from the tree, and `compiled_by` defaults to `member` for a tree you wrote yourself. A new rule starts in `observe` and covers only your own mailboxes unless you name another scope (see [Scopes](#scopes)). Pass `--state draft` to save it switched off.

When its decisions look right, turn it on:


```
primitive email-rules update-email-rule --id <rule-id> --state active
```


## Testing a rule

### Against recent mail

`POST /v1/email-rules/test` runs a rule over received mail and reports what it would decide for each email. Test a saved rule by `rule_id` (its current version, run with its author's access), or an unsaved `tree` from a compile response.


```
# A saved rule
primitive email-rules test-email-rule --rule-id <rule-id>


# An unsaved tree from a compile response
jq '{tree}' compiled.json > test.json
primitive email-rules test-email-rule --body-file test.json
```


- Without `email_ids`, the test covers the newest emails the rule can read: at most 200, none older than 30 days. With `email_ids`, it tests exactly those, up to 200.

- Emails that are not classified yet and arrived more than 2 hours ago are classified first, and that classification is saved. Newer ones are left to normal classification and reported `unclassified` with `unclassified_reason: "pending"`. Send `classify_missing: false` to classify nothing. Live rules are not run on the emails a test classifies. An email whose classification has already failed 3 times is reported `classification_failed` and not retried.

- Classification and the rule's questions stop after about 90 seconds. Run the test again to continue: classification picks up where it stopped.

- The test records no decision and grants nothing.

- Answers to a rule's questions are sampled: asked again about the same email, an unclear question can come back a little higher or lower, and so land on the other side of 0.5. So a saved rule version's questions are answered once per email and the answers are kept: by evaluation, and by a test of the saved rule that had to ask. Every later decision and test of that version reuses them, even after the email is classified again. Where none are kept and the version's decision has no complete answers (its question call failed), they are asked now. An unsaved tree has no version to keep them for, so its questions are always asked. Kept answers go with the email, its content, the rule version and the rule.

- An unsaved tree covers only your own mail unless you send `evaluation_scope`. Send `kind: "do_not_share"` to test it as a do-not-share rule.

- Testing reads what emails say, so a credential whose access to mail shows headers only is refused with `403 grant_forbidden` (`details.reason` `mail_depth`).

The response has `counts` by outcome, `classification` (`classified_now`, `still_unclassified`), `questions` (`asked`, `stored` for answers reused from decisions, and `capped` when the per-test limit on question calls was reached), `results` newest first, and a `summary`: how many emails were decided on their conditions, how each top-level condition held and failed (and how often it was the only failure), each question's yes, no and unanswered counts, and `warnings` that say what to look at first, such as what stopped every email when nothing matched.

### Against a sample email

`POST /v1/email-rules/test-sample` reports what a rule would decide for an email you describe. The email is classified the way received mail is, and the rule's conditions and questions are decided as if it had been received now. Nothing is stored.


```
cat > sample.json <<'EOF'
{
  "rule_id": "<rule-id>",
  "email": {
    "from": { "address": "billing@vendor.example" },
    "to": ["you@yourcompany.example"],
    "subject": "Invoice 1042",
    "text": "Please find invoice 1042 attached. Payment is due by November 30.",
    "attachment_types": ["application/pdf"]
  }
}
EOF
primitive email-rules test-email-rule-on-sample --body-file sample.json
```


The email takes `from` (an `address` and an optional `name`), `to` (1 to 50 addresses; the first is read as the mailbox it arrived in), `cc` (up to 50), `subject`, `text` (the plain-text body, up to 50,000 characters), and `attachment_types` (up to 20 content types; no attachment content). `automated` and `from_mailing_list` default to false. Without a `spam_score`, a rule that reads `spam_score` decides on no score and the answer lists it in `unset_columns`.

The answer has the `outcome` (`matched`, `near_miss` or `no_match`), the same per-email detail as a test result, the `classification` status (`ok`, `classifier_unavailable` or `classification_failed`), and the `facts` the classifier read from the email. Without a classification, a grant rule that reads a fact does not match, and a do-not-share rule counts as matched.

### Reading test results

OutcomeMeaning`matched`The conditions held and every question was answered yes.`near_miss`Not matched, but nearly: its questions did not all say yes, or it needs several parts and exactly one failed while at least one part that held is not an exclusion (a top-level `not`, or `is_false`).`no_match`Not matched.`unclassified`The email has no classification, so the rule could not decide it. `unclassified_reason` is `pending`, `classifier_unavailable`, `classification_failed` or `not_classifiable`.`not_found`No such email, or not one the rule can read (or not settled, held back, or being removed).

For `matched`, `near_miss` and `no_match`, each result also carries:

- `parts`: how each top-level part of the rule decided, with the columns it read.

- `missing_facts`: facts the rule reads that this email's classification left out, so the rule did not match.

- `values`: by column the rule names, the value its conditions compared (a list is cut at 50 items), and a fact's confidence. A `multi` fact also has `value_confidences`, each chosen value with its own confidence, which is what `min_confidence` on the column tests.

- `question_answers`: the answers to the rule's questions, asked only when its conditions matched.

- `question_answers_source`, when there are answers: `stored` (kept for the saved rule version, or recorded when it decided this email, and reused), `asked_now` (asked by this test), or `not_asked` (no question call could be made; the answers' `error_class` says why).

### Decisions on live mail

Once a rule is in `observe` or `active`, `GET /v1/email-rules/{id}/decisions` lists what it decided for each email it ran on, newest first: whether it matched, whether it nearly did (`near_miss`), how each part decided, the answers to its questions and the `rule_version` that decided. Only decisions on emails you can read are listed. Pass `matched=true` or `matched=false` to filter. A decision about an email whose content cannot be read (it is withheld, failed to parse, or is still awaiting sender authentication) shows only whether the rule matched: `parts` and `question_answers` are empty, `near_miss` is false, and `detail_withheld` is `true`, because they were read from that content. Discarding an email's content normally removes its decisions. A decision is read from what the email says, so access to mail that shows headers only is refused with `403 grant_forbidden` (`details.reason` `mail_depth`).


```
primitive email-rules list-email-rule-decisions --id <rule-id> --matched true
```


### Why a rule did not run on an email

`GET /v1/emails/{id}/rule-results` lists every rule you can see and what it decided about one email: those that matched, then those that did not, then those that have not run on it, newest rule first within each. At most 50 rules are listed, and `hidden` counts the rest. Note that the id is the **email's** id:


```
primitive email-rules list-email-rule-results --email-id <email-id>
```


Each rule carries its `decision` (the version that decided, whether it matched, its parts, its question answers, and that version's `tree`), or `decision: null` when it has not run on the email. As with decisions, a result on an email whose content cannot be read shows only `matched`, with `detail_withheld: true`. Beside the rules, `classification` is what the classifier read from the email: every fact as rule conditions read it, with its confidence, so you can see why a rule did or did not match. It is `null` when the email has no classification, or while its content is withheld, discarded or failed to parse. For a rule without a decision, `not_run_reason` says what is known:

`not_run_reason`Meaning`draft`The rule is a draft now.`created_after_email`The rule was created after the email arrived.`null`The rule has a result, or nothing more is known.

When a rule has no decision and no reason, check the usual causes:

- The email is outside the rule's scope, or the rule's author can no longer read it. `GET /v1/email-rules/{id}/scope` shows what the rule covers now and, in `stopped_reason`, why it cannot run.

- The email was not classified successfully. Check `understanding.status` on `GET /v1/emails/{id}`.

- The rule was a draft when the email was evaluated.

- The email was first classified by a rule test, which does not run live rules.

What each rule decided about an email, and what the classifier read from it, are read from what the email says. Access to mail that shows headers only is therefore refused with `403 grant_forbidden` (`details.reason` `mail_depth`), and so is `matched_email_id` on `GET /v1/email-rules`.

## Scopes

A rule's `evaluation_scope` says whose mail it runs over:

`evaluation_scope`Covers`own_mailboxes`Mail to your own addresses and your agents' addresses. For a grant rule, only those your role lets you share (`mail:share_own`); for a do-not-share rule, every one you read. This is the default.`author`All the mail you can read, including inboxes you can read later.`selected`The inboxes and domains named in `scope_selection`. A domain covers every address at it that you can read.

When `evaluation_scope` is omitted, on create and when testing an unsaved tree, the rule covers only your own mail, so leaving the field out never reaches the whole organization. `scope_selection` goes with `selected` and only with it:


```
{
  "evaluation_scope": "selected",
  "scope_selection": {
    "addresses": ["billing@yourcompany.example"],
    "domains": ["support.yourcompany.example"]
  }
}
```


It names at least one inbox or domain, up to 100 addresses and 50 domains, and only ones you can read. Naming one you cannot read answers `403 email_rule_scope_not_readable` with the refused names in `details.addresses` and `details.domains`. `GET /v1/email-rule-scope-options` lists what you may name: how much you can read (`reach`: `organization`, `limited` or `own`), the inboxes and domains you can read (filter with `q`), and `may_name_unlisted`, which says whether you may also name an address the list does not show.

A rule never reaches mail its author cannot read. It runs with its author's access as it is each time it runs, not as it was when the rule was written, and temporary break-glass access never widens it. If the author's access shrinks, the rule covers less; inboxes a `selected` rule names that its author can no longer read stop matching; and if the author leaves the organization, the rule stops running. When a rule could not run as its author, creating it, changing its scope or testing it answers `409 email_rule_author_unresolvable`, with `details.reason` `left`, `revoked`, `suspended`, `membership_ended`, `no_mail_read`, `no_own_mailbox`, `no_shareable_mailbox` or `no_selected_mailbox`.

`GET /v1/email-rules/{id}/scope` shows what a rule's scope comes to now:

FieldMeaning`evaluation_scope`, `scope_selection`The scope as stored.`locked`The rule is shared, so its scope cannot change.`author_reach`How much its author can read now (`organization`, `limited` or `own`), or `null` when it cannot run as them.`no_longer_readable`What `scope_selection` names that its author no longer reads in full.`still_read`Inboxes its author still reads at the domains in `no_longer_readable`. The rule still covers them.`stopped_reason`Why the rule cannot run now, or `null`.

To change the scope, the author sends `evaluation_scope` (with `scope_selection` when `selected`) to `PATCH /v1/email-rules/{id}`. This saves a new version with the same conditions, and what the rule already granted stays. A rule shared with another member keeps its scope: changing it answers `409 email_rule_scope_locked`. So does widening the scope of a rule on an API key, for an author limited to their own mail (`details.reason` `rule_on_key`).

## Keys and webhooks for a rule

### Keys limited to rules

An API key's grant can list up to 20 rules. A key limited to rules reads only the mail those rules granted (plus any addresses and domains it also lists):


```
primitive keys create --name invoices --permission mail:read --rule <rule-id>
```


Repeat `--rule` to list several. `primitive keys update <id> --rule <rule-id>` replaces the rules a key lists. Over HTTP, rules go in `grant.rules` on `POST /v1/keys` and `PATCH /v1/keys/{id}`.

- Only the rule's author, from their own dashboard session or Primitive CLI login, or a member who reads every mailbox can put a rule on a key. An author who does not read every mailbox can put a rule on a key only when everything it has granted is mail they can still read.

- A do-not-share rule cannot be put on a key: it grants nothing, so the request is refused with `details.reason` `rule_blocks_sharing`.

- The key reads what a rule granted only while the rule is `active`. Taking the rule off the key removes that access at once.

- The key can list, search and read the granted emails. It never gets their raw message, and `POST /v1/emails/{id}/replay` on an email it reads only through a rule answers `404`.

### Webhooks that receive rule-granted mail

When a rule grants an email, Primitive delivers it to every enabled webhook owned by a key that lists the rule. Functions are not sent rule-granted mail. A webhook is owned by a key when a key limited to some mail created it for itself, or when it was given to the key with `owner_key_id` on `POST /v1/endpoints` or `PATCH /v1/endpoints/{id}`. A webhook a key makes for itself signs its deliveries with a signing secret of its own; the endpoint's `signed_with` says which secret signs it. See [Endpoints](/docs/endpoints) and [Signature Verification](/docs/signature-verification).

A rule-granted delivery:

- is sent shortly after the email is classified and the grant is recorded. An `email.received` delivery is sent only once the email's understanding is `ready`, so it carries the facts, and `understanding.rules` names the matching rules the receiving key lists by id. If the understanding is `unavailable`, the delivery is not sent; the key can still read the email through the API.

- is additional to routing. It never changes where routes and endpoints send the email, and an email reaches a webhook once however many of the key's rules match it.

- carries no raw content and no `routing` object.

- is not sent for mail the receiving key sent itself.

- goes only to webhooks the key owns when the grant is recorded. A webhook added later does not receive mail granted before it existed; the key can still read that mail through the API.

A queued delivery that comes due while its rule is not `active` (or after the rule is deleted) is skipped and not retried.

`understanding.rules` names the rules the receiving key lists itself that matched and granted the email, at the version that matched, with their name, description, explanation and question answers. A rule is listed only while it is `active` and still at the version that matched: a delivery sent or replayed after the rule gets a new version leaves that rule out, even though its earlier grant still gives the key the email. Treat the list as the rules that apply now, not as a complete record of what granted access. See [The understanding block](#the-understanding-block).

### Replays

Replaying a rule-granted delivery to its webhook (`POST /v1/webhooks/deliveries/{id}/replay`) checks again that the key still reads the email through an active rule it lists, and is refused if it does not. The replayed payload carries an `understanding` block built for that key now, naming its current rules; it still carries no raw content and no `routing`. An email replay (`POST /v1/emails/{id}/replay`) replays routing, which rules never extend, so it never sends through a grant. See [Webhook Payload](/docs/webhook-payload#replays) for what a replay keeps and refreshes.

## Do-not-share rules

A **do-not-share** rule marks mail that must not be shared through rules, such as legal or HR mail. Create one with `kind` set to `do_not_share`:


```
primitive email-rules compile-email-rule \
  --english "Anything from our outside counsel or about a legal dispute" \
  --kind do_not_share \
  --json > legal.json
primitive email-rules create-email-rule --body-file legal.json \
  --name "Never share legal mail" \
  --english "Anything from our outside counsel or about a legal dispute" \
  --kind do_not_share
```


It is written, versioned, tested and moved through `draft`, `observe` and `active` like any rule, and runs over its own scope. It never grants anything itself.

Compile it with `kind` set to `do_not_share` too. A grant rule is compiled for precision, because what it matches is shared. A do-not-share rule is compiled for recall, because an email it misses can be shared: its conditions are only as narrow as your description says, and the judgement of what you described goes into its questions, which read the whole email. When your description names nothing a column decides for certain, its only condition is that the email arrived after 1970, which every email did, and the questions decide. Its `warnings` then say so: the questions are asked about every email in its scope, and an email whose questions cannot be answered is kept from being shared. Read the compiled tree before you save it. In a do-not-share rule each condition is mail it can miss, so remove any that `assumptions` names as `Added without the description asking for it:`.

- What it blocks. While it is `active`, no rule shares an email it matches: the email is not granted to any rule, so keys limited to rules do not read it, their webhooks do not receive it, and rule shares do not include it.

- Ordering. It runs before every other rule, so a grant rule that also matches the email grants nothing.

- Fails closed. An email it cannot decide counts as matched: a fact the rule reads that classification left out, a fact condition without `min_confidence` that classification left undecided (see [Confidence bars](#confidence-bars)), a question that was not answered, or a rule that could not be evaluated all block sharing. Test an unsaved tree as a do-not-share rule with `kind: "do_not_share"` on `POST /v1/email-rules/test` to see this.

- Forward only. It blocks sharing from the time it is active. Anything shared before, including while it was in `observe`, stays shared.

- Only within its scope. It blocks only mail its scope covers. With the default `own_mailboxes` scope, that is every mailbox of your own that you read, including ones you could not share.

- Only sharing through rules. It does not hide mail from people and keys that read the mailbox directly, such as the mailbox's owner, members who read all of the organization's mail, and keys that reach the mailbox by its address or every mailbox. It does not change where routes and endpoints deliver mail.

- Its author's membership. A do-not-share rule whose author is no longer a member blocks nothing. `GET /v1/emails/{id}/rule-results` marks it with `author_left: true`.

In `observe`, a do-not-share rule records what it would match and blocks nothing, so you can check it on live mail before turning it on.

## Rule requests and shares

A member can ask another member for a rule over that member's own mail:

- Ask. `POST /v1/email-rule-requests` (`create-email-rule-request`) takes a name, the English description, the rule's fields as a compile response returns them, and the member asked, by `target_user_id` or by their member mailbox address in `target_email` (at most 30 lookups by address an hour). Nothing is evaluated or shared yet. You can have at most 5 pending requests to one member and 50 in all. You can withdraw a pending request, and then delete it (`DELETE /v1/email-rule-requests/{id}`), which removes it for the member you asked and for owners and admins too.

- Review. The member asked sees the request, can preview what it matches in their own mail and can change the rule first, or decline.

- Accept. `POST /v1/email-rule-requests/{id}/accept` writes the rule as theirs, over their own mailboxes only, turns it on, and shares what it matches from then on with the member who asked. Because accepting gives that member's mail to someone else, it is done only from the dashboard or the Primitive CLI, and needs `mail:share_own`. Accepting is refused with `409 email_rule_share_limit` when the member who asked already receives 20 live shared rules.

The member it is shared with reads the shared mail through `GET /v1/email-rule-shares/{id}/emails`, from the dashboard or the Primitive CLI, and webhooks they own receive it, without `understanding.rules`. They can read that mail but not reply to it. Shared mail is readable while the rule is active and the share is live: not ended, with both people still members. Which emails a share gave is read from what they say, so either person whose access to mail shows headers only is refused that list with `403 grant_forbidden` (`details.reason` `mail_depth`).

- Either person can end a share with `DELETE /v1/email-rule-shares/{id}`, and so can an owner or admin who manages members.

- A new version of the shared rule ends the share (`end_reason: "rule_changed"`). A shared rule's scope cannot change.

- `GET /v1/email-rule-shares` lists shares by `box` (`received`, `given` or `all`) and `live` (`true`, `false` or `any`).

A request is visible to the member who made it, the member it was made to, and the organization's owners and admins. A share is visible to its two people and the organization's owners and admins.

## Endpoint reference

Method and pathCLI commandPurpose`GET /v1/email-rules/vocabulary`noneThe column catalog and tree limits.`POST /v1/email-rules/compile``compile-email-rule`Compile a description into a rule tree.`POST /v1/email-rules/test``test-email-rule`Test a rule against received mail.`POST /v1/email-rules/test-sample``test-email-rule-on-sample`Test a rule against a made-up email.`POST /v1/email-rules``create-email-rule`Create a rule.`GET /v1/email-rules``list-email-rules`List the rules you can see. `matched_email_id` lists only those that matched one email.`GET /v1/email-rules/{id}``get-email-rule`Read a rule and its current version.`PATCH /v1/email-rules/{id}``update-email-rule`Rename, add a version, change state or change scope.`DELETE /v1/email-rules/{id}``delete-email-rule`Delete a rule with its versions, decisions and grants.`GET /v1/email-rules/{id}/decisions``list-email-rule-decisions`What the rule decided on live mail.`GET /v1/email-rules/{id}/versions``list-email-rule-versions`Read named versions.`GET /v1/email-rules/{id}/scope``get-email-rule-scope`What the rule covers now.`GET /v1/email-rule-scope-options``list-email-rule-scope-options`What you may name in a selected scope.`GET /v1/emails/{id}/rule-results``list-email-rule-results`Every rule's result on one email.`POST /v1/email-rule-requests``create-email-rule-request`Ask a member for a rule.`GET /v1/email-rule-requests``list-email-rule-requests`List requests (`box`: `incoming`, `outgoing` or `all`).`GET /v1/email-rule-requests/{id}``get-email-rule-request`Read a request.`POST /v1/email-rule-requests/{id}/accept``accept-email-rule-request`Accept a request made to you.`POST /v1/email-rule-requests/{id}/decline``decline-email-rule-request`Decline a request made to you.`POST /v1/email-rule-requests/{id}/withdraw``withdraw-email-rule-request`Withdraw a request you made.`DELETE /v1/email-rule-requests/{id}``delete-email-rule-request`Delete a request you made and withdrew.`GET /v1/email-rule-shares``list-email-rule-shares`List shares.`GET /v1/email-rule-shares/given``list-given-email-rule-shares`List the shares you have given.`GET /v1/email-rule-shares/{id}``get-email-rule-share`Read a share.`GET /v1/email-rule-shares/{id}/emails``list-email-rule-share-emails`The emails a share has given you.`DELETE /v1/email-rule-shares/{id}``end-email-rule-share`End a share.

The full request and response schemas are in the [API reference](/api-reference) and the [OpenAPI spec](/openapi.json).

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
