# Inbound Email

Source: https://molted.email/docs/inbound

Receive and classify inbound email with platform labels, routing actions, and safety flags.

Molted Email can receive inbound email on your mailboxes, label the mail it acts on (opt-outs, automatic replies, bounces), check it for safety, and route it to the right handler. What each message is about is for your agent to decide.

## How inbound mail is checked

Every message that arrives goes through the same steps before your agent can read it:

1. **Sender authentication.** SPF, DKIM and DMARC results come only from the receiving provider's own verdict (Resend's `authentication` object, computed by the receiving mail server at receipt). An `Authentication-Results` header inside the message is never trusted, since a sender can add one claiming anything: when the provider sent no verdict, every `pass` in that header is read as `none` and only its failures are kept. A DKIM `pass` is a signature aligned with the From domain, so it counts as a DMARC pass even when the domain has no DMARC policy (`p=none`). Only a provider-verified DMARC pass earns the credits below (the allow-list downgrade, the authenticated-sender spam credit), and it is what a message needs to join an existing conversation by any match that does not rest on your own `Message-ID` (see [Threads](https://molted.email/docs/threads.md#replies-that-only-look-like-replies)). The results are stored on the message as `auth_results`.
2. **Safety classification** (Carapace). A deterministic keyword and signal floor scores the message for spam, phishing, malware, abuse and impersonation, and a separate detector scores prompt-injection risk across the subject, text and HTML. Carapace's model layer (Jev) also scores messages for eligible workspaces (new workspaces since 2026-10-10, every workspace from 2026-11-09). For prompt-injection risk its answer is live: the level applied is the higher of the keyword and model levels, and a `high` keyword hit is never lowered. For the safety verdict it is still shadow only: it records what it would have decided and never changes the verdict, quarantine or hold. Without a model answer (a timeout, an error, or a workspace that isn't eligible yet) the keyword floor alone decides.
3. **Labelling and routing.** Clean mail gets its [platform label](#platform-labels) and [routing action](#routing-actions). Mail with a `quarantine` or `reject` verdict is held for a person (see [Safety classification](#safety-classification)).
4. **Holds.** A message with high injection risk is quarantined (`quarantineHighInjection`), one with a critical thread anomaly is held (`holdCriticalAnomalies`), and one past the workspace's inbound allowance is held until the plan allows it (see [Billing](https://molted.email/docs/billing.md)).

## Platform labels

Molted labels an inbound message only when it acts on it. Every inbound message carries a `platformLabel`:

| `platformLabel` | Meaning | What Molted does |
| - | - | - |
| `opt_out` | The sender asked to stop receiving mail. | Suppresses the sender automatically when it is confident. When it isn't (`routingAction: "require_approval"`, see [Routing actions](#routing-actions)), the request waits for a decision, and the agent reading the message should act on it (add a suppression or unsubscribe the contact). Use `check_suppression` (`GET /v1/suppressions/check`) to confirm. |
| `auto_reply` | An automatic reply: out of office, a vacation responder. | Never treats it as a reply: it doesn't cancel a scheduled follow-up or count as engagement. |
| `delivery_notice` | A bounce or delivery status notification. | Same as `auto_reply`. |
| `null` | None of these. | Nothing. The message is yours to read. |

Only mail the safety check delivered gets a label. Mail it did not deliver (quarantined or rejected) gets `null`: an unsubscribe footer on quarantined spam is not the sender asking to stop, and nothing is suppressed for it.

`opt_out` reads only what the sender wrote. It ignores the quoted reply history (`>` lines, "On ... wrote:" blocks, forwarded messages) and footer or template text the sender quotes or pastes: a line with a `{{...}}` placeholder, an "Unsubscribe" link label in a `·` or `|` separated footer line, the text of an unsubscribe link. A support question that quotes your footer is not an opt-out; "please unsubscribe me" above it still is.

Business meaning, such as a sales lead, a support request or a complaint, belongs to your agent: read the message and decide. Molted doesn't guess it for you. Record what you decide with [your own labels](https://molted.email/docs/labels.md): they show on every thread and you can filter by them, and they never change suppression or routing.

Next to `platformLabel`, `autoReplyKind` says what the message's own headers said: `human`, `auto_reply` (`Auto-Submitted`, `X-Autoreply`, `X-Autorespond`, `Precedence: auto_reply`), `delivery_report` (`multipart/report`, `message/delivery-status`) or `unknown` (no headers to check). An opt-out wins over the headers: an opt-out sent from an auto-responder is still `opt_out`. Outbound messages have `null` for both. `autoReplyKind` is known as soon as a message arrives, but `platformLabel` stays `null` while the message is [pending classification](#pending-classification).

You get `platformLabel` and `autoReplyKind` on:

- each message of a thread (`GET /v1/agent/threads/:id`, the MCP `get_thread` tool, `molted threads get`), in `messages[]` and in `envelopedMessages[].metadata`
- `GET /v1/inbound` and the held-messages queue (`GET /v1/agent/override/held-messages`), as `platform_label` next to `auto_reply_kind`
- `lastClassification` in `GET /v1/agent/thread-context` (MCP `get_context`, `molted contacts context`)
- the `inbound.classified` event, over SSE, webhooks and `molted listen` (see [Events](https://molted.email/docs/events.md))
- the [classify endpoints](#classify-an-email) below

### Correcting a label

When a label is wrong, say so. The agent that reads the email decides; no person has to approve it. In the portal, a person can do the same from the conversation ("Not an opt-out", "This is an opt-out"); the conversation then shows it as feedback, for example "Marked as not an opt-out (feedback only)".

```
POST https://mailbox.molted.email/v1/agent/override/messages/:messageId/label-correction
```

This route, like every spam, sender-reputation and allow-list route on this page, is served by the mailbox service (`https://mailbox.molted.email`, the host the CLI and MCP server use), which fills in your `tenantId` from the key. `api.molted.email` serves only the queue listings, held messages and approve/reject/edit/escalate under `/v1/agent/override/*` (with `tenantId` in the query); the examples on this page use the mailbox service.

```json title="Request body"
{
  "label": "none"
}
```

`label` is what the `platformLabel` should have been: `opt_out`, `auto_reply`, `delivery_notice`, or `none` (not any of these, for example "not an opt-out"). `messageId` is the inbound message's id from `GET /v1/agent/threads/:id`.

```json title="Response (201)"
{
  "messageId": "msg-uuid",
  "id": "correction-uuid",
  "threadId": "thread-uuid",
  "originalLabel": "opt_out",
  "correctedLabel": "none",
  "alreadyRecorded": false,
  "auditEventId": "audit-uuid"
}
```

`originalLabel` is the message's label when you corrected it. A correction is **feedback only**: it helps Molted learn to sort mail, and it changes nothing else. Not routing, not a suppression, and not the `platformLabel` anyone reads: the message keeps the classifier's label. So act on the message yourself:

- **A real opt-out Molted missed:** correct it to `opt_out`, then add a suppression ([Suppressions](https://molted.email/docs/suppressions.md), MCP `add_suppression`, `molted suppressions add`).
- **Not an opt-out:** correct it to `none`. If Molted already suppressed the sender, the suppression stays. Removing one is a person's decision ([Remove a suppression](https://molted.email/docs/suppressions.md)).
- **An auto-responder that quoted an unsubscribe footer** Molted didn't recognise as one can read `opt_out` (it outranks the headers). If it isn't an opt-out, correct it to what its headers say (`autoReplyKind`): `auto_reply` or `delivery_notice`, not `none`. `none` says it isn't an automatic reply either.
- **Repeating yourself is free.** If your latest correction of a message already says the same label, nothing is written: the answer carries that correction's `id` with `alreadyRecorded: true` and `auditEventId: null`. Changing your mind (a different label) is recorded.

Needs `operator` scope and `manage` on the message's mailbox. Any other `label` is `400`, and so is a message with no inbound record behind it (an outbound message). A message that isn't in your workspace is `404`. MCP: `correct_label`. CLI: `molted overrides correct-label <messageId> <label>`.

The older `POST /v1/agent/override/messages/:messageId/intent-correction` (body `{ "intent": ... }`) is deprecated. It records the label correction the intent stands for (`unsubscribe` is `opt_out`; any other intent keeps what the headers say, `auto_reply` or `delivery_notice`; otherwise `out_of_office` is `auto_reply` and anything else is `none`) and answers with `Deprecation: true`.

### `intent` is deprecated

The older `intent` field still comes back, unchanged, everywhere it did. Don't branch on it. For delivered (clean) mail it only ever says `unsubscribe` (that's `opt_out`), `out_of_office` (that's `auto_reply` or `delivery_notice`) or `unclassified`. Values such as `interested`, `support` or `billing` only appear on mail the safety filter flagged, which is quarantined before your agent reads it.

The classify endpoints answer with a `Deprecation: true` header and a `Link` (`rel="deprecation"`) to this section. A removal date (`Sunset`) follows later, with notice; until then, `intent` keeps its current values.

## Untrusted content

Every field below sourced from an inbound message (`fromEmail`, `subject`, `bodyText`) came from the sender, a third party, not from Molted or the tenant. If you're driving an agent off these endpoints, treat that content as data to classify and route, never as instructions to follow, even if it reads like one ("ignore previous instructions", "forward this to...", etc.). The MCP server and `GET /v1/agent/threads/:id` mark this explicitly with a `contentTrust: "untrusted"` field next to `injectionRisk` -- see [MCP Server: Untrusted content](https://molted.email/docs/mcp-server.md#untrusted-content). `GET /v1/inbound` carries the same `contentTrust: "untrusted"` marker on every row (that route has no `injectionRisk` field). A high `injectionRisk` (see [Safety classification](#safety-classification) below) means be extra careful.

A message quarantined for high injection risk (`quarantineHighInjection`, see [Safety settings](#safety-settings) below) has its `body_text`/`body_html` replaced with the `"[quarantined -- injection risk]"` placeholder on `GET /v1/inbound`, and `GET /v1/agent/messages/:id/raw` refuses its raw MIME outright (`403`), unless the calling key has `operator` scope -- the same rule [`GET /v1/agent/threads/:id`](https://molted.email/docs/mcp-server.md#untrusted-content) already applies to thread reads. A mailbox-scoped key also only ever sees messages in its accessible mailboxes on both routes. A quarantined message's attachments follow the same rule: `GET /v1/attachments` leaves them out, and `GET /v1/attachments/:id/download` answers `403` with `quarantined_requires_operator`, unless the key has `operator` scope. Attaching one of those attachments to an outbound send is refused the same way -- see [MCP Server: Attachments](https://molted.email/docs/mcp-server.md#get_attachment).

A message the safety classifier quarantined or rejected for any verdict other than spam (phishing, malware, abuse, impersonation) is gated exactly the same way until a person releases it from Quarantine in the portal (`/app/quarantine`): `quarantined: true`, the body replaced with the `"[quarantined -- flagged as unsafe, held until a person releases it]"` placeholder, the raw MIME refused, and its attachments left out, refused for download and refused on a send, unless the calling key has `operator` scope. After the release its content and attachments open up (an attachment the file scan blocked stays blocked). Deleting it from Quarantine keeps it gated. Spam is not gated this way.

### Pending classification

Whether a message is quarantined, or held because the workspace's inbound allowance is used up (see [Billing](https://molted.email/docs/billing.md)), is decided a moment after it arrives, once classification has finished. The `inbound.received` event fires before that. Until the decision is made, the message is **pending classification**, and every agent read path hides its content, whatever the key's scopes:

- `GET /v1/inbound` returns it with `pending_classification: true`, `subject`, `from_name`, `reply_to` and `body_html` set to `null`, and `body_text` set to `"[pending classification: this message is still being checked; its content is shown once the check finishes]"`.
- `GET /v1/agent/messages/:id/raw` (`molted messages raw <messageId>`) answers `403` with `pending_classification`. Once classified, it answers `403` with `inbound_quota_held` while the message is held for the inbound allowance, and `quarantined_requires_operator` for a quarantined message unless the key has `operator` scope.
- Its attachments are not listed by `GET /v1/attachments`, `GET /v1/attachments/:id/download` answers `403` with `pending_classification`, and attaching one to an outbound send is refused the same way.
- The contact timeline (`GET /v1/agent/thread-context`, the MCP `get_context` tool) returns its `subject` as `null`, with `pending_classification: true`.

Classification usually takes a few seconds, and a message is normally ready by the time `inbound.classified` fires. To read a new message as soon as it is ready, react to `inbound.classified` instead of `inbound.received` (and retry later if it still reads `pending_classification: true`), or read the thread: a message only appears in `GET /v1/agent/threads` once it has been classified.

## List inbound messages

```
GET https://mailbox.molted.email/v1/inbound
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/inbound" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

It returns the 100 newest messages. Filter by the message's routing status with `?status=`: `pending` (waiting for a person), `completed` (approved, or handled by the classifier) or `rejected` (a person or agent said no; see [Approve or reject routing](#approve-or-reject-routing)). There is no CLI command or MCP tool for this list: agents read mail through threads (`molted threads`, MCP `read_inbox` / `get_thread`).

### Response

```json title="Response"
[
  {
    "id": "msg_abc123",
    "tenant_id": "tenant_abc123",
    "from_email": "customer@example.com",
    "to_email": "support@yourdomain.com",
    "subject": "Need help with billing",
    "body_text": "I have a question about my invoice...",
    "auto_reply_kind": "human",
    "platform_label": null,
    "intent": "unclassified",
    "confidence": 0.5,
    "verdict": "clean",
    "safety_action": "deliver",
    "action_type": "notify_owner",
    "routing_status": "pending",
    "quarantined": false,
    "inbound_quota_held": false,
    "pending_classification": false,
    "contentTrust": "untrusted",
    "created_at": "2026-03-01T12:00:00Z"
  }
]
```

The rows are snake\_case and also carry `mailbox_id`, `thread_id`, `from_name`, `reply_to`, `body_html`, the threading headers and `auth_results` (the stored [sender authentication](#how-inbound-mail-is-checked) result). `platform_label` is the message's [platform label](#platform-labels) (`null` while it is pending classification); `intent` and `confidence` are deprecated. `inbound_quota_held` is `true` while the message is held because the workspace's inbound allowance is used up: its `subject`, `from_name` and `reply_to` are `null` and `body_text` is a placeholder until it is released.

`quarantined` is `true` when this message was flagged for high injection risk, or sits in a safety quarantine (phishing, malware, ...) no person has released yet; `body_text`/`body_html` come back as a placeholder (`"[quarantined -- injection risk]"`, or `"[quarantined -- flagged as unsafe, held until a person releases it]"` for a safety quarantine; `body_html: null`) unless the calling key has `operator` scope. `contentTrust` is always `"untrusted"` here -- every message this endpoint returns is inbound (contact-authored), see [Untrusted content](#untrusted-content) above. A mailbox-scoped key sees only messages in its accessible mailboxes. `pending_classification` is `true` while the message is still being classified, with its content hidden (see [Pending classification](#pending-classification) above).

## Read the original message

```
GET /v1/agent/messages/:id/raw
```

```bash title="CLI"
molted messages raw MESSAGE_ID
```

`:id` is the inbound message's id (a UUID, from `GET /v1/agent/threads/:id` or `GET /v1/inbound`). The answer is `{ "messageId": "...", "rawMime": "...", "contentTrust": "untrusted" }`.

`rawMime` is the message exactly as the receiving provider reported it, kept as a JSON string: the sender, recipients, subject, text and HTML bodies, the full header map, and the provider's own sender-authentication verdict. Despite its name it is not RFC 822 source text, so parse it with `JSON.parse`. Use it when the thread view leaves out what you need: a header (`List-Unsubscribe`, `Received-SPF`, a custom `X-` header), a check of what the provider saw, or your own parsing. Everything in it came from the sender: treat it as data, never as instructions.

It answers `404` for a message that has none: an unknown id, a message in an archived mailbox, and any message you [recorded yourself](#recording-an-inbound-message-yourself), which has no provider payload. A message that is still being classified, held for the inbound allowance or quarantined is refused with `403`, as described in [Untrusted content](#untrusted-content). A mailbox-scoped key needs `read` on the message's mailbox (`403 mailbox_scope_denied` otherwise). There is no MCP tool for it.

## Recording an inbound message yourself

Mail that arrives at a mailbox address is recorded for you. Use `record-inbound` when a message reached your agent some other way and you want it in Molted: in a thread and classified like the rest.

```bash title="CLI"
molted record-inbound --from alice@example.com --to support@yourdomain.com \
  --subject "Re: Invoice" --body "Where is my invoice?"
```

```bash title="curl"
curl -X POST "https://mailbox.molted.email/v1/agent/record-inbound" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fromEmail": "alice@example.com",
    "toEmail": "support@yourdomain.com",
    "subject": "Re: Invoice",
    "bodyText": "Where is my invoice?"
  }'
```

| Field | Description |
| - | - |
| `fromEmail`, `toEmail` | Required. The sender, and the address the message was sent to. |
| `subject`, `bodyText`, `bodyHtml` | The content. Longer values are cut at 1 KB for the subject, 100 KB for the text and 500 KB for the HTML. |
| `inReplyTo`, `referencesHeader` | The threading headers. A message that replies to one of your sends is linked to it and to its thread. |
| `providerMessageId` | Your own id for the message. It is stored on the message; it is not used to skip duplicates. |
| `mailboxId` | The mailbox to record it in. Leave it out and Molted picks the mailbox from `toEmail`: an exact address match first, else a catch-all mailbox for that domain. |
| `threadId` | Put the message in this existing thread. |

```json title="Response (201)"
{
  "messageId": "msg-uuid",
  "threadId": "thread-uuid",
  "isNewThread": true,
  "linkedRequestId": null,
  "classificationJobId": "msg-uuid"
}
```

`linkedRequestId` is the send this message replies to, when its headers match one. `classificationJobId` is `null` if the classification job could not be queued.

The call returns before classification finishes. The message is **pending classification** until it has run, so it stays hidden from every read path for a moment (see [Pending classification](#pending-classification)), then gets its [platform label](#platform-labels), safety verdict and routing action like any other inbound message, and an `inbound.classified` event is published. No `inbound.received` event is published for a recorded message.

What a recorded message does not have: sender authentication and headers. Nothing verified it, so it never earns the DMARC-pass credits (see [How inbound mail is checked](#how-inbound-mail-is-checked)), and an unauthenticated message that only looks like a reply in an existing thread (same sender, `Re:` subject) starts a thread of its own and is flagged `possible_spoofed_reply` rather than joining; pass `threadId` when you know where it belongs. With no headers to read, its `platformLabel` can only be `opt_out` or `null`, never `auto_reply` or `delivery_notice`. It has no raw payload either (see [Read the original message](#read-the-original-message)).

A mailbox-scoped key needs `send` on the mailbox the message lands in, whichever way it is chosen (`mailboxId`, `toEmail` or `threadId`); another mailbox is `403 mailbox_scope_denied`. There is no MCP tool for it.

## Approve or reject routing

For messages that require manual approval before being routed (a `require_approval` routing action):

```
POST https://mailbox.molted.email/v1/inbound/:id/approve
POST https://mailbox.molted.email/v1/inbound/:id/reject
```

```bash title="curl"
curl -X POST "https://mailbox.molted.email/v1/inbound/ROUTING_ACTION_ID/approve" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Approving completes the routing action (`{ "approved": true }`); for a possible opt-out it also suppresses the sender. Rejecting sets its status to `rejected` (`{ "rejected": true }`): the email stays where it is and no action runs. Both record who decided and when. Only a pending routing action can be decided: repeating the same decision is a no-op, and the other decision on one already decided returns `409` with `{ "error": "already_decided", "status": "completed" }` (or `"rejected"`). Both are [human decisions](https://molted.email/docs/approval-queues.md#decisions-need-a-human): a person makes them on the portal's Home, where each one is a card.

`ROUTING_ACTION_ID` is the routing action's ID. An ID that doesn't exist in your tenant returns `404 Not Found`. A mailbox-scoped key needs `manage` scope on the underlying message's mailbox; approving or rejecting one outside that scope answers `403` with `mailbox_scope_denied` and leaves it pending. `POST /v1/inbound/bulk-approve?label=` (bulk-approves every pending routing action whose message carries that [platform label](#platform-labels): `opt_out`, `auto_reply` or `delivery_notice`) applies the same scope: a mailbox-scoped key only approves within its own mailboxes, silently leaving any out-of-scope pending action untouched; an admin/all-mailbox key is unaffected. Approving `opt_out` suppresses each sender. Mail the safety check held has no label, so it is never included. The older `?intent=` form still works but is deprecated (the response carries `Deprecation: true`): it matches the stored intent, as before, and refuses `legal` and `security`. Pass one of `label` or `intent`.

## Classify an email

Classify an email the way the live inbound pipeline would, before or without receiving it.
This calls the exact same classification pipeline the live inbound worker uses --
the same tenant safety settings, spam feedback, sender reputation and allow-list --
so the result matches what happens to a real inbound message with the same
content and sender, except for sender authentication (see below).

```bash title="CLI"
molted classify --subject "Re: Invoice" --body "Where is my invoice?" --from alice@example.com
```

```bash title="curl"
curl -X POST "https://mailbox.molted.email/v1/agent/classify-intent" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Re: Invoice",
    "bodyText": "Where is my invoice?",
    "fromEmail": "alice@example.com"
  }'
```

`fromEmail` is optional but recommended: when set, it loads that sender's tenant
safety settings, spam feedback, sender reputation and allow-list status -- the
same context the live pipeline uses. `fromName`/`replyTo` are also optional (the
same sender fields inbound ingestion captures), and `bodyHtml` feeds the
excessive-links safety signal. Omit `fromEmail` when there's no real sender to
classify against -- no placeholder is needed or used.

This endpoint takes no headers, so it can't recognise an automatic reply or a
bounce: `platformLabel` is `opt_out` or `null` here, and `autoReplyKind` is
always `unknown`. A message you actually receive gets all three labels.

For the same reason it has no [sender authentication](#how-inbound-mail-is-checked)
result: SPF, DKIM and DMARC failures add nothing, and neither DMARC-pass credit
applies (an `allowedSenders` or allow-list match never downgrades `spam` to
`clean` here, and there is no authenticated-sender spam credit). For an
authenticated sender the live pipeline can therefore be more lenient on spam
than this preview, and for a spoofed one stricter. MCP: `classify_inbound`.

### Classify response

```json title="Response - clean"
{
  "platformLabel": null,
  "autoReplyKind": "unknown",
  "safetyVerdict": "clean",
  "safetyAction": "deliver",
  "route": {
    "actionType": "notify_owner",
    "slaMinutes": 60,
    "status": "pending"
  },
  "signals": {
    "safety": [],
    "injectionRiskLevel": "none",
    "injectionScore": 0,
    "injectionMatchedPatterns": []
  },
  "intent": "unclassified",
  "confidence": 0.5,
  "masked": true,
  "suggestedAction": "notify_owner",
  "classifierVersion": "v3-safety",
  "flags": [],
  "allScores": []
}
```

```json title="Response - opt-out"
{
  "platformLabel": "opt_out",
  "autoReplyKind": "unknown",
  "safetyVerdict": "clean",
  "safetyAction": "deliver",
  "route": {
    "actionType": "trigger_unsubscribe",
    "slaMinutes": 0,
    "status": "completed"
  },
  "signals": {
    "safety": [],
    "injectionRiskLevel": "none",
    "injectionScore": 0,
    "injectionMatchedPatterns": []
  },
  "intent": "unsubscribe",
  "confidence": 0.95,
  "masked": false,
  "suggestedAction": "trigger_unsubscribe",
  "classifierVersion": "v3-safety",
  "flags": [],
  "allScores": [
    { "intent": "unsubscribe", "score": 0.95, "keywordMatches": 2, "subjectMatches": 0, "bodyMatches": 2 }
  ]
}
```

| Field | Description |
| - | - |
| `platformLabel` | `opt_out` or `null` here (see [Platform labels](#platform-labels)). |
| `autoReplyKind` | Always `unknown` here: this endpoint takes no headers. |
| `safetyVerdict` | Safety classification result (see [Safety classification](#safety-classification)). |
| `safetyAction` | Action the safety system recommends: `deliver`, `quarantine`, or `reject`. |
| `route` | The full action the live pipeline would take: `actionType`, `targetEmail?`, `slaMinutes`, `overrideReason?`, and `status` (`pending` or `completed`). Includes any safety override, e.g. `actionType: "spam"` or `"require_approval"` for a quarantined message. |
| `signals` | The safety/injection detail behind `safetyVerdict`: matched safety signal categories, and the injection risk level/score/matched patterns. |
| `flags` | Array of safety flags that may require attention (see [Safety flags](#safety-flags)). |
| `suggestedAction` | Kept for backward compatibility; always matches `route.actionType`. |
| `intent` | Deprecated: read `platformLabel`. For clean mail only `unsubscribe` or `unclassified` (see `masked`). |
| `confidence` | Deprecated with `intent`: its score, 0 to 1. |
| `masked` | True when `intent`/`confidence` were replaced with `unclassified` @ 0.5, which production does for every clean email that isn't an opt-out. |
| `allScores`, `runnerUpIntent`, `runnerUpConfidence` | Deprecated with `intent`: the keyword scoring behind it, for debugging. |

The response carries `Deprecation: true` and a `Link` header for the `intent` field.
This endpoint never calls the Carapace/Jev model shadow -- only the deterministic
keyword pipeline the worker's live routing decision itself is based on.

### Batch classify

Classify multiple messages in a single request. Each message accepts the same
optional `bodyHtml`/`fromEmail`/`fromName`/`replyTo` fields as a single
classify call above:

```bash title="CLI"
molted classify batch --emails '[
  {"subject": "Re: Order", "body": "When does it ship?", "from": "alice@example.com"},
  {"subject": "Thanks!", "body": "Got it, looks great."}
]'
```

```bash title="curl"
curl -X POST "https://mailbox.molted.email/v1/agent/batch/classify-intent" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"subject": "Re: Order", "bodyText": "When does it ship?", "fromEmail": "alice@example.com"},
      {"subject": "Thanks!", "bodyText": "Got it, looks great."}
    ]
  }'
```

The response contains a `results` array with one classification per message, in the
same order as the input, each shaped like a single classify response
(including `platformLabel`/`autoReplyKind`/`route`/`signals`). A batch takes at
most 500 messages; more is `400` with `error: "batch_too_large"`. MCP:
`batch_classify` (`messages[]` of `subject`, `body_text` and the same optional
sender fields).

### Intent values (deprecated)

For reference while you move off `intent`: `interested`, `not_now`, `objection`,
`unsubscribe`, `support`, `billing`, `legal`, `security`, `out_of_office` and
`unclassified`. Clean mail only ever gets `unsubscribe`, `out_of_office` or
`unclassified`; the rest only appear on mail the safety filter flagged.

## Routing actions

The classification engine suggests one of these routing actions:

| Action | Description |
| - | - |
| `notify_owner` | Notify the contact's assigned owner. |
| `require_approval` | Hold the message until manually approved. |
| `auto_archive` | Archive the message automatically (e.g., out-of-office replies). |
| `trigger_unsubscribe` | Add the contact to the suppression list and acknowledge -- legally required for opt-out requests under CAN-SPAM, GDPR, CASL. Never auto-archive opt-outs. |
| `escalate` | Reserved: part of the type, but the classifier does not produce it today. |
| `spam` | Mail with a `spam` verdict whose action is `quarantine` or `reject`: held for review, and shown on the portal's Spam page. Its thread sits in the `needs_approval` queue (see [Safety classification](#safety-classification)), not the `spam` queue. Other quarantined or rejected verdicts get `require_approval`. |

A **low-confidence opt-out** (confidence below 0.6), or one that asks for more than
one thing or reads as written to steer your agent, is held as `require_approval`
instead of acting immediately -- a human verifies the opt-out request before the
contact is suppressed. Approving that routing action (`POST /v1/inbound/:id/approve`
or `POST /v1/inbound/bulk-approve?label=opt_out`) adds the sender to the
suppression list at approval time, same as an immediate `trigger_unsubscribe` would
have; leaving it unapproved never suppresses the contact. Correcting a message's
label never changes this: a correction is feedback, it neither stops an opt-out
from being honoured nor removes a suppression.

**Routes and SLAs.** For mail the safety check delivered (verdict `clean`), the route and its SLA follow the platform label:

| Platform label | Route | SLA |
| - | - | - |
| `opt_out` | `trigger_unsubscribe` | 0 |
| `auto_reply`, `delivery_notice` | `auto_archive` | 0 |
| none | `notify_owner` | 60 minutes |

Per-intent SLAs apply only to mail the safety check did not call clean (spam, phishing, malware, abuse or impersonation), which still carries the keyword classifier's [intent](#intent-is-deprecated): `interested` 5 minutes, `support` 30, `billing` 60, `legal` 30, `security` 15, `unclassified` 60, the rest 0. Such mail is usually quarantined or rejected, which replaces its action but keeps this deadline, so a message a person releases escalates on it. When a workspace's safety settings deliver it anyway, it routes on its intent.

Two overrides apply on top of either table, and `route.overrideReason` names the one that fired:

- A `conflicting_intents` or `adversarial_position` flag (see [Safety flags](#safety-flags)) makes the route `require_approval` with a 15-minute SLA.
- A confidence below 0.6 makes `auto_archive` a `require_approval` with 30 minutes, `notify_owner` a `require_approval` with 60 minutes, and `trigger_unsubscribe` a `require_approval` with 30 minutes (`low_confidence`). Mail with no label, or with intent `unclassified`, skips this check.

`route.slaMinutes` is the length of that deadline for the label. Whether a real message in a mailbox actually gets one is the mailbox's [Reply deadlines](https://molted.email/docs/mailboxes.md#reply-deadlines) switch: with it off, no deadline is set and nothing is escalated for an unanswered email. The route's action type, status and approvals are the same either way.

## Safety classification

Inbound messages are run through a safety classifier that produces a verdict:

| Verdict | Description |
| - | - |
| `clean` | No threats detected. |
| `spam` | Unsolicited bulk email. |
| `phishing` | Credential harvesting attempt. |
| `malware` | Dangerous content detected. |
| `abuse` | Threats or harassment. |
| `impersonation` | Spoofing or BEC attempt. |

A message is given the highest-scoring non-clean verdict when that score reaches the threshold (`0.5`; your `spamThreshold` applies to the `spam` verdict only, so it can never weaken phishing, malware, abuse or impersonation detection). Otherwise it is `clean`.

Every non-`clean` verdict is routed by its per-verdict action (`spamAction`, `phishingAction`, etc. -- see [Safety Settings](#safety-settings) below), one of `deliver`, `quarantine`, or `reject`. A `reject` whose confidence is below 0.6 is downgraded to `quarantine`. `quarantine` and `reject` are handled the same way: the message is stored, never deleted or bounced, and held from your agents until a person decides. The thread lands in the `needs_approval` queue, tagged with `metadata.safety_verdict` and `metadata.safety_action`, alongside ordinary outbound-approval and policy items -- there is no separate spam-verdict routing at classification time (#1644). The dedicated `spam` queue below is filled only by a manual **report spam**, never automatically by the classifier.

In the portal, held mail with a `spam` verdict shows on the Spam page (`/app/spam`), next to threads reported as spam. Held mail with any other verdict, and mail quarantined for prompt injection, shows on the Quarantine page (`/app/quarantine`), where a person releases or deletes it.

## Spam queue and triage

Spam is always stored: the classifier never deletes a message, it only holds it, and it stays until someone marks it not spam or deletes it. In the portal, the Spam page (`/app/spam`) lists both kinds below, with the classifier's reason, for a person to rescue or delete. A person's **Not spam** there can also always allow the sender, which adds the address to `allowedSenders` (see [Safety settings](#safety-settings)); an agent's not-spam never does. A person can also send a conversation to Spam from a handoff card on Home or from the phone app, which closes the handoff.

The `spam` queue holds threads a human (or agent) explicitly reported as spam. Report-spam, delete-spam on a `spam`-queue thread, feedback stats and sender-reputation are not human decisions -- those calls go through directly, with no `decisionUrl` (delete-spam on a classifier-held thread is the exception, see [Delete spam](#delete-spam)) -- but report-spam and delete-spam still refuse (`403`) an agent on a thread a human has taken over, regardless of the tenant's gate mode. Every route in this section needs `operator` scope, and the thread routes need `manage` on the thread's mailbox.

| Action | API | CLI | MCP |
| - | - | - | - |
| List a queue | `GET /v1/agent/override/queues/:queue` | `molted overrides queue spam` | - |
| Queue counts | `GET /v1/agent/override/queues/counts` | `molted overrides counts` | - |
| Mark not spam | `POST /v1/agent/override/:threadId/not-spam` | `molted overrides not-spam <threadId> [--reason]` | `mark_not_spam` |
| Delete spam | `POST /v1/agent/override/:threadId/delete-spam` | `molted overrides delete-spam <threadId>` | `delete_spam` |
| Report spam | `POST /v1/agent/override/:threadId/report-spam` | `molted overrides report-spam <threadId>` | `report_spam` |
| Feedback stats | `GET /v1/agent/override/spam-feedback/stats` | `molted overrides spam-stats` | `spam_feedback_stats` |
| Sender reputation | `GET /v1/agent/override/sender-reputation?domain=` | `molted overrides sender-reputation --domain <d>` | `sender_reputation` |
| List the allow-list | `GET /v1/agent/override/allowlist` | `molted safety allowlist list` | `list_sender_allowlist` |
| Remove an allow-list entry | `DELETE /v1/agent/override/allowlist/:id` | `molted safety allowlist remove <id>` | `remove_sender_allowlist_entry` |

### List spam messages

```
GET https://mailbox.molted.email/v1/agent/override/queues/spam
```

Takes `mailboxId`, `limit` (1 to 50, default 25) and `offset`. The queue listing and the counts are also served on `api.molted.email` (same path, with `?tenantId=`). Valid queues: `needs_approval`, `blocked_by_policy`, `high_risk`, `spam`.

### Mark as not spam

Release a thread from the `spam` queue, **or** from `needs_approval` when the classifier itself put it there for a safety verdict (a false positive) -- both cases clear `metadata.queue`/`safety_verdict`/`safety_action` (and move status to `open`, unless the thread is `waiting` on an outbound send or `escalated`, which this never overrides). A `needs_approval` thread with no safety verdict (e.g. one still waiting on an outbound send, or held for a policy/anomaly reason) is refused with `400` -- there is no spam classification on it to correct.

This is only partly a non-decision, and only for an agent (a human caller is never gated). Releasing a thread the classifier itself held is exactly the kind of loosening [approve/edit/release](https://molted.email/docs/approval-queues.md#decisions-need-a-human) are gated for -- but a thread's `safety_verdict` reflects only the LATEST classified message on it, so an earlier phishing message followed by a later plain-spam one flips the whole thread's verdict to `spam` while the phishing message is still sitting there. A per-verdict exemption is bypassable, not just per-thread, so there isn't one: an agent releases directly ONLY for a thread currently in the `spam` queue that the classifier never held at all (a plain inbox thread manually reported as spam, with no `needs_approval`/`high_risk`/`blocked_by_policy` history). Everything else -- that queue, or ANY safety verdict at all, even one that currently reads `spam` -- comes back `403 human_decision_required` with a `decisionUrl` instead of releasing the thread. An agent is also refused, unconditionally, on a thread a human has taken over.

```
POST https://mailbox.molted.email/v1/agent/override/:threadId/not-spam
```

```json title="Request body"
{
  "reason": "This is a legitimate message"
}
```

### Delete spam

Remove a spam message (it moves to the trash), confirming the spam verdict. Accepts a thread in the `spam` queue, or one the classifier held with a `spam` verdict (`needs_approval` with `metadata.safety_verdict = "spam"`); anything else is a `400` -- since [report-spam](#report-spam) itself refuses to move a `needs_approval`/`high_risk`/`blocked_by_policy` thread into `spam`, delete-spam can never be used to bypass that review either. On a classifier-held thread it is a human decision, gated like reject (it trashes a thread out of a review queue), and it is refused (`409`) while that thread also holds a draft waiting for approval. Refused (`403`) on a thread a human has taken over:

```
POST https://mailbox.molted.email/v1/agent/override/:threadId/delete-spam
```

### Queue counts

The queue counts endpoint includes a `spam` count:

```
GET https://mailbox.molted.email/v1/agent/override/queues/counts
```

```json title="Response"
{
  "needs_approval_outbound": 3,
  "needs_approval_inbound": 1,
  "blocked_by_policy": 0,
  "high_risk": 0,
  "spam": 5
}
```

### Report spam

Move an inbox thread to the spam queue and record feedback:

```
POST https://mailbox.molted.email/v1/agent/override/:threadId/report-spam
```

This action moves the thread into the spam queue and records spam feedback that improves future classification accuracy for that sender. Refused with `400` when the thread is currently `needs_approval`, `high_risk`, or `blocked_by_policy` -- moving one of those into `spam` first would let [mark-not-spam](#mark-as-not-spam) release it without the review that queue exists to enforce. Use approve, reject, or escalate on those instead; report-spam is for a plain inbox thread the classifier missed, not a second way to clear a held one. Refused with `409` for a thread in the trash (restore it first), and (`403`) on a thread a human has taken over, whatever the tenant's gate mode.

## Spam feedback

Every time a message is marked as spam, not spam, or deleted from spam, a feedback record is created. This feedback trains the classifier to improve accuracy over time for each tenant.

Feedback effects:

- **3+ spam reports** from the same sender email: strong spam signal (+0.5) for future messages
- **3+ spam reports** from the same domain: moderate spam signal (+0.4)
- **3+ not-spam marks** for the same sender email: spam score reduced by 0.4 (or by 0.3 for 3+ on the same domain)
- **3+ not-spam marks from a human**, on 3+ distinct messages, for the same sender: auto-added to the sender allow-list below, always as the exact address -- never a whole domain. Counted from human-attributed feedback only (the portal's cookie session carries a verified human claim): an agent or API key can call not-spam itself, but can't grant itself an allow-list entry by doing it three times, since that would let an agent loosen spam filtering, which agents may never do. Marking the SAME message not-spam repeatedly doesn't count three times either -- distinct messages are required.

### Feedback stats

View a summary of all spam feedback for your tenant:

```
GET https://mailbox.molted.email/v1/agent/override/spam-feedback/stats
```

```json title="Response"
{
  "total": 42,
  "spamReports": 30,
  "notSpamReports": 12,
  "uniqueDomains": 8
}
```

## Sender reputation

Molted tracks reputation for inbound sender domains. The reputation score (0-1) is computed from:

- **Positive signals**: replies sent to the sender, passing SPF/DKIM/DMARC, not-spam feedback
- **Negative signals**: spam classifications, spam feedback, auth failures
- **Decay**: scores drift toward 0.5 (neutral) with a 30-day half-life when no activity is seen

The classifier uses reputation to adjust spam scores:

- Score below 0.2 (known spammer): adds +0.3 to spam score
- Score above 0.8 (trusted sender): reduces spam score by 0.2

### Query sender reputation

```
GET https://mailbox.molted.email/v1/agent/override/sender-reputation?domain=example.com
```

```json title="Response"
[
  {
    "domain": "example.com",
    "email": null,
    "totalReceived": 150,
    "spamCount": 2,
    "notSpamCount": 5,
    "repliedCount": 12,
    "authPassCount": 145,
    "authFailCount": 3,
    "reputationScore": 0.82,
    "lastSeenAt": "2026-03-25T10:00:00Z"
  }
]
```

## Sender allow-list

The sender allow-list (`sender_whitelist`) is separate from the `allowedSenders` field in [Safety settings](#safety-settings) below. An entry here skips the spam-verdict-to-`clean` downgrade check (see [Rule evaluation order](#rule-evaluation-order)) -- it can never mask phishing, malware, abuse, impersonation, or a high injection risk, and only applies when the message's DMARC check passes. Entries come from repeated not-spam marks (`source: "manual"`, see [Spam feedback](#spam-feedback) above) or a prior outbound send to that address (`source: "outbound_send"`, always the exact address). Creating or syncing a [contact](https://molted.email/docs/contacts.md) no longer adds one ([#2232](https://github.com/chunkydotdev/molted-mail/issues/2232)): an agent-reachable contact write must not grant itself an allow-list entry, the same tighten-only rule as everything else on this list.

Both routes below require an admin or scope-all-mailboxes key: the list isn't mailbox-scoped data, so a key scoped to one mailbox gets `403` even though it passes every other `operator`-scoped route.

### List allow-list entries

```
GET https://mailbox.molted.email/v1/agent/override/allowlist?limit=100&offset=0
```

```json title="Response"
{
  "items": [
    { "id": "uuid", "type": "email", "coversDomain": false, "email": "friend@example.com", "domain": null, "source": "manual", "createdAt": "2026-01-01T00:00:00.000Z" }
  ],
  "totalCount": 1
}
```

`type`/`coversDomain` are derived from whether `domain` is set: whenever it is, `type` reads `"domain"` and `coversDomain` is `true`, even if `email` also happens to be set -- domain scope is the security-relevant fact, and it's always the broader of the two. The not-spam auto-whitelist (see [Spam feedback](#spam-feedback) above) never writes `domain` at all any more; a `"domain"` row today only comes from an entry created before that change, or a hand-inserted one.

Rows with `source: "saved_contact"` predate [#2232](https://github.com/chunkydotdev/molted-mail/issues/2232) and were written by creating or syncing a contact; that side effect is now removed, but existing rows are left in place. There is no filter on this endpoint for `source` -- page through the list, pick out the `"saved_contact"` rows client-side, and remove the ones you don't want with the delete route below.

### Remove an allow-list entry

Not human-gated: removing an entry only **tightens** spam filtering for that sender, the opposite of a loosening decision like approving a send.

```
DELETE https://mailbox.molted.email/v1/agent/override/allowlist/:id
```

```json title="Response"
{ "id": "uuid", "deleted": true, "auditEventId": "uuid" }
```

CLI: `molted safety allowlist list|remove`. MCP: `list_sender_allowlist`, `remove_sender_allowlist_entry`.

## Safety settings

Safety settings control tenant-level guardrails for inbound message handling. They include per-threat actions, automation flags, and spam filter rules. Settings are tenant-wide and affect all agents and API keys. The full reference, including who may change what, is on [Safety Settings](https://molted.email/docs/safety-settings.md); there is no MCP tool for them.

### Get safety settings

```bash title="CLI"
molted safety get
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/agent/config/safety-settings" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json title="Response"
{
  "tenantId": "tenant_abc123",
  "quarantineHighInjection": true,
  "holdCriticalAnomalies": true,
  "blockCanaryViolations": true,
  "spamAction": "quarantine",
  "phishingAction": "quarantine",
  "malwareAction": "reject",
  "abuseAction": "quarantine",
  "impersonationAction": "quarantine",
  "spamThreshold": 0.5,
  "maxLinksThreshold": 5,
  "blockNoAuth": false,
  "blockedKeywords": [],
  "allowedSenders": [],
  "spamActionLowConfidence": "deliver"
}
```

### Update safety settings

```bash title="CLI"
molted safety update --settings '{
  "spamThreshold": 0.3,
  "blockNoAuth": true,
  "malwareAction": "reject"
}'
```

```bash title="curl"
curl -X PUT "https://mailbox.molted.email/v1/agent/config/safety-settings" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "spamThreshold": 0.3,
    "blockNoAuth": true,
    "malwareAction": "reject"
  }'
```

All fields are optional -- only include what you want to change. Unspecified fields retain their current values. An unknown field is `400`. Changing settings needs a key that reaches every mailbox (a mailbox-scoped key gets `403 mailbox_scope_denied`) with `manage` or `admin` scope. An agent may only tighten them: a loosening change is a human decision (`403 human_decision_required` with a `decisionUrl`), see [Who can change what](https://molted.email/docs/safety-settings.md#who-can-change-what).

### Safety automation flags

| Field | Type | Default | Description |
| - | - | - | - |
| `quarantineHighInjection` | boolean | `true` | Quarantine messages whose injection risk is `high`: thread reads show a placeholder instead of the body. Lower risk levels only add the `injection_risk` flag. |
| `holdCriticalAnomalies` | boolean | `true` | Hold a message whose thread anomaly is `critical` (its behaviour breaks sharply from the thread's pattern) until a person releases it from the held-messages queue. |
| `blockCanaryViolations` | boolean | `true` | Block a send or reply on a thread when its content contains that thread's canary token (see [Canary tokens](https://molted.email/docs/safety-settings.md#canary-tokens)). |

### Per-threat actions

Each threat type can be set to `deliver`, `quarantine`, or `reject`.

| Field | Type | Default | Description |
| - | - | - | - |
| `spamAction` | string | `quarantine` | Action for messages classified as spam. |
| `phishingAction` | string | `quarantine` | Action for phishing attempts. |
| `malwareAction` | string | `reject` | Action for dangerous content. |
| `abuseAction` | string | `quarantine` | Action for threats or harassment. |
| `impersonationAction` | string | `quarantine` | Action for spoofing or BEC attempts. |

### Spam filter rules

| Field | Type | Default | Description |
| - | - | - | - |
| `spamThreshold` | number | `0.5` | Spam verdict threshold (0.1-1.0). Lower means more aggressive filtering. |
| `maxLinksThreshold` | integer | `5` | Max links before triggering the excessive\_links signal (1-100). |
| `blockNoAuth` | boolean | `false` | When true, a message whose SPF, DKIM and DMARC checks all fail gets a heavy spam penalty (+0.5). A check with no result (`none`, for example an unsigned message) is not a failure, so it does not trigger this. |
| `blockedKeywords` | string\[] | `[]` | Custom keyword blocklist (max 100 entries). Each match adds +0.4 to the spam score. |
| `allowedSenders` | string\[] | `[]` | Email addresses or domains whose mail, when the message's DMARC result is `pass`, has a `spam` verdict downgraded to `clean` (max 100 entries). Never bypasses phishing, malware, abuse, impersonation, or injection detection. |
| `spamActionLowConfidence` | string | `deliver` | Action for spam between the custom threshold and 0.5 confidence: `deliver`, `quarantine`, or `reject`. |

**Authenticated senders get a spam credit.** When a message's DMARC result is `pass` as verified by the receiving provider (the From domain is authenticated and aligned, see [How inbound mail is checked](#how-inbound-mail-is-checked)), its spam score is reduced by 0.15, or by 0.3 when it is also list mail (it carries a `List-Id` or `List-Unsubscribe` header). This is how legitimate newsletters, receipts and notifications, with their unsubscribe footers and many links, stay out of quarantine. The reduction only offsets the built-in content checks (spam phrases, bulk-mail footers, all-caps text, link count). It never offsets your `blockedKeywords`, `blockNoAuth`, spam reports or a low sender reputation, and it never affects the phishing, malware, abuse or impersonation verdicts. A comma-form "Congratulations," on its own is also no longer enough to flag a message as spam; it still counts together with any other spam signal.

```json title="Example: aggressive spam filtering"
{
  "spamThreshold": 0.3,
  "maxLinksThreshold": 3,
  "blockNoAuth": true,
  "blockedKeywords": ["crypto", "investment opportunity", "wire transfer"],
  "allowedSenders": ["trusted-partner.com", "internal@mycompany.com"],
  "spamActionLowConfidence": "quarantine"
}
```

### Rule evaluation order

1. The message is always fully classified first -- safety classification and prompt-injection detection run for every sender, allow-listed or not. **Blocked keywords** are matched case-insensitively against the combined subject + body. **Block unauthenticated** checks SPF, DKIM, and DMARC results. **Max links threshold** counts links in the body text and HTML. All signals are aggregated into a verdict and compared against the **spam threshold**.
2. **Allowed senders** and the sender whitelist are checked next, and can only ever downgrade a resulting `spam` verdict to `clean` -- never phishing, malware, abuse, impersonation, or the injection risk. This only applies when the receiving provider's DMARC result is `pass`; a sender that merely matches the allow-listed address or domain, without DMARC passing, is classified exactly as if it weren't allow-listed (DMARC pass already implies an aligned SPF-or-DKIM pass, and an `Authentication-Results` header in the message is never read as a pass, so this can't be defeated by spoofing the From header alone).
3. Low-confidence spam (between threshold and 0.5, so only when `spamThreshold` is below 0.5) uses the **low-confidence action**. Any other non-clean verdict uses its per-threat action.

## Safety flags

Messages may be flagged with safety indicators that require additional attention:

| Flag | Description |
| - | - |
| `conflicting_intents` | Multiple intent signals detected with similar scores - may need human review. |
| `low_confidence` | Classification confidence is below 0.6 - routing may be unreliable. |
| `injection_risk` | Prompt injection patterns detected in the message. |
| `adversarial_position` | Subject implies one intent but body matches a different, action-oriented intent. |
| `thread_anomaly` | Message behavior deviates from the thread's established pattern. |
| `possible_spoofed_reply` | The message looked like a reply in another thread (its `From` is that thread's contact, or its subject is `Re:` that thread's subject) but the receiving mail provider did not verify the sender, so it was given its own thread. `agentGuidance.claimedThreadId` names the thread it claimed to continue. See [Threads](https://molted.email/docs/threads.md#replies-that-only-look-like-replies). |
