REST API guide pro
The shape of the API, the conventions it keeps, and the handful of flows everything else is built out of. For the field-by-field detail of every endpoint, read the API reference.
This page assumes you have a token. If you do not, start at The automation layer.
Base URL
mimux is self-hosted, so there is no shared API host. The API is always mounted
under /api on the same origin as the web client:
https://mail.example.com/api/v1/...
The binary serves its own spec at /api/v1/openapi.json —
unauthenticated, because a client that cannot yet authenticate is exactly the
one that needs to read it. That file is the same one this site renders, so the
documentation can never describe a different API than the one you are running.
Authentication
Authorization: Bearer mimux_pat_...
Every request. There is no session, no cookie, no login call and no refresh
token. A missing or malformed header is 401
(unauthorized); a valid token without the required scope is
403 (insufficient_scope), and the message names
the scope you are missing so you know which box to tick in Settings.
Verify a token at any time with GET /api/v1/tokens/self, which
requires no scope and returns the token's label and scope list.
Envelopes
Errors
Every non-2xx response has the same shape, so failures parse without special-casing per endpoint.
{
"error": {
"code": "insufficient_scope",
"message": "This token is missing the mail:send scope."
}
}
Branch on code, never on message — the wording may
change between versions. The codes are
invalid_request, unauthorized,
licence_required, licence_version,
insufficient_scope, not_found,
rate_limited, upstream, send_failed and
internal. The licence codes carry a details.url
pointing at where to fix it.
| Status | Means |
|---|---|
400 | Bad request. Unknown JSON fields are rejected too — a typo'd field name fails loudly instead of being silently ignored. |
401 | Missing, malformed, unknown, revoked or expired token. |
402 | The pro licence does not currently allow the API. Mail is unaffected. See the licence rules. |
403 | Token authenticated, but lacks the scope. |
429 | Over the per-token rate limit. Honour Retry-After. |
502 | The upstream mail server refused — e.g. SMTP rejected a send. |
Collections
{ "data": [ ... ], "next_cursor": "" }
next_cursor is an opaque string, or "" when the
collection is exhausted. Endpoints that are not paginated always return
"" — the field is present regardless, so a client never has to
ask whether this particular collection pages.
Pagination
GET /v1/messages pages with limit (1–500, default
100) and cursor. Feed the previous response's
next_cursor back in; stop when it comes back empty. A
limit outside the range is a 400 — it is not
silently clamped.
curl -s -H "Authorization: Bearer $MIMUX_TOKEN" \
"$BASE/api/v1/messages?account=work&unread=true&limit=50"
Filters on that endpoint: folder (a folder id, which takes
precedence over account), account (that account's
inbox), unread, starred, since (RFC
3339), and substring matches on from, to and
subject. The reference documents the lenient parsing of each
honestly, including the cases where an unparseable value is ignored rather
than rejected.
Idempotency
The three mutating POSTs that create something —
/v1/messages/send, /v1/drafts and
/v1/filters — accept an Idempotency-Key header. A
repeat of the same key on the same route with the same token replays the
stored response instead of executing again, and the replay is marked with
Idempotency-Replayed: true.
curl -X POST "$BASE/api/v1/messages/send" \
-H "Authorization: Bearer $MIMUX_TOKEN" \
-H "Idempotency-Key: deploy-2026-08-18-green" \
-H "Content-Type: application/json" \
-d '{"to":["bob@example.com"],"subject":"Deploy is green","body":"All checks passed."}'
Only 2xx responses are stored — a failed call is safe to re-execute — and entries live for 24 hours. The cache is in memory, so keys do not survive a restart: at-most-once per process, not per install.
Common flows
Read the inbox
# unread across one account's inbox
GET /v1/messages?account=work&unread=true # mail:read
# one message, with body and attachment list
GET /v1/messages/{id}?body=both # mail:read
# the message's own headers: raw, parsed, or both
GET /v1/messages/{id}?headers=both # mail:read
# dedicated source endpoints
GET /v1/messages/{id}/headers # mail:read
GET /v1/messages/{id}/raw # mail:read, message/rfc822
# create a reviewable draft with the original attached; never sends
POST /v1/messages/{id}/forward-eml # mail:send
# an attachment by index
GET /v1/messages/{id}/attachments/{n} # mail:read
Folder ids come from GET /v1/folders, which returns each
account's tree with special_use marking
inbox/sent/drafts/archive/spam/trash. Account names come from
GET /v1/accounts, which also reports live sync state and unread
counts — worth checking before you trust freshness.
headers is opt-in and separate from body:
raw is the header block exactly as it arrived — folding, field
order and every repeated Received: line intact — and
parsed is the same block as field name → list of values, so a
chain stays a chain. Ask for it when the routing is the question
(deliverability, SPF/DKIM results, where a message actually came from); it is
bulk otherwise. Like the body, it degrades rather than fails:
headers_error instead of headers.
Triage
PATCH /v1/messages/{id} {"read":true} # mail:modify
PATCH /v1/messages/{id} {"starred":true}
PATCH /v1/messages/{id} {"folder_id":42} # same account only
POST /v1/messages/{id}/archive
POST /v1/messages/{id}/spam
DELETE /v1/messages/{id} # move to Trash
PATCH applies only the fields present;
labels_add is applied before labels_remove. The
three move routes answer {"status":"ok","folder_id":…} with the
folder the message now lives in. These are optimistic: mimux updates its own
store and returns, then applies the change on the IMAP server behind the
response.
Search
Local search answers immediately:
curl -X POST "$BASE/api/v1/messages/search" \
-H "Authorization: Bearer $MIMUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"from:alice is:unread after:2026-01-01","limit":50}'
Deep search asks the IMAP servers themselves. It answers 202 with a job, which you then poll:
POST /v1/messages/search {"query":"invoice","mode":"deep"}
-> 202 {"id":"9f2c...","status":"running","query":"invoice","started_at":"..."}
GET /v1/search/jobs/9f2c...
-> 200 {"id":"9f2c...","status":"done","results":[ ... ]}
A job whose IMAP searches all errored still ends done, with empty
results — there is no failure status. Jobs live in a small in-memory ring, so
poll promptly and do not expect one to still be there tomorrow.
The query language is the same one the web UI's search box takes; it is described on the Architecture page.
Send
POST /v1/messages/send # mail:send
{
"to": ["alice@example.com"],
"subject": "Re: quarterly report",
"body": "**Numbers attached.**",
"mode": "markdown",
"in_reply_to": 41207,
"attachments": [
{"filename":"q3.csv","content_type":"text/csv","data":"YSxiLGMK"}
]
}
in_reply_to is a mimux message id, not a
Message-ID header — mimux reads the original's headers and builds
the threading itself, and can resolve the sending account from it. Attachment
data is base64; the combined decoded size is capped at 25 MiB.
mode is plain (default), html or
markdown; markdown is rendered to HTML with a plain-text
alternative, exactly as the compose form does it.
Sending now answers 200
{"status":"sent","message_id":"<...>"} — that
message_id is the RFC 5322 header value, not a mimux id. Adding
schedule_at puts it in the outbox instead and answers
202 {"status":"scheduled","outbox_id":…,"send_at":…}.
There is no API to read, edit or cancel the outbox yet.
Drafts
POST /v1/drafts # mail:send — creates the same row the compose form uses
PATCH /v1/drafts/{id}
Note the asymmetry with send: draft recipients are comma-separated
strings, where SendRequest takes arrays. A draft created
through the API is a draft like any other: written to SQLite and published to
the account's IMAP Drafts folder in the background, attachments and all.
A draft is also how an agent proposes mail without being able to send it; see MCP server.
Filters
GET /v1/filters # mail:read
POST /v1/filters # mail:modify
A rule is at least one condition (all ANDed) and at least one action.
Conditions are field ∈
{from, to, subject, body} ×
op ∈ {contains, regex} ×
value. Actions are move, label,
forward, mark_read, star,
delete, notify. position sets
evaluation order; an empty account means every account.
Webhooks
Managed under /v1/webhooks with the
webhooks:manage scope, and documented on their own page:
Webhooks.
Health
curl -s "$BASE/api/health"
{"ok":true,"accounts":2}
Unauthenticated and outside the licence gate, on purpose: a health probe that needs a credential is a health probe nobody wires up. It exposes a count, not any mail.
The full reference
The API reference is generated from the spec the binary itself serves, with request samples in curl, Python, JavaScript, Go, Ruby and PHP for every operation.