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.

StatusMeans
400Bad request. Unknown JSON fields are rejected too — a typo'd field name fails loudly instead of being silently ignored.
401Missing, malformed, unknown, revoked or expired token.
402The pro licence does not currently allow the API. Mail is unaffected. See the licence rules.
403Token authenticated, but lacks the scope.
429Over the per-token rate limit. Honour Retry-After.
502The 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.