# Rules

> Typed conditions and actions that run on every message, with a written order of evaluation.

Source: https://developer.mueslimail.com/docs/rules/

A rule is read by the platform as mail arrives and does one thing or several: label a message, put it in a folder, forward it to a verified address, answer it once, or refuse it. Conditions and actions are a closed and typed vocabulary, never a script: an unknown field or an unknown action is a `400 validation_error`, not something silently ignored. There is no user language to execute, so the outcome of a rule is the same every time.

## The order of evaluation

* The rules whose target covers the message are collected: one inbox, every inbox of a domain, or the organization. A disabled rule is not collected.
* They are all evaluated, in `priority` ascending and then by age. No rule wins and stops the others: the consequences accumulate.
* Metadata actions come first (`add_label`, `remove_label`, `mark_read`), then placement (`archive`, `move_to_folder`), then the actions with external effects (`forward_to`, `auto_reply`, `webhook`, `assign`).
* `reject` is not a consequence: it is an outcome. If any evaluated rule rejects, the message is refused with `550` and the reason of the most specific rejecting rule, and every other consequence is discarded.
* A conflict of placement goes to the more specific rule (inbox over domain over organization), and at equal specificity to the older one. Losing a conflict does not cancel the other actions of that rule.
* Actions are idempotent: the same label from two rules is one label, and marking an already read message read changes nothing.

## The spam guard

A rule never sees a message with the spam verdict unless it sets `include_spam: true`. The guard is applied before the conditions are read, so no rule written without it can reach spam, which is the structural version of the anti-spam preamble every filter carries elsewhere. `spam_verdict` inside the conditions is how a rule asks for one verdict or another once it is allowed to look.

## The guards that are checked when the rule is written

* `forward_to` only accepts an address that is one of your inboxes or on a domain you verified. Anything else is a `422 unverified_forward_address` at creation, not silence at three in the morning.
* A message that already carries `Auto-Submitted: auto-forwarded` is never forwarded again, and a forward adds that header, so two hops cannot form a ring.
* `auto_reply` never answers an automatic message (`Auto-Submitted`), a delivery report, a complaint or a bulk message, and it answers a given sender at most once every 24 hours per inbox. The answers carry `Auto-Submitted: auto-replied` (RFC 3834).
* A `reject` reason must be one line and must not contain an address: it leaves in the SMTP reply, and it must not confirm that an address exists. A rejection is final and produces no bounce.
* A `webhook` action must name a live webhook of your organization. It emits `rule.matched` after the inbox transaction commits, with the same signature as every other delivery, and it is a no-op recorded in the audit log when the endpoint has been disabled.

## Folders are labels

There is one taxonomy in this product: a label. A folder is a label, and the archive is the `archived` label, so `move_to_folder` and `archive` move a message in the same way search finds it with `label:`.

## The automatic answer of an inbox, with a schedule

