HTTP API
Everything is JSON except raw source and attachment downloads. Messages are addressed by IMAP UID, never by sequence number. A UID is unique within one folder, so routes that take a UID also take ?folder=, which defaults to the mailbox’s imap.default_folder (INBOX unless configured).
{n} below is a mailbox name, or its IMAP or SMTP username when that is an address.
Authentication
Send Authorization: Bearer <token> on every request except GET /health. A missing or wrong token gets:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
{"detail":"missing or invalid Bearer token"}
The token’s scopes and mailbox list decide what it can do; see Tokens and scopes. FastAPI’s generated schema is served at /openapi.json, with Swagger UI at /docs and ReDoc at /redoc. They need the bearer token like every other route, so fetch the schema with curl -H "Authorization: Bearer $TOKEN".
Errors
Errors carry an error object with code, message and optional details, plus a detail field for older clients:
{
"error": {
"code": "permission_denied",
"message": "token 'drafter' lacks the 'send' scope",
"details": {
"required_scope": "send"
}
},
"detail": "token 'drafter' lacks the 'send' scope"
}
| Status | code | When |
|---|---|---|
401 | none | Missing or invalid bearer token, or no tokens configured |
403 | permission_denied | The token lacks the scope. details.required_scope names it |
404 | not_found | Unknown mailbox, message, part, folder or delivery, or a mailbox outside the token’s list |
409 | conflict | The mailbox lacks IMAP or SMTP, or the action does not apply here (see Conflicts) |
413 | too_large | Request body, attachment or message over its configured limit |
422 | validation_error | Bad body or parameters. FastAPI validation errors list each problem in details.errors |
422 | invalid_identity | Sender address not covered by the configured identities. details.allowed lists them |
429 | rate_limited | Hourly send limit reached. Retry-After header and details.retry_after, in seconds |
502 | imap_error / smtp_error | The mail server refused a command or the login, or could not be reached (connection refused, DNS failure, timeout). The message is always mail server operation failed |
/inbox reports a failing account in errors instead and still answers for the others.
Conflicts
409 conflict is returned when:
- the mailbox has no
imap(reading, organizing, drafts) or nosmtp(sending, identities); - a normal delete targets messages already in Trash, or a permanent delete targets any other folder;
- permanent deletion is requested on a server without
UIDPLUS; POST /mailboxes/{n}/foldersnames a folder that exists;- a draft was already sent once, or an earlier send attempt for it had an unknown outcome;
- a webhook delivery is being replayed while it is in flight.
Routes
| Method and path | Scope | What it does |
|---|---|---|
GET /health | none | {"ok": true, "version": "…"} |
GET /health/deep | admin | Connects and logs in to each visible mailbox’s IMAP and SMTP servers |
GET /mailboxes | any token | Visible mailboxes with address, protocols and identities |
GET /inbox | read | Unified newest-first feed across mailboxes |
GET /mailboxes/{n}/folders | read | Folders with delimiter, flags and detected special use |
POST /mailboxes/{n}/folders | organize | {"name": "…"} creates a folder and subscribes to it |
GET /mailboxes/{n}/messages | read | One folder, newest first, with a raw IMAP search string |
GET /mailboxes/{n}/search | read | Structured search in one folder |
GET /mailboxes/{n}/messages/{uid} | read | The decoded message with security analysis |
GET /mailboxes/{n}/messages/{uid}/raw | read | The RFC 822 source, as a download |
GET /mailboxes/{n}/messages/{uid}/attachments/{part_id} | read | One attachment, as a download |
GET /mailboxes/{n}/messages/{uid}/thread | read | The conversation containing the message |
GET /mailboxes/{n}/threads | read | Recent conversations |
GET /mailboxes/{n}/identities | read | Sender identities |
GET /mailboxes/{n}/stats | read | Per-folder counts and quota |
DELETE /mailboxes/{n}/messages/{uid} | organize, or delete with permanent=true | Move to Trash, or erase from Trash |
POST /mailboxes/{n}/messages/delete | organize, or delete with permanent | Batch: {"uids": […], "permanent": false} |
POST /mailboxes/{n}/messages/{uid}/seen | organize | {"seen": true} (the default) or false |
POST /mailboxes/{n}/messages/seen | organize | Batch: {"uids": […], "seen": true} |
POST /mailboxes/{n}/messages/labels | organize | {"uids": […], "add": […], "remove": […]} |
POST /mailboxes/{n}/messages/move | organize | {"uids": […], "to_folder": "…"}. The folder must exist |
POST /mailboxes/{n}/send | send | Send a new message |
POST /mailboxes/{n}/messages/{uid}/reply | send, or draft with "draft": true | Reply in the thread |
GET /mailboxes/{n}/drafts | read | List drafts, ?limit= (1 to 500, default 50) |
POST /mailboxes/{n}/drafts | draft | Save a draft |
PUT /mailboxes/{n}/drafts/{uid} | draft | Replace a draft. The UID changes |
DELETE /mailboxes/{n}/drafts/{uid} | draft | Delete a draft |
POST /mailboxes/{n}/drafts/{uid}/send | send | {"dry_run": false}. Send a draft |
GET /audit | admin | Audit log, see below |
GET /mailboxes/{n}/webhook-deliveries | admin | Webhook delivery records, see Webhooks |
POST /mailboxes/{n}/webhook-deliveries/{id}/replay | admin | Queue a delivery again |
HTTP batch bodies have no UID count limit beyond limits.request_max_bytes; MCP caps them at 500. Every UID must be numeric.
Listing and searching
Search filters
GET /inbox and GET /mailboxes/{n}/search take the same filters. All are optional and combine with AND.
| Parameter | What it matches |
|---|---|
from, to, subject | That header contains the text |
body | The body contains the text |
text | Headers or body contain the text |
recipient | To, Cc, Delivered-To or X-Original-To contains the text, so mail for contact@ and support@ aliases, including blind copies, can be told apart |
message_id | The Message-ID header |
since, before | Date on or after / before. ISO (2026-01-31) or IMAP (31-Jan-2026) |
unseen, seen, flagged, answered | true to require the flag. false is the same as leaving it out |
label | A friendly label name from the config, or flagged |
keyword, unkeyword | Has / lacks a raw IMAP keyword |
larger_than, smaller_than | Size in bytes |
folder | Folder to search. Default: the mailbox’s default_folder |
limit | 1 to 500, default 50. On /inbox the limit applies per mailbox, so the merged list can be longer |
Matching is done by the IMAP server, so substring and case rules are the server’s. Non-ASCII terms are sent with CHARSET UTF-8.
/search also takes has_attachments=true|false. With it, total is -1 because counting would mean scanning every message.
Unified inbox
GET /inbox searches every visible IMAP mailbox in parallel, merges the results newest first and tags each message with mailbox, mailbox_address (the IMAP username) and folder. ?mailbox= takes a comma-separated list of names or addresses; one that does not match returns 404. A failing account goes into errors and the others still answer.
{
"messages": [
{
"uid": "1234",
"mailbox": "contact",
"mailbox_address": "contact@cgs.example",
"folder": "INBOX",
"from": "boss@corp.com",
"to": "contact@cgs.example",
"cc": "",
"subject": "weekly sync",
"date": "…",
"message_id": "<…>",
"in_reply_to": "",
"references": [],
"size": 4821,
"flags": [
"\\Seen"
],
"labels": []
}
],
"errors": [
{
"mailbox": "support",
"error": "login failed: …"
}
],
"next_since_cursor": "eyJjb250YWN0IjpbIjEiLDEyMzRdfQ",
"positions": {
"contact": {
"uidvalidity": "1",
"max_uid": 1234,
"next_after_uid": 1234,
"more_new": false,
"folder": "INBOX"
}
}
}
Single-folder listings
GET /mailboxes/{n}/messages?folder&limit&search&cursor lists one folder newest first. search is a raw IMAP SEARCH string such as UNSEEN or FROM "boss", default ALL. Prefer /search unless you need a criterion it lacks.
Both /messages and /search return messages, total (the number of matches) and next_cursor. /search also returns next_since_cursor, and more_new when following a cursor.
Paging and “new mail only”
- Older pages. Pass
next_cursorback ascursorfor the next, older page. It isnullon the last page. - New since last time. Pass a previous
next_since_cursorassince_cursor, or give a name ascheckpoint=and the server stores the position for you, per token, checkpoint name, mailbox and folder. Only mail newer than the position comes back, oldest first in the scan so nothing is skipped;more_new: truemeans call again. The first call with a new checkpoint name returns the newestlimitmessages and stores the position after them. - UIDVALIDITY. If the server renumbered the folder since the cursor was issued, the response has
"reset": true(and on/inbox,reset_mailboxes). Asince_cursoror checkpoint then starts over from the oldest message; a pagingcursorstarts over from the newest.
Cursors are opaque base64 strings. Do not build them yourself.
Reading a message
GET /mailboxes/{n}/messages/{uid}?folder&reader&headers&safe_html returns:
| Field | Content |
|---|---|
uid, from, to, cc, reply_to, subject, date, message_id, in_reply_to, references | Decoded headers |
body_text, body_html | The first text/plain and text/html parts, or null |
attachments | part_id, filename, content_type, size, sha256, inline for each attachment |
authentication | trusted, authserv_id, spf, dkim (a list), dmarc, arc, raw (every Authentication-Results header) and signals |
html_analysis | Present when there is an HTML body: hidden_text, suspicious_links, link_count |
body_reader | With reader=true: the HTML as readable markdown via html2text, else the plain text |
headers | With headers=true: every header in wire order, as [name, value] pairs |
body_html_safe | With safe_html=true: the HTML through an nh3 allow-list. Scripts, event handlers, comments and every <img> are removed; links keep only http, https and mailto and get rel="noopener noreferrer nofollow" |
Reading never marks a message as read; the server fetches with BODY.PEEK.
signals can contain:
| Signal | Meaning |
|---|---|
reply_to_domain_mismatch | Reply-To is on a different organizational domain than From |
return_path_domain_mismatch | Return-Path is on a different organizational domain than From |
display_name_spoof | The From display name contains an address other than the real one |
no_authentication_results | No Authentication-Results header at all |
authentication_untrusted | Headers exist, but none from your trusted_authserv_id (or it is unset) |
spf_fail, spf_softfail, spf_permerror | SPF result |
dmarc_fail | DMARC fail or permerror |
dkim_fail | No DKIM signature passed |
dkim_not_aligned | No passing DKIM signature on the From domain |
How much to trust these depends on Authentication-Results trust.
hidden_text collects text hidden with display:none, visibility:hidden, zero font size, zero opacity, zero max-height, zero height with overflow hidden, the hidden attribute, or an element whose text color equals its background color. suspicious_links lists links with one or more reasons: script_or_data_url, ip_literal, punycode, url_shortener, userinfo_in_url, text_domain_mismatch (the link text shows another domain).
Downloads
/rawreturnsmessage/rfc822withContent-Disposition: attachment; filename="message-<uid>.eml"./attachments/{part_id}returnsapplication/octet-streamwithContent-Disposition: attachment, a sanitized file name andX-Content-Type-Options: nosniff. Attachments overlimits.attachment_max_bytesget413.
part_id comes from the message’s attachments list.
Threads and stats
/messages/{uid}/threadsearches the message’s folder and the Sent folder for messages linked byMessage-ID,In-Reply-ToandReferences, and returnsroot,countandmessagesoldest first./threads?folder&limit(1 to 200, default 20) groups the newest headers of the folder and Sent into conversations:thread_id,subject,count,unread,participants,last_dateandmessages./statsreturnsfolders(each withname,special,messages,unseenandrecent, orerrorwhen the server refused STATUS for it) andquotawhen the server supports the QUOTA extension./identitiesreturnsaddress,name,defaultandhas_signaturefor each identity, including*@domainpatterns.
Sending, replying and drafts
POST /mailboxes/{n}/send and POST /mailboxes/{n}/drafts take the same body:
{
"to": [
"dest@example.com"
],
"cc": [],
"bcc": [
"hidden@example.com"
],
"subject": "hi",
"body_text": "plain text body",
"body_html": "<p>optional html body</p>",
"identity": "support@cgs.example",
"reply_to": "noreply@example.com",
"attachments": [
{
"filename": "a.pdf",
"content_type": "application/pdf",
"data_base64": "…"
},
{
"from_message": {
"uid": "1234",
"part_id": "3",
"folder": "INBOX"
}
}
],
"signature": true,
"dry_run": false
}
to(at least one) andsubjectare required, plus at least one ofbody_textandbody_html. Both make amultipart/alternativemessage.identitydefaults to the mailbox’s default identity. See Identities and signatures.- Each attachment has either
data_base64orfrom_message, which copies a part of a message in the same mailbox and also needs thereadscope. A missingcontent_typeis guessed from the file name. - Bcc recipients get the message but never appear in the headers that go on the wire. The Sent copy and drafts keep the Bcc header.
- Addresses and the subject cannot contain line breaks.
dry_runis ignored by/drafts.
POST /mailboxes/{n}/messages/{uid}/reply takes this body. At least one of body_text and body_html is required; every other value shown is the default:
{
"body_text": "thanks, see you thursday",
"body_html": null,
"reply_all": false,
"identity": null,
"attachments": [],
"quote": true,
"draft": false,
"signature": true,
"dry_run": false
}
The server fills in the rest from the original message:
- To: Reply-To, else From. Replying to a message you sent goes to its original recipients.
- Cc with
reply_all: the original To and Cc, minus your own addresses (the IMAP username, every identity, and addresses covered by*@domainpatterns). - Subject:
Re:added unless the subject already starts with a reply or forward prefix. - Threading:
In-Reply-Toand up to 20References. - Quote: with
quote: true, the original text (up to 50,000 characters) is quoted below the reply.
After a real send the original gets \Answered. With "draft": true the reply is saved to Drafts and needs only the draft scope.
A send returns:
{
"dry_run": false,
"from": "…",
"to": [
"…"
],
"cc": [],
"subject": "…",
"message_id": "<…@cgs.example>",
"sent_copy": {
"folder": "Sent",
"uid": "42"
},
"warnings": []
}
With a dry run, nothing is delivered and preview holds the headers and text body that would have been sent. warnings can hold sent_copy_failed, answered_flag_failed or draft_not_removed: the mail went out, but filing it afterwards failed. Fix those by hand; do not send again. sent_copy is null when save_sent is off.
Drafts
- Creating a draft appends it to the Drafts folder with
\Draft \Seenand anX-Mailboxd-Draft-By: <token name>header, and returnsuid,folderandmessage_id. PUT /drafts/{uid}takes any subset of the compose fields; the rest is kept from the old draft. It saves a new draft, removes the old one, and returns the newuidwithreplaced_uid. A signature added earlier is replaced, not doubled.POST /drafts/{uid}/sendstripsX-Mailboxd-*headers, refreshesDate, checks the sender against the identities again, delivers, files the Sent copy, removes the draft, and flags the message it replies to (looked up byIn-Reply-Toin the default folder) as\Answered.
Organizing
-
DELETE /messages/{uid}andPOST /messages/deletemove messages to Trash and return:{ "ok": true, "action": "trashed", "method": "move", "trash_folder": "Trash" }methodismove,copy+uid-expungeorcopy+flagged-deleted; the last one means the server lacksUIDPLUSand the original was left flagged\Deletedrather than expunging the whole folder. - With
permanent=true,foldermust be the Trash folder, the token needsdelete, and the server needsUIDPLUS. The result has"action": "deleted". POST /messages/labelsneeds at least one ofaddandremove.POST /foldersrejects empty names, names over 200 characters and names with line breaks or NUL.
Audit log
GET /audit?since&action&token&mailbox&limit returns {"entries": […]}, newest first. since is an ISO date or timestamp, or Unix seconds; limit is 1 to 1000, default 100. Each entry has id, ts (Unix seconds), token, action, mailbox, folder, uids, outcome (ok, denied, error or dry_run) and details. Only entries for the token’s own mailboxes are returned.
Actions are send, reply, send_draft, create_draft, update_draft, delete_draft, delete, delete_permanent, mark_seen, set_labels, move, create_folder and replay_webhook. A refused MCP tool call is logged as mcp.<tool name>.
Deep health
GET /health/deep checks only the mailboxes the token can see. imap or smtp is null when the mailbox does not configure it, and ok is false when any check failed:
{
"ok": true,
"mailboxes": [
{
"name": "contact",
"imap": {
"ok": true,
"latency_ms": 212,
"error": null
},
"smtp": {
"ok": true,
"latency_ms": 341,
"error": null
}
}
]
}