Every edge in this repo (MX, submission, IMAP) is a stateless protocol head. All state and
policy live behind the HTTP endpoints below. Implement them over any store and every edge
works unchanged — api-local/ (SQLite) and MailKite Cloud are
two implementations of the same contract.
Versioning: this document is the contract. Backwards-incompatible changes bump the repo's major version; additive fields are allowed at any time (clients must ignore unknown fields).
Two schemes, mirroring the two trust models:
- Edge trust (HMAC secret): the shared secret (
MAILKITE_HMAC_SECRETon the edges) proves the caller is a trusted edge. Sent asAuthorization: Bearer <secret>— except ingest, which uses a signature over the body (see below). Edge-trusted endpoints accept identity fields (e.g.userId) at face value, because the edge already authenticated the end user. - User trust (API key / app-password):
/api/relayauthenticates the user with their API key as Bearer;/api/imap/authverifies a scoped app-password.
All request/response bodies are JSON unless noted. Non-2xx errors should carry
{"error": "<human message>", "code": "<machine_code>"} when a body is present.
Called by mta/plugins/mailkite_ingest.js on Haraka's queue hook.
- Body: raw RFC822 bytes (
Content-Type: message/rfc822). - Headers:
x-mailkite-signature: t=<unix-seconds>,v1=<hex>where<hex> = HMAC-SHA256(secret, "<t>." ‖ raw-body). Reject if the signature fails ortis outside a small clock-drift window (the reference backend uses ±5 minutes).x-mailkite-rcpt: comma-separated envelope RCPT TO addresses.x-mailkite-mailfrom: envelope MAIL FROM.- Optional edge verdicts:
x-mailkite-spf,x-mailkite-dkim,x-mailkite-dmarc(pass/fail/none…),x-mailkite-spam(numeric score),x-mailkite-spam-verdict.
- Post-acceptance handling is the backend's business. What a backend does with an
accepted message — store it, POST it to a webhook, evaluate per-address routes
(
routes.md) — is deliberately outside this contract, and must never change the ingest response. A backend that runs an AI agent on the message still answers 2xx as soon as the message is durably stored; the edge must not wait on it. - Responses: any 2xx = accepted (the edge tells the sender 250). Any non-2xx or network error = the edge tempfails (DENYSOFT) and the sender retries — so a backend outage delays mail, never loses it. Do not return 4xx for per-recipient problems; recipient acceptance already happened at RCPT time (below).
Called by mta/plugins/mailkite_rcpt.js (live, cached ~30 s) and
mta/scripts/sync-host-list.mjs (boot-time seed).
- Auth:
Bearer <hmac secret>. - Response:
{"domains": ["example.com", ...]}— every domain the backend will accept inbound mail for. The edge DENYs RCPTs outside this set.
Called by mta-submit/plugins/auth_mailkite.js.
- Auth:
Bearer <hmac secret>(proves the caller is the edge). - Body:
{"key": "<the user's API key>"}. - Response:
200 {"ok": true, "userId": "<id>"}on success;{"ok": false}(or non-2xx) → the edge answers 535.
Called by mta-submit/plugins/mailkite_relay.js after successful AUTH.
- Auth:
Bearer <the user's API key>(user trust, not edge trust). - Body: raw RFC822 bytes (
Content-Type: message/rfc822). - Headers:
x-mailkite-rcpt: full envelope recipient list, comma-separated (recipients not in To/Cc are Bcc). - Responses (the edge maps these to SMTP):
- 2xx →
250 queued. - 4xx → permanent SMTP reject (bad From-domain, suppressed recipient, bad MIME) —
include
{"error": ...}so the client sees why. - 5xx / network → tempfail; the client retries.
- 2xx →
- Semantics: parse the MIME, enforce the same gates as the normal send API (From-domain verified, quotas), sign (DKIM) and deliver, and record the message in the account's Sent mailbox so it is visible over IMAP.
All five endpoints are edge-trusted: Authorization: Bearer <hmac secret>, JSON
bodies. mailbox is "INBOX" or "Sent" (v1).
mailboxId is an opaque session-scope token: whatever /api/imap/auth returns, the
edge echoes back on every read of that session, and the backend interprets. null means
account-wide. Implementations choose their own representation — api-local returns the
matched address string, MailKite Cloud returns a mailboxes row id — so treat it as
meaningless outside the backend that issued it. An address-scoped app password must
produce a non-null scope, or the session would read the whole account's mail over IMAP
while the same credential is correctly restricted over the mailbox REST routes.
- Body:
{"username": "<mailbox address>", "password": "<app-password>", "ip": "<client ip>"}. Theipis for backend-side brute-force lockout (lock the IP, not the username — no victim DoS). - Response:
200 {"ok": true, "userId": ..., "domain": ..., "mailboxId": <id|null>}; failure →{"ok": false, "code": "bad_credentials"}or non-2xx. - The password is an app password (
app-passwords.md): the backend resolves it, then checks that itsdomainequals the username's domain, that itsaddresspattern covers the username's local part, and that it grantsimapaccess. A password scoped*therefore serves every address on its domain. - App passwords are scoped credentials, never full API keys. Domains without retention (passthrough) must fail auth.
- Body:
{"userId", "mailboxId", "mailbox"}. - Response:
{"total": n, "unseen": n, "uidvalidity": n, "uidnext": n}.uidvaliditymust be stable for the life of the mailbox; if the mailbox is ever rebuilt, it must change.
- Body:
{"userId", "mailboxId", "mailbox"}. - Response:
{"messages": [{"uid": n, "flags": "Seen Flagged", "internaldate": "<ISO-8601>", "from_addr": ..., "to_addr": ..., "subject": ...}, ...]}ordered by uid ascending.flagsis space-separated without the IMAP backslashes ("Seen", not"\\Seen"); the edge converts.
- Body:
{"userId", "mailboxId", "mailbox", "uid"}. - Response:
200with the stored raw RFC822 bytes. Non-2xx when raw is unavailable (e.g. a message stored before raw retention) — the edge synthesizes a minimal header envelope from thelistfields instead.
- Body:
{"userId", "mailboxId", "mailbox", "uid", "flags": "Seen Flagged"}— full replacement set, same backslash-less encoding. - Response: any 2xx.
Create an app password for a mailbox address. Not called by any edge; backends may expose
it however suits them (the reference backend uses /api/admin/app-passwords and its CLI).
Read and manage one mailbox with an app password, no IMAP client involved. Auth is
Authorization: Bearer <app password>, and the password must grant api access and
cover the address being acted on. address is always a concrete mailbox — a
password scoped * still names which one it means (a pattern is rejected with 400).
| Route | |
|---|---|
GET /api/mailbox/messages?address=&mailbox=&limit=&before= |
list, newest first; {address, mailbox, messages[], nextBefore} |
GET /api/mailbox/messages/:uid/raw?address= |
raw RFC822 (message/rfc822) |
POST /api/mailbox/messages/:uid/flags |
{address, flags, mailbox?} — full replacement set |
Reads are scoped to that address: INBOX by the envelope recipient the message was stored
for, Sent by its sender. A message belonging to another address answers 404, not 403,
so the routes never confirm what exists outside the password's scope. Failures:
401 (no bearer), 403 (unknown password, missing api access, or an address the
password doesn't cover), 400 (missing/pattern address).
api-local/test/contract.test.mjs is the executable form of this document. Run it
against any backend with:
BACKEND_URL=http://localhost:8787 HMAC_SECRET=... node --test api-local/test/