# The backend contract 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](https://mailkite.dev) 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). ## Authentication Two schemes, mirroring the two trust models: - **Edge trust (HMAC secret):** the shared secret (`MAILKITE_HMAC_SECRET` on the edges) proves the caller *is* a trusted edge. Sent as `Authorization: Bearer ` — 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/relay` authenticates the *user* with their API key as Bearer; `/api/imap/auth` verifies a scoped app-password. All request/response bodies are JSON unless noted. Non-2xx errors should carry `{"error": "", "code": ""}` when a body is present. --- ## Inbound ### `POST /api/ingest` — accept a raw inbound message 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=,v1=` where ` = HMAC-SHA256(secret, "." ‖ raw-body)`. Reject if the signature fails or `t` is 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`](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). ### `GET /api/mx/accepted-domains` — anti-open-relay list Called by `mta/plugins/mailkite_rcpt.js` (live, cached ~30 s) and `mta/scripts/sync-host-list.mjs` (boot-time seed). - **Auth:** `Bearer `. - **Response:** `{"domains": ["example.com", ...]}` — every domain the backend will accept inbound mail for. The edge DENYs RCPTs outside this set. --- ## Outbound ### `POST /api/smtp/auth` — validate an SMTP AUTH credential Called by `mta-submit/plugins/auth_mailkite.js`. - **Auth:** `Bearer ` (proves the caller is the edge). - **Body:** `{"key": ""}`. - **Response:** `200 {"ok": true, "userId": ""}` on success; `{"ok": false}` (or non-2xx) → the edge answers 535. ### `POST /api/relay` — send a raw outbound message Called by `mta-submit/plugins/mailkite_relay.js` after successful AUTH. - **Auth:** `Bearer ` (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. - **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. --- ## IMAP read API All five endpoints are **edge-trusted**: `Authorization: Bearer `, 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. ### `POST /api/imap/auth` - **Body:** `{"username": "", "password": "", "ip": ""}`. The `ip` is for backend-side brute-force lockout (lock the IP, not the username — no victim DoS). - **Response:** `200 {"ok": true, "userId": ..., "domain": ..., "mailboxId": }`; failure → `{"ok": false, "code": "bad_credentials"}` or non-2xx. - The password is an **app password** ([`app-passwords.md`](app-passwords.md)): the backend resolves it, then checks that its `domain` equals the username's domain, that its `address` pattern covers the username's local part, and that it grants `imap` access. 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. ### `POST /api/imap/status` - **Body:** `{"userId", "mailboxId", "mailbox"}`. - **Response:** `{"total": n, "unseen": n, "uidvalidity": n, "uidnext": n}`. `uidvalidity` must be stable for the life of the mailbox; if the mailbox is ever rebuilt, it must change. ### `POST /api/imap/list` - **Body:** `{"userId", "mailboxId", "mailbox"}`. - **Response:** `{"messages": [{"uid": n, "flags": "Seen Flagged", "internaldate": "", "from_addr": ..., "to_addr": ..., "subject": ...}, ...]}` ordered by uid ascending. `flags` is space-separated **without** the IMAP backslashes (`"Seen"`, not `"\\Seen"`); the edge converts. ### `POST /api/imap/raw` - **Body:** `{"userId", "mailboxId", "mailbox", "uid"}`. - **Response:** `200` with 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 the `list` fields instead. ### `POST /api/imap/flags` - **Body:** `{"userId", "mailboxId", "mailbox", "uid", "flags": "Seen Flagged"}` — full replacement set, same backslash-less encoding. - **Response:** any 2xx. ### `POST /api/imap/keys` *(optional, dashboard/CLI surface)* 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). --- ## Mailbox API — user-trust Read and manage one mailbox with an app password, no IMAP client involved. Auth is `Authorization: Bearer `, 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). --- ## Conformance `api-local/test/contract.test.mjs` is the executable form of this document. Run it against any backend with: ```sh BACKEND_URL=http://localhost:8787 HMAC_SECRET=... node --test api-local/test/ ```