Ferroma
Protocols · Documentation

Ferroma HTTP API

Two audiences share one router:

SurfaceBase pathWho uses it
Management API/api/v1Webmail, the Admin panel, scripts
Client API (FCP)/api/v1/clientthe official desktop clients

Both are plain JSON over HTTP/1.1 and HTTP/2. TLS is terminated by Ferroma itself (api.tls_port) or by a reverse proxy in front of it.

Everything on this page is implemented by ferroma-api; the protocol-level details of the client surface (versions, cursors, realtime framing) live in fcp.md.


1. Conventions

1.1 Content type and encoding

Requests and responses are application/json; charset=utf-8 unless stated otherwise. Attachments are streamed as application/octet-stream with their real Content-Type in the metadata. Field names are snake_case.

1.2 Authentication

SurfaceMechanism
Management APIAuthorization: Bearer <access_token> or a ferroma_session cookie (Webmail)
Client APIAuthorization: Bearer <access_token> only

Access tokens are HS256 JWTs issued by POST /auth/login. They are short-lived (api.access_token_ttl_secs, one hour by default); the refresh token obtained at the same time mints new ones. Refresh tokens are single-use: refreshing rotates them and invalidates the previous value.

Admin endpoints additionally require is_admin on the user.

1.3 Errors

Every failure returns the same envelope:

{
  "error": {
    "code": "invalid_input",
    "message": "recipient address has no domain: bob",
    "details": { "field": "to" }
  }
}

code is stable and machine-readable (it is literally FerromaError::code()); message is human-readable and may change. details is optional and only present when there is something structured to say.

HTTPcodeMeaning
400invalid_input, parse_error, protocol_errorthe request is malformed
401unauthorizedmissing, expired or revoked credentials
403forbiddenauthenticated but not allowed (e.g. not an admin)
404not_foundno such message, mailbox, draft, device
409conflictuniqueness or state violation; an operation whose original request is still in flight; a sync cursor older than the retained history
413limit_exceeded, mailbox_fullmessage too large, too many recipients, or the recipient's mailbox is full
426unsupportedthe client's X-Ferroma-Protocol is below client.min_protocol_version; upgrade required
429rate_limitedslow down; honour Retry-After
500storage_error, internal_errora bug or a database failure
501unsupporteda specified-but-unimplemented capability, where no protocol upgrade would help
502dns_error, network_error, timeoutan upstream failure

409 is not what a client sees for an operation it already completed. See §1.5.

1.4 Pagination

List endpoints accept limit (default 50, maximum 500) and offset and return:

{ "items": [ … ], "total": 1234, "limit": 50, "offset": 0 }

Incremental sync does not use pagination; it uses a cursor (§5).

1.5 Idempotency

State-changing requests accept an idempotency key: the Idempotency-Key header on the management surface, or operation_id in the body on the Client API. POST, PATCH and DELETE all accept it.

The server records the operation and replays the original response for a repeat — same status, same body — so a client that retries after a timeout never double-sends or double-deletes. Keys are kept for client.tombstone_retention_days.

first  POST /api/v1/client/messages  {"operation_id":"op_9f2c…", …}  -> 201 {"message_id":4821,…}
retry  POST /api/v1/client/messages  {"operation_id":"op_9f2c…", …}  -> 201 {"message_id":4821,…}

Three outcomes are worth distinguishing, because a client must react to them differently:

SituationResponseWhat the client does
The key is newthe operation runsnormal handling
The key was already completedthe recorded response, replayed verbatimtreat as success; do not retry
The key's original request is still running (or its process died mid-request)409 conflict, "operation … has not finished; retry later"retry later with the same key

A DELETE that is retried after the resource is already gone answers 404; a client flushing a queued delete must treat that as success, since the desired end state holds. That is what makes DELETE safe without a separate tombstone API.

1.6 Rate limits

429 responses carry Retry-After in seconds. Submission and login are limited per account; the API is limited per token.

1.7 Timestamps

RFC 3339 / ISO 8601 in UTC, e.g. 2026-09-16T12:00:00Z. IDs are integers except operation_id (op_…) and device_uid (client-generated string).


2. Health and discovery

