Skip to content

REST API

Purpose. The control plane for the product and the integrator contract: everything the Setup Wizard, admin UI and mailctl do goes through /api/v1. The OpenAPI 3.1 document is generated from the Zod schemas in packages/core and served at /api/v1/openapi.json, with Swagger UI at /api/v1/docs; pnpm --filter @mailserver/api openapi prints it without a database (used to generate the typed client in packages/web-shared, see Admin UI).

Behaviour. The route lists below are representative rather than exhaustive. /api/v1/openapi.json is the complete contract for every JSON route. The four streaming routes (GET /mail/events, POST /ai/summarise, POST /ai/compose and POST /ai/copilot, all text/event-stream) are left out of it on purpose and are described only here and in their feature-docs.

  • Auth: POST /auth/login { email, password, totp? } → { accessToken, expiresIn, user } + refresh cookie; POST /auth/refresh (cookie) rotates and returns a new access token (login, handoff, refresh, logout, the two password-reset routes, PUT /auth/password, the /auth/sessions routes and the webmail’s PUT /mail/account/password answer 403 cross_origin to a browser request from another origin); POST /auth/logout; GET /auth/me; GET /auth/principal → { kind, role, label } for any credential, an unscoped API token included (which /auth/me refuses); a scoped token is refused 403 token_scope; POST /auth/totp/{setup,enable,disable} — enable answers the account’s security status plus ten single-use recoveryCodes, shown once; POST /auth/totp/recovery-codes { code } replaces them for a current authenticator code; GET /auth/security → { totpEnabled, totpUnreadable, recoveryCodesRemaining }. The account’s own password and sessions (an admin-console account; an API token gets 403): PUT /auth/password { current, password } changes it and signs out every other session (401 invalid_credentials for a wrong current one, counted on the sign-in’s own password budget, so it ends in 429 too_many_attempts as a sign-in would); GET /auth/sessions → [{ id, current, userAgent, ip, lastActiveAt, expiresAt }] (lastActiveAt is the session’s last token refresh; mailboxes the account opened from the console are not listed); DELETE /auth/sessions/:id signs one out (404 for a session not the account’s); POST /auth/sessions/revoke-others signs out every one but the caller’s, and every mailbox the account opened from the console (400 session_unknown when the caller’s own session cannot be identified). A session signed out by any of these, by logout, or by a password reset, suspension or deactivation is refused on its next request, not when its access token expires (amended 2026-10-18). totp at sign-in also takes one of the account’s recovery codes, for an admin-console account and a mailbox alike. Domain activity: GET /domains/:id/stats?days= → counts and a daily series from the delivery log (Domains). Branding: GET /branding (anonymous) → { productName, accentHue, logoUrl }; PUT /branding { productName?, accentHue? }, PUT /branding/logo (multipart file; PNG, JPEG or WebP by content, ≤ 200 KB, else 400 logo_type / 413 logo_too_large) and DELETE /branding/logo are owner/admin; GET /branding/logo serves the image (Branding). Mailbox export: POST /mailboxes/:id/export (owner/admin) → 201 { url, expiresIn }; GET /mailbox-exports/:token (anonymous, single use, five minutes) → application/gzip, a tar of mbox files (Mailboxes). Password reset (anonymous): POST /auth/password-reset { email } → 202 whatever the address (5 per IP per 15 min, then 429); POST /auth/password-reset/confirm { token, password } → 200, or 400 reset_invalid for an unknown, expired or spent link (Password reset by email). Send Authorization: Bearer <accessToken> or Bearer msk_<api token>. POST /auth/login answers 429 too_many_attempts once the identity or the caller’s IP is blocked — deliberately the same answer whether or not the identity exists, so it leaks nothing (Login rate limiting & brute-force lockout). POST /auth/handoff { token } is anonymous and spends a webmail hand-off: → LoginResponse + refresh cookie for the mailbox’s session (role mailbox_user, marked with the administrator holding it — unless the mailbox is the administrator’s own, which gets an ordinary session with no mark); 401 handoff_invalid when the token is unknown, expired or already spent, and 403 when the administrator who minted it is no longer active or eligible, or the mailbox is no longer active.
  • Setup (unauthenticated while pending, 409 afterwards, except GET /setup, which stays readable anonymously): GET /setup (while pending, the full status the wizard works from, with publicBind and pop3; once complete, only { "state": "complete" }, because the rest names the owner, the hostname, the domain and the version), PUT /setup/hostname, PUT /setup/tls (refuses none on a public bind without allowPlaintextAuth), PUT /setup/protocols (the POP3 switch; not a step), POST /setup/domain, POST /setup/owner, GET /setup/ai/providers, /setup/ai (GET/PUT/DELETE), POST /setup/ai/test, POST /setup/complete, POST /setup/answers (all steps at once).
  • Mail (owner/admin/domain_admin): /domains, /domains/{id}/{mailboxes,aliases,catchall,dns,dns/verify,dns/export,dkim,dkim/{selector},dmarc,tls-rpt,stats,usage,spam,spam/list,rate-limit}, /mailboxes/{id}, /mailboxes/{id}/{password,forwarders,usage,filters,filters/check,filters/compile,autoresponder,spam,spam/list,rate-limit,security,security/app-passwords,security/totp/clear-broken}, /aliases/{id}, /forwarders/{id}, /delivery-log (Delivery log & bounce classification).
  • Open a mailbox’s webmail: POST /mailboxes/{id}/webmail-session (owner/admin only — a domain_admin is refused even in its own domains) and POST /mailboxes/mine/webmail-session (owner, admin, or domain_admin, for the caller’s own mailbox only) → 201 { url, expiresIn }. url is absolute, on the webmail’s origin, and carries a single-use hand-off that POST /auth/handoff spends; expiresIn is its life in seconds (30). The first answers 404 for an unknown mailbox and 403 for one that is inactive, suspended or a system mailbox; the second answers 404 when the account has no mailbox of its own. Nothing is audited at issue — the access is recorded when the hand-off is spent.
  • Audit (owner/admin only): GET /audit?actor=&action=&entity=&from=&to= — actor a user id, action a recorded action’s name, or a prefix ending in . (mailbox.), entity an entity name, from/to instants with to exclusive (the screen sends the reader’s local midnights); GET /audit/facets → { actions, entities, actors }, what the log holds to filter on. A sign-in is recorded as made by the account that signed in. It sits in the same route file as the mail endpoints above and reads like one of them, but it carries its own requireRole() with no arguments, which means the admin roles — so a domain_admin gets 403, not a filtered list. That is deliberate: an audit entry names users, tokens and other tenants’ objects, and there is no per-domain view of it to hand out. Admin UI has always said owner/admin; this line used to say domain_admin and was wrong.
  • Webmail (mailbox owner, or an admin whose email is a mailbox): /mail/account, /mail/account/{settings,password,profile,avatar}, /mail/avatars/lookup, /mail/avatars/{address}, /mail/folders, /mail/contacts, /mail/security, /mail/security/{app-passwords,totp/setup,totp/enable,totp/disable,totp/recovery-codes}, /mail/security/app-passwords/{id} (DELETE), /mail/folders/empty, /mail/messages, /mail/messages/{uid}, /mail/messages/{uid}/raw, /mail/messages/{uid}/parts/{part}, /mail/messages/{flags,move,delete}, /mail/uploads, /mail/send, /mail/scheduled, /mail/scheduled/{id} (PATCH the time, DELETE to cancel), /mail/scheduled/{id}/{send,edit}, /mail/drafts, /mail/filters, /mail/filters/check, /mail/filters/compile, /mail/filters/try, /mail/filters/apply, /mail/categories, /mail/autoresponder, /mail/events (SSE) — Webmail. GET /mail/account also returns adminUrl, the console’s absolute URL, which is how the webmail links back to an application whose host it does not know.
  • Mail queue (owner/admin/domain_admin): GET /queue, POST /queue/actions { action, ids } — read live from Postfix through the queue agent, never from a table (Mail queue (viewer, hold / release / delete)).
  • Server health (owner/admin): /alerts, /alerts/{id}/resolve, /server/reputation — raised/stored by the worker (Blocklist, reverse-DNS and certificate monitoring (alerts)).
  • Messages (a scoped token holding messages:send, and messages:read for the reads; no role can call these): POST /messages sends from an address the token may send from and answers 202 once Postfix has queued it, with an Idempotency-Key header making a retry safe; GET /messages/{id} is the message with the latest delivery-log event per recipient; GET /messages lists them. Refusals and the status words are in Sending from an application.
  • Admin (owner/admin): /users (and POST /users/{id}/totp/clear-broken, which clears a user’s second factor only while its seed cannot be decrypted), /tokens, /auth-bans (GET the live authentication blocks, DELETE /auth-bans/{id} to clear one early — server-wide rather than tenant-scoped, because the login that earned a block had no tenant), /server/config (GET current, PUT apply, POST …/preview render only, GET …/history, POST …/rollback { version }), /server/status (per-daemon watcher heartbeat + applied render).
  • AI (owner/admin unless noted; GET /ai/availability, /ai/opt-in and the inbox features admit any role with a mailbox — owner, admin, domain_admin, mailbox_user — AI providers, AI inbox features, AI admin copilot): provider configuration — /ai/providers (the catalogue), /ai/config (GET/PUT/DELETE), POST /ai/config/test, /ai/domains/{id}/policy, /ai/limit (GET/PUT), GET /ai/mailboxes/{id}/usage, GET /ai/availability, GET /ai/log; per-mailbox consent — /ai/opt-in (GET/PUT, any signed-in role, never a scoped token; AI privacy controls); inbox features — POST /ai/{summarise,replies,compose,explain}; the admin copilot — POST /ai/copilot, GET /ai/copilot/{readiness,tool-calls}; and write actions as stored proposals — /ai/proposals, POST /ai/proposals/{id}/{apply,reject} — never applied by the model itself.
  • Migrations (owner/admin, Migration and the per-source run-books): /migrations (GET list, POST create), POST /migrations/discover (credential probe before a job exists), /migrations/{id} (GET/DELETE), PUT /migrations/{id}/secret (re-enter the source credentials — sealed with MIGRATION_SECRET_KEY), POST /migrations/{id}/{discover,precreate,import,run,stop,cutover}, the per-mailbox selection (GET/POST /migrations/{id}/mailboxes, DELETE /migrations/{id}/mailboxes/{mailboxId}) and GET /migrations/{id}/log. POST /migrations/{id}/precreate takes the mailbox gate — a migration is not a way around the mailbox limit — without answering 402 for it: each address the licence refuses (over the plan’s limit, or from degraded) is listed in the 200 response’s skipped[] with the refusal as its reason (services/migration-import.ts). From restricted the call as a whole is refused 402 license_degraded, as every administrator’s write not left open is (plugins/licence-restriction.ts).
  • Licensing: GET /license (owner/admin/domain_admin — state (evaluation, active, grace, degraded, restricted, send_suspended, or unlicensed when not assessed), days to the next stage, the lapse’s kind, reason, plan, usage, both machine fingerprints, last check-in), POST /license/activate { key } (owner only; the key is verified before it is stored, and refused with 400 license_* naming the reason) and POST /license/transfer (owner only, no body — the credential is the key already installed here; see Licensing). Enforcement is 402 license_degraded / 402 license_limit_reached on POST /domains and POST /domains/{id}/mailboxes, and a per-address entry in skipped[] on POST /migrations/{id}/precreate; the other refusals — AI, starting a migration, sending tokens, administration read-only from restricted and outside recipients refused at submission from send_suspended — are listed in licensing.md → What each stage refuses (Licensing).
  • System: GET /health { ok, version, setup } (no auth).
  • Secrets are write-only. GET /server/config and its history never carry relay.password; an audit entry’s before/after is redacted at any depth.
  • Every response carries x-request-id except the four event streams (/mail/events, /ai/summarise, /ai/compose and /ai/copilot), which write their headers directly, a UUID the server generated and logged as reqId; a client’s own x-request-id or request-id is ignored.
  • Errors are { statusCode, error, code, message }; 400 validation lists the offending fields; 403 no_mailbox means the caller has no active mailbox, from every webmail route and the mailbox-scoped /ai/* routes; 422 config_rejected carries the daemon validator output; 422 sieve_rejected / 502 sieve_unavailable come from the ManageSieve push (Mailbox filters & autoresponders); Send later answers 400 send_at_too_soon / 400 send_at_too_late for a time under a minute or over a year ahead, 409 not_scheduled for a message no longer waiting, and 503 scheduling_unavailable on a server that cannot send later (Compose, send and drafts).
  • Lists are { items, total } with ?limit=&offset=. The cap is per endpoint, not global. PageQuery — domains, mailboxes, aliases — and /audit’s own query allow up to 500 (default 100), and so does /delivery-log. Everything with a more expensive backing store is tighter: /queue and /mail/messages cap at 200 (default 50), though /mail/messages pages by cursor rather than offset (?before=<uid>, then follow next; it answers { folder, items, total, next }), /ai/log at 200 (default 50), /mail/contacts at 50 (default 10), /messages at 100 (default 50), and /migrations/{id}/log at 1000 (default 200). GET /ai/proposals, GET /ai/copilot/tool-calls and GET /mail/contacts are the exceptions to the shape: plain arrays with ?limit= only (no offset, no total), the first two capped at 200 (default 50), and contacts also taking ?q=. GET /mail/scheduled is a plain array too, with no paging: a mailbox’s waiting, sending and failed messages. A limit over the endpoint’s own maximum is a 400 validation, not a silently clamped page, so an integrator who hard-codes 500 everywhere gets an error on the seven capped below 500 (/queue, /mail/messages, /ai/log, /mail/contacts, /messages, /ai/proposals and /ai/copilot/tool-calls) rather than a short answer they might not notice.
  • Every administrative mutation writes an audit entry, except a send through /messages, which is recorded in its own outbound_message row rather than the audit log. A mailbox owner’s own webmail actions are not audited — flags, moves, folders, sends, scheduled messages, the profile and picture, POST /mail/filters/apply — except its password, app passwords and two-factor changes, its filters and autoresponder, and what an administrator does on its behalf.

Configuration. Environment of the api container (set by compose from .env), grouped by what happens when one is missing.

  • No default — the API will not boot without them: DATABASE_URL (refused, too, when it names a user with an empty password, which is what Compose builds from an .env without DB_ADMIN_PASSWORD), JWT_SECRET (min 32 chars) and SECRET_ENCRYPTION_KEY (min 32 chars). SECRET_ENCRYPTION_KEY is deliberately required rather than optional: a server that silently stored the customer’s AI provider key in plaintext when the variable happened to be unset is the exact failure it exists to prevent, so the schema refuses to start instead. An unset or short one stops the API at boot with one line naming each variable. mailctl upgrade back-fills a secret that is absent from .env; a line that is present but empty or short is left alone (except DB_ADMIN_PASSWORD, which mailctl upgrade and mailctl restore fill with mail, the value the database was created with), and Upgrade → One-time: an .env without its secrets says what to set, because a new key can make sealed data unreadable. The Compose bundle passes them through with no default of its own, so an .env without them is refused rather than run on a key somebody else chose.
  • LICENSE_PUBLIC_KEY is the exception to the empties below: Compose’s :- substitutes the production key for an empty value, deliberately, so an empty line cannot switch licence enforcement off (Licensing).
  • Defaults to empty, and the empty value disables a feature rather than failing: LICENSE_SERVER_URL (empty ⇒ self-service licence transfer falls back to the support procedure; everything else about licensing is offline anyway), MIGRATION_SECRET_KEY (empty ⇒ the migration routes answer 503 rather than storing a third party’s password in the clear)
  • Daemon credentials, because the daemons read Postgres directly: DB_DAEMON_USER (mail_daemon) and DB_DAEMON_PASSWORD — the API sets the real password on the role on every boot; the bundle supplies no default, so Postfix and Dovecot refuse to start without it, naming the key, and mailctl upgrade/restore generate one for an absent or empty line. DOVECOT_MASTER_USER (mailserver-api) and DOVECOT_MASTER_PASSWORD are the webmail bridge, submission and ManageSieve credentials (ADRs 0008, 0010); the same holds — no default, Dovecot refuses to start without it, and upgrade/restore generate it.
  • Transports and service names, all with working defaults: ENGINE_TRANSPORT=file|noop, SIEVE_TRANSPORT=managesieve|noop, MAIL_BRIDGE=imap|noop, MAILCONFIG_ROOT, CERTS_DIR, CADDY_ADMIN_URL, ENGINE_TIMEOUT_MS, QUEUE_TIMEOUT_MS (shorter than the engine’s on purpose — a render is an act an admin waits out, a queue listing is a page load), SERVICE_*, SIEVE_PORT, IMAP_PORT, SUBMISSION_PORT. There is no TLS setting for the internal hops: they upgrade with STARTTLS whenever the daemon offers it (Compose, send and drafts).
  • PUBLISH_BIND (default 0.0.0.0, the same as Compose’s port mappings): the host address the mail ports are published on, read for one decision — whether tls.mode = none is refused without its escape. IPv6: IPV6_ENABLED (default false; on, the render has Postfix and Dovecot listen on IPv6 too, and the DNS list offers the AAAA and IPv6 PTR), PUBLISH_BIND6 (default ::, the IPv6 publish address, read for the same decision) and PUBLIC_IP6 (the host’s public IPv6 address, for the DNS list).
  • The messages resource: MESSAGES_RATE_LIMIT_PER_MINUTE (default 120, per token, this resource only — reads count against it too) and MESSAGES_BODY_LIMIT_BYTES (default 15 MiB, the largest request; the composed message is separately held to limits.messageSizeBytes). Compose passes both from .env with those defaults.
  • Presentation and operations: APP_VERSION (the image tag), BUILD_ID (normally unset: read from the image’s /app/build-id, and with the tag it is the build identity server_config.rendered_by records; setting it, even to an empty value, replaces the image’s, so leave it unset), PUBLIC_IP, COOKIE_SECURE, LOG_LEVEL, PORT, HOST, ACCESS_TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_DAYS, RUN_MIGRATIONS (default true).

apps/api/src/config.ts is the single definition; it is a Zod schema, so it is also the list of what is required and what defaults to what.

API tokens. POST /tokens creates one and returns its msk_… secret once; only a hash is stored. A token is one of two kinds.

  • A token with a role acts as that role: { name, role?, expiresAt? }, where role is admin when left out.
  • A scoped token may do what its permissions say and nothing a role may do: { name, permissions, senders?, expiresAt? }. The permissions are messages:send and messages:read. senders lists the addresses the token may send from, each one address (billing@example.com) or every address on a domain (*@example.com). It is required with messages:send and refused without it. A wildcard does not cover a subdomain.

A request naming both role and permissions is a 400. So is a sender on a domain this server does not hold (sender_domain_unknown).

A scoped token is refused by every route that is guarded by role, with 403 token_scope. That is every route here that requires a role, the signed-in /auth/me and /auth/principal included, except Messages, whose routes admit only a scoped token holding the permission (Sending from an application). A token object carries permissions and senders (null on a token with a role); at most 50 senders, no bare *, and a permission listed twice is a 400. The routes that need no role at all are the anonymous ones: POST /auth/login, /auth/handoff, /auth/refresh, /auth/logout, the two password-reset routes, /setup/* while setup is pending (GET /setup always), /health, GET /openapi.json and the /docs page, GET /branding and its logo, GET /mailbox-exports/{token}, /internal/tls-ask, and the per-domain client and policy files answered by host name (GET /.well-known/mta-sts.txt, GET /mail/config-v1.1.xml, POST /autodiscover/autodiscover.xml).

Limits & edge cases. Breaking changes bump the /api/v1 prefix. Access tokens live 15 min, and are refused as soon as their session ends; revoked API tokens fail immediately. A token created before scoped tokens existed is a token with a role, and behaves as it always did.