# Messages and threads

> How inbound mail is parsed, threaded, labeled and screened before your agent reads it.

Source: https://mueslimail.com/docs/messages-and-threads/

Inbound mail is parsed into **messages** and grouped into **threads** using the `In-Reply-To` and `References` headers. Your agent reads conversations, not raw MIME.

## Threads

List threads of one inbox, or across the whole organization with `GET /v0/threads`. Lists are newest first and paginated with `limit` (1 to 100, default 20) and `page_token` (from `next_page_token`). `GET /v0/threads/{thread}` returns the thread with all messages, oldest first.

## Messages

Listing returns metadata without bodies, to keep responses small for agents. Getting one message returns:

* `text` and `html`: the bodies as sent.
* `extracted_text`: only the new text, with quoted history and mobile signatures removed. Set by the gatekeeper; prefer it when you pass a message to a model.
* `authentication_results`: DKIM result for the From domain, spam score and spam action.
* `gatekeeper`: prompt injection screening, see below.
* `attachments`: id, filename, content type and size. See [Attachments](https://mueslimail.com/docs/attachments.md).

The original message is available as RFC 822 from `GET .../messages/{message}/raw`. The `{message}` path parameter accepts the message UUID or the RFC `Message-ID` (URL encoded).

## Gatekeeper verdict

Every inbound message is screened for prompt injection before it is marked complete. The verdict is `clear` or `review`, with a score and the reasons that triggered it, such as `instruction_override`, `data_exfiltration`, `role_hijack`, `fake_tool_markup`, `hidden_html_text`, `invisible_unicode` or `dmarc_fail`.

```json
"gatekeeper": {
  "status": "complete",
  "verdict": "review",
  "score": 0.8,
  "reasons": ["instruction_override", "data_exfiltration"],
  "model": "gatekeeper-heuristics-v1"
}
```

> Careful: A `clear` verdict lowers risk, it does not remove it. Keep sensitive actions (payments, sharing credentials, forwarding mail) behind a confirmation step in your agent, whatever the verdict.

`status` is `pending` until screening finishes, `unavailable` if the screener could not run and `timeout` if no verdict arrived within 15 minutes. Messages with verdict `review` also get the `review` label, and the `message.security.review` event fires.

## Screen before notify

Every organization has the setting `screen_before_notify`, on by default (see `GET /v0/auth/me`). Inbound mail is held until the gatekeeper verdict exists, so an agent never reads a prompt injection before screening finishes. The message field `screening` shows the hold state:

| `screening` | Meaning | Visible | Events |
| --- | --- | --- | --- |
| (pending) | Waiting for the gatekeeper. | Never returned. | None yet. |
| `allowed` | Verdict `clear`. | Yes. | `message.security.completed`, then `message.received`. |
| `review` | Verdict `review`, screener unavailable or timed out: the hold fails closed. | Only with `include_review=true` and `message_review`. | `message.security.review` only, without bodies. |
| `released` | A reviewer released it. | Yes. | `message.received`. |

Held messages are left out of message and thread lists, gets, raw downloads and attachments, and cannot be replied to or relabeled. A thread started by a held message stays hidden until one of its messages is visible. The same rules apply to the MCP tools.

```bash
# a key with message_read and message_review
curl -s "https://api.mueslimail.com/v0/inboxes/billing@agents.your-domain.eu/messages?include_review=true&label=review"   -H "Authorization: Bearer $MAI_REVIEW_KEY"

# a key with message_release: the message becomes visible and message.received fires
curl -s -X POST "https://api.mueslimail.com/v0/inboxes/billing@agents.your-domain.eu/messages/$MESSAGE_ID/release"   -H "Authorization: Bearer $MAI_REVIEW_KEY"
```

> Careful: Give `message_review` and `message_release` to a human review tool, not to the agent that reads the inbox: an agent that can release its own mail can be talked into it. Every release is written to the audit log. Keys without a permission list hold every permission, including these two, so give agents scoped keys.

## Labels

The platform sets system labels: `received` and `unread` on inbound mail, `sent` on outbound mail, `spam` when the spam filter flags a message, `review` when the gatekeeper does. Add your own labels to track workflow state, and filter any list with `?label=`.

```bash
curl -s -X PATCH "https://api.mueslimail.com/v0/inboxes/billing@agents.your-domain.eu/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $MAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"add_labels": ["invoice", "approved"], "remove_labels": ["unread"]}'
```

## Endpoints

### GET /v0/inboxes/{inbox}/threads

List threads of an inbox

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `limit` | integer | in query, min 1, max 100, default 20 |
| `page_token` | string | in query |
| `label` | string | in query |
| `include_review` | boolean | in query, Also return messages held for security review; needs message_review, default false |

Responses:

* `200` OK: ThreadList

### GET /v0/threads

List threads across the organization

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `limit` | integer | in query, min 1, max 100, default 20 |
| `page_token` | string | in query |
| `label` | string | in query |
| `include_review` | boolean | in query, Also return messages held for security review; needs message_review, default false |

Responses:

* `200` OK: ThreadList

### GET /v0/threads/{thread}

Get a thread with the messages the caller may see, oldest first

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `thread` | string (uuid) | in path, required |
| `include_review` | boolean | in query, Also return messages held for security review; needs message_review, default false |

Responses:

* `200` OK: Thread

### GET /v0/inboxes/{inbox}/messages

List message metadata (no bodies)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `limit` | integer | in query, min 1, max 100, default 20 |
| `page_token` | string | in query |
| `label` | string | in query |
| `include_review` | boolean | in query, Also return messages held for security review; needs message_review, default false |

Responses:

* `200` OK: MessageList

### GET /v0/inboxes/{inbox}/messages/{message}

Get a message with bodies, extracted_text, authentication results and gatekeeper verdict

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |
| `include_review` | boolean | in query, Also return messages held for security review; needs message_review, default false |

Responses:

* `200` OK: Message
* `403` Error envelope: Error
* `404` Error envelope: Error

### PATCH /v0/inboxes/{inbox}/messages/{message}

Add or remove custom labels

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |

Body:

| Name | Type | Notes |
| --- | --- | --- |
| `add_labels` | string[] |  |
| `remove_labels` | string[] |  |

Responses:

* `200` OK: Message

### GET /v0/inboxes/{inbox}/messages/{message}/raw

Raw RFC 822 message

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |
| `include_review` | boolean | in query, Also return messages held for security review; needs message_review, default false |

Responses:

* `200` OK: message/rfc822

### POST /v0/inboxes/{inbox}/messages/{message}/release

Release a message held for review (needs message_release, audited)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |

Responses:

* `200` Released: Message
* `403` Error envelope: Error
* `404` Error envelope: Error
* `409` Error envelope: Error

### Thread object

| Name | Type | Notes |
| --- | --- | --- |
| `thread_id` | string (uuid) |  |
| `inbox_id` | string |  |
| `subject` | string |  |
| `preview` | string |  |
| `labels` | string[] |  |
| `message_count` | integer |  |
| `timestamp` | string (date-time) |  |
| `messages` | Message[] |  |
| `retention_purged_at` | string (date-time) | Set when retention deleted the last message of the thread. A thread with no messages answers 200 with this and retention_notice, instead of 404, because a ticket may point at it. |
| `retention_notice` | string | The sentence in place of a body the retention window removed. |

### Message object

| Name | Type | Notes |
| --- | --- | --- |
| `id` | string (uuid) |  |
| `message_id` | string | RFC 5322 Message-ID with angle brackets |
| `thread_id` | string (uuid) |  |
| `inbox_id` | string |  |
| `direction` | in \| out |  |
| `from` | string |  |
| `to` | string[] |  |
| `cc` | string[] |  |
| `subject` | string |  |
| `text` | string |  |
| `html` | string |  |
| `extracted_text` | string | New text without quoted history (set by the gatekeeper) |
| `preview` | string |  |
| `snippet` | string | Excerpt around the match. Only in search results, and empty for a key holding message_metadata without message_read |
| `labels` | string[] |  |
| `in_reply_to` | string |  |
| `references` | string[] |  |
| `status` | received \| queued \| sending \| sent \| failed \| held \| cancelled |  |
| `delivery_error_code` | unauthorized_ip \| auth_failed \| relay_unreachable \| connection_refused \| tls_failed \| unknown | Why an outbound message did not leave. Set when a delivery attempt was refused, absent otherwise; with status failed, the message is not delivered and the console says so. |
| `delivery_error` | string | What the mail server said when it refused the message, one line, at most 500 characters, never a credential. |
| `last_error` | string | The last reason a message did not leave, from the transport or from the platform: a send held by undo send whose window closed while sending was paused fails with the reason of the pause here, and no delivery_error, because no mail server refused it. |
| `cancel_window_ends_at` | string (date-time) | End of the undo window of a send born held. Before it, POST .../cancel withdraws the message; after it, the message is on its way and cancel answers 409 invalid_state. Absent on a message that never had a window. |
| `authentication_results` | object | What the SMTP edge verified for inbound mail: dkim, spf, dmarc, dmarc_policy, antivirus, spam_score, spam_action. Mail failing DMARC is labelled dmarc-fail, and spam under p=quarantine or p=reject. |
| `gatekeeper` | object |  |
| `screening` | review \| allowed \| released | Hold state under screen before notify; absent when the message was not held. Pending messages are never returned. |
| `attachments` | object[] |  |
| `created_at` | string (date-time) |  |