GET /api/v1/health

No authentication. Drives the container healthcheck.

{
  "status": "ok",
  "version": "0.1.0",
  "protocol_version": 1,
  "uptime_secs": 84213,
  "database": { "ok": true, "server_version": "PostgreSQL 16.15", "pool": { "size": 4, "idle": 3, "max": 20 } },
  "smtp": { "enabled": true, "connections": 3 },
  "imap": { "enabled": true, "connections": 1 },
  "clients": { "active_sessions": 4, "active_devices": 2 },
  "queue": { "pending": 0, "delivering": 0, "retry": 2, "failed": 1, "received_today": 128, "sent_today": 41 }
}

clients.active_sessions counts live Webmail/API/client sessions (rows in sessions that are neither revoked nor expired), and active_devices counts non-revoked devices rows. queue.received_today and sent_today cover the current UTC day. All four feed the Admin dashboard, which must render an absent figure as rather than a fabricated zero.

Returns 503 with "status": "degraded" when the database is unreachable; the queue and clients blocks are then omitted rather than reported as zero.

GET /api/v1/version

{ "version": "0.1.0", "protocol_version": 1, "git_sha": "abc1234", "built": "…" }

GET /.well-known/ferroma

Unauthenticated autodiscovery (specification §32). Clients fetch this from the domain of the address the user typed.

{
  "api": "https://mail.example.com/api/v1",
  "imap": { "host": "mail.example.com", "port": 993, "tls": true },
  "smtp": { "host": "mail.example.com", "port": 587, "tls": true },
  "web": "https://mail.example.com",
  "protocol_version": 1
}

GET /.well-known/mta-sts.txt and GET /api/v1/domains/:id/dns

See §4.4.


3. Authentication (management surface)

POST /api/v1/auth/login

{ "email": "[email protected]", "password": "…", "device_name": "Firefox on Linux" }
{
  "access_token": "eyJ…",
  "refresh_token": "rt_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "user": { "id": 7, "email": "[email protected]", "display_name": "Alice", "is_admin": false, "quota_bytes": 1073741824, "used_bytes": 52428800 }
}

Sets the ferroma_session cookie when device_name is absent (browser flow). 401 unauthorized for bad credentials; 429 rate_limited after limits.max_failed_logins failures, with the account locked for limits.login_lockout_secs.

POST /api/v1/auth/refresh

{ "refresh_token": "rt_…" } → a brand-new token pair. The presented refresh token is invalidated. A reused refresh token revokes the whole family and returns 401.

POST /api/v1/auth/logout

Revokes the current session. 204 No Content.

GET /api/v1/auth/me

The authenticated user, plus their addresses:

{
  "id": 7, "email": "[email protected]", "display_name": "Alice",
  "is_admin": false, "quota_bytes": 1073741824, "used_bytes": 52428800,
  "mailboxes": [ { "id": 3, "address": "[email protected]", "is_primary": true } ]
}

POST /api/v1/auth/password

{ "current_password": "…", "new_password": "…" }. Revokes every other session.


4. Administration

Admin-only. 403 forbidden for ordinary users.

4.1 Users

MethodPathNotes
GET/api/v1/users?query=&limit=&offset=
POST/api/v1/users{email, password, display_name?, is_admin?, quota_bytes?}
GET/api/v1/users/:id
PATCH/api/v1/users/:idany of display_name, enabled, is_admin, quota_bytes, password
DELETE/api/v1/users/:idcascades: addresses, folders, messages, queue rows
GET/api/v1/users/:id/mailboxesaddresses owned by the user
POST/api/v1/users/:id/mailboxes{domain, local_part, is_primary?, quota_bytes?} — creates the Maildir and the standard folders

4.2 Domains

MethodPathNotes
GET/api/v1/domains
POST/api/v1/domains{name, description?}
GET/api/v1/domains/:id
PATCH/api/v1/domains/:idenabled, description, catch_all
DELETE/api/v1/domains/:idrefuses while addresses exist unless ?force=true

4.3 Aliases

MethodPath
GET/api/v1/domains/:id/aliases
POST/api/v1/domains/:id/aliases{local_part, target}
PATCH/api/v1/aliases/:id
DELETE/api/v1/aliases/:id

