<!-- https://docs.elchi.dev/emx-api | EMX | Elchi Docs -->

# 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](https://github.com/Elchi-Studios/emx-client),
and anyone can build their own against the same API.

## Authentication

```http
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`

```json
{"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.

```json
{"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](#sealed-mailboxes) mailbox comes back as
`ciphertext` (base64 age), and its parts answer `409 sealed`.

### Changes

`GET /api/accounts/{account}/changes?since={modseq}`

```json
{"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`

```json
{
  "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](/emx-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.
