MOLTED EMAIL

Inbound Email

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). 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 and routing action. Mail with a quarantine or reject verdict is held for a person (see 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).

Platform labels

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

platformLabelMeaningWhat Molted does
opt_outThe sender asked to stop receiving mail.Suppresses the sender automatically when it is confident. When it isn't (routingAction: "require_approval", see 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_replyAn 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_noticeA bounce or delivery status notification.Same as auto_reply.
nullNone 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: 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.

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)
  • the classify endpoints 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.

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.

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, 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).
  • 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. GET /v1/inbound carries the same contentTrust: "untrusted" marker on every row (that route has no injectionRisk field). A high injectionRisk (see Safety classification below) means be extra careful.

A message quarantined for high injection risk (quarantineHighInjection, see 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 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.

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), 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
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). There is no CLI command or MCP tool for this list: agents read mail through threads (molted threads, MCP read_inbox / get_thread).

Response

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 result). platform_label is the message's platform label (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 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 above).

Read the original message

GET /v1/agent/messages/:id/raw
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, 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. 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.

CLI
molted record-inbound --from alice@example.com --to support@yourdomain.com \
  --subject "Re: Invoice" --body "Where is my invoice?"
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?"
  }'
FieldDescription
fromEmail, toEmailRequired. The sender, and the address the message was sent to.
subject, bodyText, bodyHtmlThe content. Longer values are cut at 1 KB for the subject, 100 KB for the text and 500 KB for the HTML.
inReplyTo, referencesHeaderThe threading headers. A message that replies to one of your sends is linked to it and to its thread.
providerMessageIdYour own id for the message. It is stored on the message; it is not used to skip duplicates.
mailboxIdThe 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.
threadIdPut the message in this existing thread.
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), then gets its platform label, 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), 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).

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
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: 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: 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).

CLI
molted classify --subject "Re: Invoice" --body "Where is my invoice?" --from alice@example.com
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 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

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": []
}
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 }
  ]
}
FieldDescription
platformLabelopt_out or null here (see Platform labels).
autoReplyKindAlways unknown here: this endpoint takes no headers.
safetyVerdictSafety classification result (see Safety classification).
safetyActionAction the safety system recommends: deliver, quarantine, or reject.
routeThe 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.
signalsThe safety/injection detail behind safetyVerdict: matched safety signal categories, and the injection risk level/score/matched patterns.
flagsArray of safety flags that may require attention (see Safety flags).
suggestedActionKept for backward compatibility; always matches route.actionType.
intentDeprecated: read platformLabel. For clean mail only unsubscribe or unclassified (see masked).
confidenceDeprecated with intent: its score, 0 to 1.
maskedTrue when intent/confidence were replaced with unclassified @ 0.5, which production does for every clean email that isn't an opt-out.
allScores, runnerUpIntent, runnerUpConfidenceDeprecated 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:

CLI
molted classify batch --emails '[
  {"subject": "Re: Order", "body": "When does it ship?", "from": "alice@example.com"},
  {"subject": "Thanks!", "body": "Got it, looks great."}
]'
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:

ActionDescription
notify_ownerNotify the contact's assigned owner.
require_approvalHold the message until manually approved.
auto_archiveArchive the message automatically (e.g., out-of-office replies).
trigger_unsubscribeAdd the contact to the suppression list and acknowledge -- legally required for opt-out requests under CAN-SPAM, GDPR, CASL. Never auto-archive opt-outs.
escalateReserved: part of the type, but the classifier does not produce it today.
spamMail 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), 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 labelRouteSLA
opt_outtrigger_unsubscribe0
auto_reply, delivery_noticeauto_archive0
nonenotify_owner60 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: 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) 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 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:

VerdictDescription
cleanNo threats detected.
spamUnsolicited bulk email.
phishingCredential harvesting attempt.
malwareDangerous content detected.
abuseThreats or harassment.
impersonationSpoofing 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 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); 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) -- 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.

ActionAPICLIMCP
List a queueGET /v1/agent/override/queues/:queuemolted overrides queue spam-
Queue countsGET /v1/agent/override/queues/countsmolted overrides counts-
Mark not spamPOST /v1/agent/override/:threadId/not-spammolted overrides not-spam <threadId> [--reason]mark_not_spam
Delete spamPOST /v1/agent/override/:threadId/delete-spammolted overrides delete-spam <threadId>delete_spam
Report spamPOST /v1/agent/override/:threadId/report-spammolted overrides report-spam <threadId>report_spam
Feedback statsGET /v1/agent/override/spam-feedback/statsmolted overrides spam-statsspam_feedback_stats
Sender reputationGET /v1/agent/override/sender-reputation?domain=molted overrides sender-reputation --domain <d>sender_reputation
List the allow-listGET /v1/agent/override/allowlistmolted safety allowlist listlist_sender_allowlist
Remove an allow-list entryDELETE /v1/agent/override/allowlist/:idmolted 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 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
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 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
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 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
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
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 below. An entry here skips the spam-verdict-to-clean downgrade check (see 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 above) or a prior outbound send to that address (source: "outbound_send", always the exact address). Creating or syncing a contact no longer adds one (#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
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 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 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
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; there is no MCP tool for them.

Get safety settings

CLI
molted safety get
curl
curl "https://mailbox.molted.email/v1/agent/config/safety-settings" \
  -H "Authorization: Bearer YOUR_API_KEY"
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

CLI
molted safety update --settings '{
  "spamThreshold": 0.3,
  "blockNoAuth": true,
  "malwareAction": "reject"
}'
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.

Safety automation flags

FieldTypeDefaultDescription
quarantineHighInjectionbooleantrueQuarantine messages whose injection risk is high: thread reads show a placeholder instead of the body. Lower risk levels only add the injection_risk flag.
holdCriticalAnomaliesbooleantrueHold 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.
blockCanaryViolationsbooleantrueBlock a send or reply on a thread when its content contains that thread's canary token (see Canary tokens).

Per-threat actions

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

FieldTypeDefaultDescription
spamActionstringquarantineAction for messages classified as spam.
phishingActionstringquarantineAction for phishing attempts.
malwareActionstringrejectAction for dangerous content.
abuseActionstringquarantineAction for threats or harassment.
impersonationActionstringquarantineAction for spoofing or BEC attempts.

Spam filter rules

FieldTypeDefaultDescription
spamThresholdnumber0.5Spam verdict threshold (0.1-1.0). Lower means more aggressive filtering.
maxLinksThresholdinteger5Max links before triggering the excessive_links signal (1-100).
blockNoAuthbooleanfalseWhen 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.
blockedKeywordsstring[][]Custom keyword blocklist (max 100 entries). Each match adds +0.4 to the spam score.
allowedSendersstring[][]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.
spamActionLowConfidencestringdeliverAction 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), 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.

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:

FlagDescription
conflicting_intentsMultiple intent signals detected with similar scores - may need human review.
low_confidenceClassification confidence is below 0.6 - routing may be unreliable.
injection_riskPrompt injection patterns detected in the message.
adversarial_positionSubject implies one intent but body matches a different, action-oriented intent.
thread_anomalyMessage behavior deviates from the thread's established pattern.
possible_spoofed_replyThe 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.