4.4 DNS diagnostics

GET /api/v1/domains/:id/dns runs the live checks behind the Admin "DNS Health" panel (specification §16):

{
  "domain": "example.com",
  "checked_at": "2026-09-16T12:00:00Z",
  "records": [
    { "kind": "MX",    "status": "ok",   "expected": "mail.example.com", "found": ["10 mail.example.com."] },
    { "kind": "A",     "status": "ok",   "expected": "203.0.113.10",     "found": ["203.0.113.10"] },
    { "kind": "AAAA",  "status": "skip", "found": [] },
    { "kind": "PTR",   "status": "ok",   "expected": "mail.example.com", "found": ["mail.example.com."] },
    { "kind": "SPF",   "status": "ok",   "found": ["v=spf1 mx -all"] },
    { "kind": "DKIM",  "status": "warn", "expected": "default._domainkey.example.com",
      "found": [], "hint": "publish the TXT record shown by GET /api/v1/domains/:id/dkim" },
    { "kind": "DMARC", "status": "ok",   "found": ["v=DMARC1; p=quarantine; rua=mailto:[email protected]"] }
  ],
  "score": 5,
  "max_score": 7
}

status is ok, warn, fail or skip.

GET /api/v1/domains/:id/dkim returns the DNS record to publish:

{ "selector": "default", "record_name": "default._domainkey.example.com", "record_type": "TXT", "record_value": "v=DKIM1; k=rsa; p=MIIBIjANBg…" }

POST /api/v1/domains/:id/dkim generates a key pair when none exists.

4.5 Mail queue and delivery logs

MethodPathNotes
GET/api/v1/queue?status=pending|delivering|delivered|retry|failed|cancelled&limit=&offset=
GET/api/v1/queue/:idone entry plus its attempt history
POST/api/v1/queue/:id/retryrequeue a failed entry now
DELETE/api/v1/queue/:idcancel
GET/api/v1/queue/statscounts per status, plus next_due_at

4.6 Storage, audit and settings

MethodPathNotes
GET/api/v1/storagesee the shape below
POST/api/v1/storage/gcdrops unreferenced attachment blobs and stale tmp/ files
GET/api/v1/audit?actor_user_id=&action=&since=&limit=&offset=
GET/api/v1/settingsDB-backed settings
PUT/api/v1/settings/:key{ "value": … }

GET /api/v1/storage answers the Admin "Storage" screen and the dashboard cards:

{
  "maildir_bytes": 8123456789,
  "attachment_bytes": 1234567890,
  "database_bytes": 234567890,
  "mailboxes": 42,
  "messages": 128431,
  "users": 17,
  "domains": 3,
  "disk_total_bytes": 107374182400,
  "disk_free_bytes": 64424509440
}

users, domains, mailboxes and messages are counts, not sizes — and mailboxes counts addresses, matching the schema. A figure a deployment cannot report is omitted from the object rather than sent as 0.

4.7 First-run setup

GET /api/v1/setup{ "required": true } while no admin exists. POST /api/v1/setup{email, password, hostname, domain} creates the first admin, the domain and its primary address, then returns a normal token pair. Both endpoints return 409 conflict once an admin exists; they are disabled entirely by api.enable_setup_wizard = false.

4.8 System logs and devices

The Admin panel's "System Logs" and "Devices" screens (specification §36) need a management-side view; the client-API device routes are bearer-only and scoped to one account.

GET /api/v1/logs

{
  "items": [
    { "at": "2026-09-16T12:00:00Z", "level": "warn", "target": "ferroma_smtp::client",
      "message": "delivery deferred: 421 too many connections", "fields": { "queue_id": 91, "remote_mx": "mx1.example.net" } }
  ],
  "total": 1, "limit": 100, "offset": 0
}

Backed by a bounded in-process ring buffer that a tracing layer fills with events at WARN and above (configurable down to INFO with ?level=info), so the panel works without shipping logs off the host. The buffer holds the most recent 1000 entries and is lost on restart — that is the honest trade, and the response says so with "buffer_entries", "buffer_capacity" and "oldest_at". Filters: ?level=, ?target=, ?query=, ?since=, ?limit=, ?offset=.

