Elchi Docs

↑ ↓ to move · Enter to open · Esc to close

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, and snippet is empty;
  • parts, search on the server and IMAP are off;
  • message.received webhooks 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.

7 min read