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/sessionsroutes and the webmail’sPUT /mail/account/passwordanswer403 cross_originto 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/merefuses); a scoped token is refused403 token_scope;POST /auth/totp/{setup,enable,disable}—enableanswers the account’s security status plus ten single-userecoveryCodes, 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 gets403):PUT /auth/password{ current, password }changes it and signs out every other session (401 invalid_credentialsfor a wrong current one, counted on the sign-in’s own password budget, so it ends in429 too_many_attemptsas a sign-in would);GET /auth/sessions→[{ id, current, userAgent, ip, lastActiveAt, expiresAt }](lastActiveAtis the session’s last token refresh; mailboxes the account opened from the console are not listed);DELETE /auth/sessions/:idsigns one out (404for a session not the account’s);POST /auth/sessions/revoke-otherssigns out every one but the caller’s, and every mailbox the account opened from the console (400 session_unknownwhen the caller’s own session cannot be identified). A session signed out by any of these, bylogout, or by a password reset, suspension or deactivation is refused on its next request, not when its access token expires (amended 2026-10-18).totpat 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(multipartfile; PNG, JPEG or WebP by content, ≤ 200 KB, else400 logo_type/413 logo_too_large) andDELETE /branding/logoare owner/admin;GET /branding/logoserves 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 400reset_invalidfor an unknown, expired or spent link (Password reset by email). SendAuthorization: Bearer <accessToken>orBearer msk_<api token>.POST /auth/loginanswers 429too_many_attemptsonce 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 (rolemailbox_user, marked with the administrator holding it — unless the mailbox is the administrator’s own, which gets an ordinary session with no mark); 401handoff_invalidwhen 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, withpublicBindandpop3; once complete, only{ "state": "complete" }, because the rest names the owner, the hostname, the domain and the version),PUT /setup/hostname,PUT /setup/tls(refusesnoneon a public bind withoutallowPlaintextAuth),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 — adomain_adminis refused even in its own domains) andPOST /mailboxes/mine/webmail-session(owner, admin, or domain_admin, for the caller’s own mailbox only) →201 { url, expiresIn }.urlis absolute, on the webmail’s origin, and carries a single-use hand-off thatPOST /auth/handoffspends;expiresInis 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=—actora user id,actiona recorded action’s name, or a prefix ending in.(mailbox.),entityan entity name,from/toinstants withtoexclusive (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 ownrequireRole()with no arguments, which means the admin roles — so adomain_admingets 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 saydomain_adminand 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}(PATCHthe time,DELETEto 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/accountalso returnsadminUrl, 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, andmessages:readfor the reads; no role can call these):POST /messagessends from an address the token may send from and answers 202 once Postfix has queued it, with anIdempotency-Keyheader making a retry safe;GET /messages/{id}is the message with the latest delivery-log event per recipient;GET /messageslists them. Refusals and the status words are in Sending from an application. - Admin (owner/admin):
/users(andPOST /users/{id}/totp/clear-broken, which clears a user’s second factor only while its seed cannot be decrypted),/tokens,/auth-bans(GETthe 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(GETcurrent,PUTapply,POST …/previewrender only,GET …/history,POST …/rollback { version }),/server/status(per-daemon watcher heartbeat + applied render). - AI (owner/admin unless noted;
GET /ai/availability,/ai/opt-inand 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(GETlist,POSTcreate),POST /migrations/discover(credential probe before a job exists),/migrations/{id}(GET/DELETE),PUT /migrations/{id}/secret(re-enter the source credentials — sealed withMIGRATION_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}) andGET /migrations/{id}/log.POST /migrations/{id}/precreatetakes 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 fromdegraded) is listed in the200response’sskipped[]with the refusal as its reason (services/migration-import.ts). Fromrestrictedthe call as a whole is refused402 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, orunlicensedwhen 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 with400 license_*naming the reason) andPOST /license/transfer(owner only, no body — the credential is the key already installed here; see Licensing). Enforcement is402 license_degraded/402 license_limit_reachedonPOST /domainsandPOST /domains/{id}/mailboxes, and a per-address entry inskipped[]onPOST /migrations/{id}/precreate; the other refusals — AI, starting a migration, sending tokens, administration read-only fromrestrictedand outside recipients refused at submission fromsend_suspended— are listed inlicensing.md→ What each stage refuses (Licensing). - System:
GET /health{ ok, version, setup }(no auth). - Secrets are write-only.
GET /server/configand its history never carryrelay.password; an audit entry’sbefore/afteris redacted at any depth. - Every response carries
x-request-idexcept the four event streams (/mail/events,/ai/summarise,/ai/composeand/ai/copilot), which write their headers directly, a UUID the server generated and logged asreqId; a client’s ownx-request-idorrequest-idis ignored. - Errors are
{ statusCode, error, code, message };400 validationlists the offending fields;403 no_mailboxmeans the caller has no active mailbox, from every webmail route and the mailbox-scoped/ai/*routes;422 config_rejectedcarries the daemon validator output;422 sieve_rejected/502 sieve_unavailablecome from the ManageSieve push (Mailbox filters & autoresponders); Send later answers400 send_at_too_soon/400 send_at_too_latefor a time under a minute or over a year ahead,409 not_scheduledfor a message no longer waiting, and503 scheduling_unavailableon 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:/queueand/mail/messagescap at 200 (default 50), though/mail/messagespages by cursor rather than offset (?before=<uid>, then follownext; it answers{ folder, items, total, next }),/ai/logat 200 (default 50),/mail/contactsat 50 (default 10),/messagesat 100 (default 50), and/migrations/{id}/logat 1000 (default 200).GET /ai/proposals,GET /ai/copilot/tool-callsandGET /mail/contactsare the exceptions to the shape: plain arrays with?limit=only (nooffset, nototal), the first two capped at 200 (default 50), and contacts also taking?q=.GET /mail/scheduledis a plain array too, with no paging: a mailbox’s waiting, sending and failed messages. Alimitover the endpoint’s own maximum is a400 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/proposalsand/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 ownoutbound_messagerow 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.envwithoutDB_ADMIN_PASSWORD),JWT_SECRET(min 32 chars) andSECRET_ENCRYPTION_KEY(min 32 chars).SECRET_ENCRYPTION_KEYis 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 upgradeback-fills a secret that is absent from.env; a line that is present but empty or short is left alone (exceptDB_ADMIN_PASSWORD, whichmailctl upgradeandmailctl restorefill withmail, the value the database was created with), and Upgrade → One-time: an.envwithout 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.envwithout them is refused rather than run on a key somebody else chose. LICENSE_PUBLIC_KEYis 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) andDB_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, andmailctl upgrade/restoregenerate one for an absent or empty line.DOVECOT_MASTER_USER(mailserver-api) andDOVECOT_MASTER_PASSWORDare 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(default0.0.0.0, the same as Compose’s port mappings): the host address the mail ports are published on, read for one decision — whethertls.mode = noneis refused without its escape. IPv6:IPV6_ENABLED(defaultfalse; 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) andPUBLIC_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) andMESSAGES_BODY_LIMIT_BYTES(default 15 MiB, the largest request; the composed message is separately held tolimits.messageSizeBytes). Compose passes both from.envwith 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 identityserver_config.rendered_byrecords; 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(defaulttrue).
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? }, whereroleisadminwhen left out. - A scoped token may do what its permissions say and nothing a role may do:
{ name, permissions, senders?, expiresAt? }. The permissions aremessages:sendandmessages:read.senderslists the addresses the token may send from, each one address (billing@example.com) or every address on a domain (*@example.com). It is required withmessages:sendand 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.