Message bodies, credentials and tokens are never written to this buffer; the log layer scrubs values that look like opaque tokens (rt_…, st_…) before storing them.

GET /api/v1/devices — every device on the server, newest activity first:

{
  "items": [
    { "id": 12, "user_id": 7, "email": "[email protected]", "device_uid": "3f2c…",
      "name": "Alice's laptop", "platform": "windows", "client_version": "0.7.0",
      "protocol_version": 1, "last_seen_at": "2026-09-16T12:00:00Z",
      "last_ip": "203.0.113.44", "created_at": "2026-08-01T10:00:00Z", "revoked": false }
  ],
  "total": 1, "limit": 50, "offset": 0
}

Filters: ?user_id=, ?include_revoked=, ?platform=. POST /api/v1/devices/:id/revoke and DELETE /api/v1/devices/:id behave exactly as the client-API equivalents: the device is marked revoked, every session it holds is revoked, and device.revoked is published.


5. Mailboxes, messages and attachments

5.1 Mailboxes and folders

MethodPathNotes
GET/api/v1/mailboxesthe caller's addresses
GET/api/v1/mailboxes/:id/foldersIMAP folders with message_count, unseen_count, special_use
POST/api/v1/mailboxes/:id/folders{name, parent?}
PATCH/api/v1/folders/:id{name?, subscribed?}
DELETE/api/v1/folders/:idrefuses INBOX

5.2 Messages

MethodPathNotes
GET/api/v1/messages?mailbox_id=&folder_id=&query=&unread=&flagged=&has_attachments=&since=&before=&limit=&offset=
GET/api/v1/messages/:idfull message: headers, text body, html body, attachment metadata
GET/api/v1/messages/:id/rawthe RFC 5322 bytes, message/rfc822
POST/api/v1/messagessend. {from, to[], cc[]?, bcc[]?, subject, text?, html?, attachments[]?, in_reply_to?, references[], draft_id?}
PATCH/api/v1/messages/:id{seen?, flagged?, answered?, deleted?}
POST/api/v1/messages/:id/move{folder_id}
POST/api/v1/messages/:id/copy{folder_id}
DELETE/api/v1/messages/:idmove to Trash; ?permanent=true removes it
POST/api/v1/messages/batch{operation: "read"|"unread"|"flag"|"unflag"|"move"|"delete", ids: [ … ], folder_id?}

Sending queues one mail_queue row per recipient and returns immediately:

{ "message_id": 4821, "queued": 2, "recipients": ["[email protected]", "[email protected]"] }

413 limit_exceeded when the message exceeds limits.max_message_size, 429 rate_limited beyond submission_rate_limit per hour or daily_send_limit per day.

POST /api/v1/messages also accepts draft: true, which files the message in the sender's Drafts folder instead of queueing it. A draft needs no to — that is the normal case for "save and come back to it".

The single-message shape

GET /api/v1/messages/:id returns everything a reader needs, including the headers a reply must carry:

{
  "id": 4821,
  "uid": 117,
  "folder_id": 5,
  "mailbox_id": 3,
  "subject": "Invoice for September",
  "from": { "address": "[email protected]", "name": "Bob" },
  "to": [ { "address": "[email protected]", "name": "Alice" } ],
  "cc": [],
  "reply_to": [],
  "flags": "seen",
  "size_bytes": 24831,
  "snippet": "Hi Alice, attached is the invoice…",
  "text_body": "Hi Alice,\n\nattached is the invoice for September.\n",
  "html_body": "<p>Hi Alice,</p><p>attached is the invoice for September.</p>",
  "message_id_header": "<[email protected]>",
  "in_reply_to": "<[email protected]>",
  "references": ["<[email protected]>", "<[email protected]>"],
  "internal_date": "2026-09-16T09:12:44Z",
  "sent_at": "2026-09-16T09:12:31Z",
  "is_draft": false,
  "has_attachments": true,
  "attachment_count": 1,
  "attachments": [
    { "id": 991, "filename": "invoice-2026-09.pdf", "content_type": "application/pdf", "size_bytes": 24831, "is_inline": false, "content_id": null }
  ]
}

