EMX API reference
Base URL https://mail.emxmail.app. JSON in and out, UTF-8, times in
RFC 3339 (UTC), IDs are UUIDs. The OpenAPI 3.1 description is at
/api/openapi.json; /api lists the endpoints. A client in Rust, the
emx command line and the desktop app are open source at
github.com/Elchi-Studios/emx-client,
and anyone can build their own against the same API.
Authentication
Authorization: Bearer emx_…
| Scope | Allows |
|---|---|
mail:read |
reading mailboxes, messages, attachments, search, changes |
mail:write |
flags, moves, deletions, sending, webhooks; includes reading |
admin |
the administration of the organisation (administrators only) |
A token never opens another person's mailbox. Tokens, sessions, app
passwords and the operator's pages are managed in the web client only.
600 requests per minute per token; beyond that 429 with Retry-After.
Every answer carries X-Request-Id.
Me
GET /api/me returns the person, their accounts (own mailbox and the
shared ones they may open, each with rights), sendFrom, limits,
orgs (for people in several organisations), prefs and mailHost.
PATCH /api/me/prefs merges a JSON object into the preferences; null
removes a key.
POST /api/me/switch {"tenantId": "…"} changes the organisation of the
session (sessions only).
Mailboxes
GET /api/accounts/{account}/mailboxes
{"mailboxes": [{"id": "…", "name": "INBOX", "role": "inbox", "total": 120, "unseen": 4,
"bytes": 8123456, "modseq": 812, "uidValidity": 1266595615}]}
role is inbox, drafts, sent, archive, junk, trash,
snoozed, screener, or empty for a folder the person made.
POST /api/accounts/{account}/mailboxes {"name": "Projekte", "parentId": null}
Messages
GET /api/accounts/{account}/messages?mailbox={id}&limit=50&cursor=…
lists newest first; the answer carries cursor for the next page, empty
at the end.
{"messages": [{
"id": "…", "mailboxId": "…", "threadId": "…",
"receivedAt": "2026-09-24T07:41:12Z", "sentAt": "2026-09-24T07:41:03Z",
"subject": "Offerte Website-Relaunch",
"from": {"name": "Anna Beispiel", "address": "anna@beispiel-ag.ch"},
"to": ["samuel@elchi.dev"], "cc": [], "snippet": "Danke für die Offerte …",
"hasAttachments": true, "keywords": ["$seen"], "size": 48213, "dmarc": "pass", "modseq": 811
}], "cursor": "…"}
| Call | Returns |
|---|---|
GET …/messages/{id} |
the message with text, html (sanitised, remote images in data-src, remoteImages counts them), attachments with part paths, replyTo, messageId, inReplyTo, references |
GET …/messages/{id}/parts/{part} |
one part with its content type; ?download=1 forces a download |
GET …/messages/{id}/raw |
the message as received, message/rfc822 |
GET …/threads/{threadId} |
the conversation, oldest first |
GET …/search?q=offerte+2026 |
subject, people and text; every word must occur, prefixes match |
A message in a sealed mailbox comes back as
ciphertext (base64 age), and its parts answer 409 sealed.
Changes
GET /api/accounts/{account}/changes?since={modseq}
{"updated": [ …messages… ], "destroyed": ["…"], "modseq": 812, "hasMore": false}
Everything that changed after since. Keep the returned modseq and ask
again. 410 with code reload means the state is too old: load the
lists again.
Live updates
GET /api/accounts/{account}/events is a server-sent event stream. Each
change event carries {"modseq": N}; ask /changes?since= with the
modseq you had. A keepalive comment comes every 25 seconds; a stream ends
after an hour and is reconnected by the client.
Writing
| Call | Body |
|---|---|
POST …/messages/keywords |
{"ids": ["…"], "add": ["$seen"], "remove": ["$flagged"]} |
POST …/messages/move |
{"ids": ["…"], "to": "{mailboxId}"} |
POST …/messages/delete |
{"ids": ["…"]}, for good; the client moves to Trash first |
POST …/messages/snooze |
{"ids": ["…"], "until": "2026-09-28T07:00:00Z"}; at that time the messages return to the inbox, on top and unread |
Keywords follow JMAP: $seen, $flagged, $answered, $draft,
$forwarded, and your own without $. IMAP flags map onto the same
keywords, so a message read on the phone is read here.
The screener
With prefs.screener true, mail from outside by a sender the person
never wrote to waits in the Screener folder. Decisions are contacts:
| Call | Does |
|---|---|
GET …/contacts?state=approved|blocked |
lists them |
POST …/contacts {"address": "…", "state": "approved", "name": "…"} |
records the decision and moves that sender's waiting messages to the inbox, or to Trash when blocked |
DELETE …/contacts/{address} |
forgets it |
Sending to somebody records them as approved. Colleagues in the same organisation are never screened; blocked senders go to Trash whether the screener is on or not.
Sending
POST /api/send
{
"from": "contact@elchi.dev",
"to": "Anna Beispiel <anna@beispiel-ag.ch>, marco@example.ch",
"cc": "", "bcc": "",
"subject": "Re: Offerte", "text": "Gerne.", "html": "<p>Gerne.</p>",
"inReplyTo": "abc@beispiel-ag.ch", "references": ["abc@beispiel-ag.ch"],
"attachments": [{"filename": "Offerte.pdf", "contentType": "application/pdf", "data": "<base64>"}]
}
from must be an address in sendFrom. The message is signed, filed in
Sent, delivered locally or queued, the same path a mail app takes. Send
Idempotency-Key to make a retry safe: the same key from the same
person within a day returns the first answer with
Idempotent-Replayed: true.
Answers: 200 {"sent": true, "recipients": 2}, 403 not_permitted,
400 unknown_recipient, 413 too_large, 429 daily_limit or
429 monthly_limit.
Webhooks
See Webhooks. GET /api/me/webhooks,
POST /api/accounts/{account}/webhooks, DELETE /api/webhooks/{id},
POST /api/webhooks/{id}/test, POST /api/webhooks/{id}/enable,
GET /api/webhooks/{id}/deliveries.
Administration (scope admin)
{tenant} is mine; operators may name a tenant ID.
| Method and path | Does |
|---|---|
GET /api/admin/{tenant} |
plan, usage, domains, billing |
GET /api/admin/{tenant}/people |
people, shared mailboxes and groups |
POST /api/admin/{tenant}/people |
add a person; answers with an invitation link |
POST /api/admin/{tenant}/people/{id}/role |
{"role": "admin"} |
POST /api/admin/{tenant}/people/{id}/suspend, restore |
sign-in, sending and receiving off or on |
POST /api/admin/{tenant}/people/{id}/invite |
a new invitation link |
POST /api/admin/{tenant}/people/{id}/addresses |
{"address": "…", "primary": false} |
DELETE /api/admin/{tenant}/addresses/{address} |
remove an alias |
POST /api/admin/{tenant}/mailboxes |
{"kind": "shared", "name": "Kontakt", "address": "contact@…"} |
GET, PUT, DELETE /api/admin/{tenant}/mailboxes/{id}/members[/{member}] |
members and rights (read, write, delete, send_as, send_on_behalf, manage) |
GET, PUT /api/admin/{tenant}/rules, DELETE …/rules/{id} |
membership rules by role |
GET, POST /api/admin/{tenant}/domains |
domains; a new one is unverified |
POST /api/admin/{tenant}/domains/{id}/verify |
check the _emx-challenge TXT record |
GET /api/admin/{tenant}/domains/{id}/dns?dmarc=none |
every record the domain needs, each checked in DNS |
POST /api/admin/{tenant}/domains/{id}/activate |
accept the domain as set up |
POST /api/admin/{tenant}/domains/{id}/outbound |
{"outbound": "resend", "resendKey": "re_…"} or emx |
POST /api/admin/{tenant}/domains/{id}/keys/rotate, keys/activate |
DKIM keys |
PUT, DELETE /api/admin/{tenant}/logo |
the organisation's logo in the client (PNG, JPEG, SVG or WebP, 256 KB) |
GET /api/admin/{tenant}/audit?before=… |
the audit trail, 100 at a time |
POST /api/admin/{tenant}/billing/checkout, portal |
Stripe pages |
Sealed mailboxes
Where the organisation allows it (by default only an organisation of one
person does; its owners can change that), a person can seal their
mailbox: from then on every message, its subject, sender and recipients
included, is stored as age ciphertext to the person's key, which the
server keeps only wrapped to their recovery code and to a passkey or a
passphrase. Neither the organisation nor Elchi can open it. Mail that was
in the mailbox before it was sealed stays as it was. Our servers
see a message in clear only while it is being received or sent, and mail
to and from other servers travels as ordinary mail. For the API this
means:
- messages come back as
ciphertext, which holds the subject and the people as well; what stays readable is the time a message arrived, its size, its mailbox and its keywords, andsnippetis empty; - parts, search on the server and IMAP are off;
message.receivedwebhooks leave out the subject and the addresses;- the mailbox sends no automatic replies and forwards nothing, and of its rules only those on the size of a message apply.
If the passkey, the passphrase and the recovery code are all lost, the sealed mail is lost with them: nobody can recover it. The switch is in the settings, behind a page that lists these consequences.
Versioning
The API is unversioned while EMX is with its first customers. Fields are
added, never renamed or removed, without notice. A breaking change gets
/api/v2 and a year of both.