`PUT /v0/inboxes/{inbox}/auto-reply` configures the automatic answer of one inbox in one of five forms, each with an **explicit** IANA `timezone` (the server's clock is never used): `fixed` with two RFC 3339 instants, `daily` with two `HH:MM` clock times, `weekly` with two clock times and weekday tokens in `days` (`mon`..`sun`), `monthly` with two clock times and day-of-month numbers in `days`, and `permanent` with no window at all. An `end` before its `start` wraps past midnight and belongs to the day it started on.

The configuration is **one ordinary rule** of the engine: target the inbox, condition `to` the address, action `auto_reply`. That is what makes the guards the same ones: no answer to a message carrying `Auto-Submitted`, to a delivery report (`multipart/report`, which is also what an ARF complaint is) or to bulk mail, at most one answer per sender per inbox every 24 hours, and the answer queued like any other send so it pays the plan limits. The answer carries `Auto-Submitted: auto-replied` (RFC 3834) and `rule_id` in the response is the rule it created, visible in `GET /v0/rules`. `restrict_to_domain` answers only senders on the inbox's own domain or on a domain the organization verified.

> Note: `GET /v0/inboxes/{inbox}/auto-reply` reports `active`: whether the window covers now, in the schedule's timezone, so a console never has to reimplement the arithmetic. Outside the window nothing is sent and the sender's 24 hour budget is not spent. `DELETE` removes the schedule and the rule with it, and it works with `MAI_RULES_ENABLED` off: a flag flipped off must not leave an answer nobody can stop.

## Block and allow lists

`POST /v0/inboxes/{inbox}/blocklist` and `POST /v0/organization/blocklist` take `{kind: sender|domain, value}` plus an optional `list_kind` (`block` by default, or `allow`). The list of one inbox is `inbox_write`; the organization wide list is its own permission, `org_blocklist_write`, because one entry there covers every inbox.

* They are evaluated in the ingest **before any rule runs**, so a blocked sender never reaches a rule that would label, forward or answer the message.
* `allow` beats `block`: an allowed address passes through a blocked domain, which is the reason to keep an allow list.
* A refused sender gets `550` with a fixed sentence that names no address, and nothing of the message is stored. A `domain` entry matches that domain exactly, never a suffix that merely ends the same way.
* `DELETE` names the entry in the query string (`kind`, `value`, and `list_kind` when it is not `block`). Removing an entry that is not there is a `404`, so a typo is not a silent no-op.

## Applying a rule to the messages that already exist

`POST /v0/rules/{rule}/apply` is an asynchronous job: the answer is `202` with a report, and the mailbox is walked in batches. A rule that is switched off is refused with `409 rule_disabled`, because applying it would be the one way to make a disabled rule act. The past is treated as if the rule had existed, so labels, placement and ticket assignment are applied, while `forward_to`, `auto_reply` and `webhook` are skipped and counted: nobody receives a forward for a rule written today. The job is repeatable and never duplicates a label: a second run reports `already_applied`.

> Note: The report distinguishes `matched` (the conditions selected the message), `applied` (something changed), `already_applied` (nothing changed), `external_action` (it carried an action the past skips) and `spam_guard` (spam a rule without `include_spam` may not see). A message can be applied and still have its forward skipped, because the counters report what happened rather than a partition of the matches.

## Permissions and the feature flag

Reading needs `rule_read`; creating, changing, deleting and applying need `rule_write`, which is separate from `message_update` because applying a rule rewrites the mailbox it covers. The engine is switched per organization by the platform operator, and `MAI_RULES_ENABLED` is the deployment's kill switch: both have to be on. With either off every route under `/v0/rules` answers `501 rules_disabled`, which is a feature switched off and not a fault, and no rule runs on the organization's mail.

## Endpoints

### GET /v0/rules

List the rules of the organization (requires rule_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `target` | inbox \| domain \| org | in query, Only rules of this kind |
| `target_id` | string (uuid) | in query, Only rules of this inbox or domain |
| `enabled` | boolean | in query |

Responses:

* `200` OK: object
* `403` Error envelope: Error
* `501` Error envelope: Error

### POST /v0/rules

Create a rule (requires rule_write, audited)

Body (RuleInput):

| Name | Type | Notes |
| --- | --- | --- |
| `name` | string | required, max length 100 |
| `target` | RuleTarget |  |
| `priority` | integer | min 0, max 100000, default 100 |
| `enabled` | boolean | default true |
| `include_spam` | boolean | default false |
| `conditions` | RuleConditions | required |
| `actions` | RuleAction[] | required, at least 1 item, at most 20 items |

Responses:

* `201` Created: Rule
* `400` Error envelope: Error
* `403` Error envelope: Error
* `404` Error envelope: Error
* `422` Error envelope: Error
* `429` Error envelope: Error
* `501` Error envelope: Error

### GET /v0/rules/{rule}

Read one rule (requires rule_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `rule` | string (uuid) | in path, required |

Responses:

* `200` OK: Rule
* `403` Error envelope: Error
* `404` Error envelope: Error
* `501` Error envelope: Error

### PATCH /v0/rules/{rule}

Change a rule, or switch it off (requires rule_write, audited)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `rule` | string (uuid) | in path, required |

Body (RulePatch):

| Name | Type | Notes |
| --- | --- | --- |
| `name` | string | max length 100 |
| `target` | RuleTarget |  |
| `priority` | integer | min 0, max 100000 |
| `enabled` | boolean |  |
| `include_spam` | boolean |  |
| `conditions` | RuleConditions |  |
| `actions` | RuleAction[] | at least 1 item, at most 20 items |

Responses:

* `200` OK: Rule
* `400` Error envelope: Error
* `403` Error envelope: Error
* `404` Error envelope: Error
* `422` Error envelope: Error
* `501` Error envelope: Error

### DELETE /v0/rules/{rule}

Delete a rule and any apply job queued for it (requires rule_write, audited)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `rule` | string (uuid) | in path, required |

Responses:

* `204` Deleted
* `403` Error envelope: Error
* `404` Error envelope: Error
* `501` Error envelope: Error

### POST /v0/rules/{rule}/apply

Apply a rule to the messages that already exist (requires rule_write, audited)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `rule` | string (uuid) | in path, required |

Responses:

* `202` Queued: RuleApplyReport
* `403` Error envelope: Error
* `404` Error envelope: Error
* `409` Error envelope: Error
* `501` Error envelope: Error

### GET /v0/rules/applies/{apply}

The state of one apply job (requires rule_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `apply` | string (uuid) | in path, required |

Responses:

* `200` OK: RuleApplyReport
* `403` Error envelope: Error
* `404` Error envelope: Error
* `501` Error envelope: Error

### Rule object

| Name | Type | Notes |
| --- | --- | --- |
| `rule_id` | string (uuid) |  |
| `name` | string |  |
| `target` | RuleTarget |  |
| `priority` | integer | Lower is evaluated first; ties go to the older rule |
| `enabled` | boolean |  |
| `include_spam` | boolean | False means the rule never sees a message with the spam verdict |
| `conditions` | RuleConditions |  |
| `actions` | RuleAction[] | at most 20 items |
| `created_at` | string (date-time) |  |
| `updated_at` | string (date-time) |  |

### RuleInput object

| Name | Type | Notes |
| --- | --- | --- |
| `name` | string | required, max length 100 |
| `target` | RuleTarget |  |
| `priority` | integer | min 0, max 100000, default 100 |
| `enabled` | boolean | default true |
| `include_spam` | boolean | default false |
| `conditions` | RuleConditions | required |
| `actions` | RuleAction[] | required, at least 1 item, at most 20 items |

### RulePatch object

| Name | Type | Notes |
| --- | --- | --- |
| `name` | string | max length 100 |
| `target` | RuleTarget |  |
| `priority` | integer | min 0, max 100000 |
| `enabled` | boolean |  |
| `include_spam` | boolean |  |
| `conditions` | RuleConditions |  |
| `actions` | RuleAction[] | at least 1 item, at most 20 items |

### RuleConditions object

| Name | Type | Notes |
| --- | --- | --- |
| `from` | string[] | Sender, case and accent insensitive substring, ORed, at most 32 items |
| `to` | string[] | Recipient (To or Cc), ORed, at most 32 items |
| `subject` | string[] | Subject substring, ORed, at most 32 items |
| `query` | string | Full text, same parser as search, max length 512 |
| `negated_query` | string | The message must not match this, max length 512 |
| `has_attachment` | boolean |  |
| `size` | RuleSize |  |
| `spam_verdict` | clean \| review \| spam[] | at most 3 items, one of clean, review, spam |

### RuleAction object

| Name | Type | Notes |
| --- | --- | --- |
| `type` | add_label \| remove_label \| archive \| move_to_folder \| mark_read \| forward_to \| auto_reply \| reject \| webhook \| assign | required |
| `label` | string | add_label, remove_label, move_to_folder: the label, which is also the folder, max length 64 |
| `address` | string (email) | forward_to: a verified address of the organization |
| `reason` | string | reject: one line, no address, sent back in the 550, max length 200 |
| `webhook_id` | string (uuid) | webhook: the endpoint that receives rule.matched |
| `subject` | string | auto_reply: the subject of the answer |
| `text` | string | auto_reply: the body of the answer, max length 4000 |
| `assignee` | string (uuid) | assign: the member who takes the ticket |

### RuleApplyReport object

| Name | Type | Notes |
| --- | --- | --- |
| `job_id` | string (uuid) |  |
| `rule_id` | string (uuid) |  |
| `status` | pending \| running \| done |  |
| `matched` | integer |  |
| `applied` | integer |  |
| `skipped` | object |  |
| `created_at` | string (date-time) |  |
| `finished_at` | string (date-time) |  |