5.3 Drafts

Drafts are server-side so the same draft appears on every device the user signs in from. The management surface mirrors the client one (fcp.md §7) and both address the same records.

MethodPathNotes
GET/api/v1/drafts?limit=&offset=
POST/api/v1/drafts{mailbox_id?, subject?, text?, html?, to?, cc?, bcc?, in_reply_to?, references?, attachment_ids?}
GET/api/v1/drafts/:id
PATCH/api/v1/drafts/:idany subset of the create fields
DELETE/api/v1/drafts/:id

A draft is also mirrored into the mailbox's Drafts folder as a real message carrying \Draft, so an IMAP client sees it too; deleting it from either surface removes it from both.

5.4 Attachments

MethodPathNotes
POST/api/v1/attachmentsmultipart/form-data, field name file; streams to the blob store, returns {id, filename, content_type, size_bytes, sha256}
GET/api/v1/attachments/:idstreams the bytes, supports Range and ETag
DELETE/api/v1/attachments/:idonly while still unreferenced
GET/api/v1/attachments/:id/metametadata without the bytes

Uploads above client.attachment_chunk_size should use the chunked endpoints in fcp.md §6. GET /api/v1/client/attachments/:id/status — the endpoint a resuming client polls — answers:

{ "attachment_id": 991, "size_bytes": 4194304, "chunk_size": 1048576,
  "chunk_count": 4, "received": [0, 1, 3], "complete": false }

received is the list of chunk indexes the server holds, so a client that crashed mid-upload sends only the gap.


6. Client API (FCP)

The official clients use this surface exclusively. Full protocol semantics — including the sync cursor, realtime framing and chunked uploads — are in fcp.md; this is the endpoint index from specification §19.

MethodPath
POST/api/v1/client/auth/login
POST/api/v1/client/auth/refresh
POST/api/v1/client/auth/logout
GET/api/v1/client/account
GET/api/v1/client/mailboxes
GET/api/v1/client/sync
GET/api/v1/client/messages
GET/api/v1/client/messages/:id
POST/api/v1/client/messages
PATCH/api/v1/client/messages/:id
DELETE/api/v1/client/messages/:id
POST/api/v1/client/messages/:id/read
POST/api/v1/client/messages/:id/unread
POST/api/v1/client/messages/:id/star
POST/api/v1/client/messages/:id/archive
POST/api/v1/client/messages/:id/move
POST/api/v1/client/messages/:id/trash
GET/POST/api/v1/client/drafts
PATCH/DELETE/api/v1/client/drafts/:id
GET/POST/api/v1/client/attachments, /api/v1/client/attachments/:id
GET/api/v1/client/devices
DELETE/api/v1/client/devices/:id
POST/api/v1/client/devices/:id/revoke
GET/api/v1/client/events (WebSocket upgrade)

Client requests declare themselves, and the server uses this to gate compatibility (specification §56):

X-Ferroma-Client: FerromaClient/0.7.0
X-Ferroma-Protocol: 1
X-Ferroma-Platform: windows
User-Agent: FerromaClient/0.7.0 (Windows 11; x86_64)

A client whose X-Ferroma-Protocol is below client.min_protocol_version receives 426 Upgrade Required with { "error": { "code": "unsupported", "message": "client protocol 0 is no longer supported; upgrade to FCP/1" } }.


7. Sending an email, end to end

The flow the Webmail UI and the official client both follow:

  1. POST /api/v1/auth/login (or the client equivalent) → tokens.
  2. GET /api/v1/mailboxes → the addresses the user may send from.
  3. POST /api/v1/attachments for each file; collect the ids.
  4. POST /api/v1/messages with from, to, subject, text/html and the
    attachment ids. The server writes the message into the sender's Sent folder,
    enqueues one mail_queue row per recipient, publishes mail.sent, and returns
    {message_id, queued, recipients}.
  5. GET /api/v1/queue?status=retry,failed (or the client's own Outbox view) to
    watch delivery; the server pushes delivery.updated events over the socket as
    each attempt completes.
Ferroma · MIT OR Apache-2.0 · built from docs/