Ferroma HTTP API
Two audiences share one router:
| Surface | Base path | Who uses it |
|---|---|---|
| Management API | /api/v1 | Webmail, the Admin panel, scripts |
| Client API (FCP) | /api/v1/client | the 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
| Surface | Mechanism |
|---|---|
| Management API | Authorization: Bearer <access_token> or a ferroma_session cookie (Webmail) |
| Client API | Authorization: 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.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_input, parse_error, protocol_error | the request is malformed |
| 401 | unauthorized | missing, expired or revoked credentials |
| 403 | forbidden | authenticated but not allowed (e.g. not an admin) |
| 404 | not_found | no such message, mailbox, draft, device |
| 409 | conflict | uniqueness or state violation; an operation whose original request is still in flight; a sync cursor older than the retained history |
| 413 | limit_exceeded, mailbox_full | message too large, too many recipients, or the recipient's mailbox is full |
| 426 | unsupported | the client's X-Ferroma-Protocol is below client.min_protocol_version; upgrade required |
| 429 | rate_limited | slow down; honour Retry-After |
| 500 | storage_error, internal_error | a bug or a database failure |
| 501 | unsupported | a specified-but-unimplemented capability, where no protocol upgrade would help |
| 502 | dns_error, network_error, timeout | an 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:
| Situation | Response | What the client does |
|---|---|---|
| The key is new | the operation runs | normal handling |
| The key was already completed | the recorded response, replayed verbatim | treat 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
| Method | Path | Notes |
|---|---|---|
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/:id | any of display_name, enabled, is_admin, quota_bytes, password |
DELETE | /api/v1/users/:id | cascades: addresses, folders, messages, queue rows |
GET | /api/v1/users/:id/mailboxes | addresses 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
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/domains | |
POST | /api/v1/domains | {name, description?} |
GET | /api/v1/domains/:id | |
PATCH | /api/v1/domains/:id | enabled, description, catch_all |
DELETE | /api/v1/domains/:id | refuses while addresses exist unless ?force=true |
4.3 Aliases
| Method | Path |
|---|---|
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
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/queue | ?status=pending|delivering|delivered|retry|failed|cancelled&limit=&offset= |
GET | /api/v1/queue/:id | one entry plus its attempt history |
POST | /api/v1/queue/:id/retry | requeue a failed entry now |
DELETE | /api/v1/queue/:id | cancel |
GET | /api/v1/queue/stats | counts per status, plus next_due_at |
4.6 Storage, audit and settings
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/storage | see the shape below |
POST | /api/v1/storage/gc | drops unreferenced attachment blobs and stale tmp/ files |
GET | /api/v1/audit | ?actor_user_id=&action=&since=&limit=&offset= |
GET | /api/v1/settings | DB-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
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/mailboxes | the caller's addresses |
GET | /api/v1/mailboxes/:id/folders | IMAP 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/:id | refuses INBOX |
5.2 Messages
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/messages | ?mailbox_id=&folder_id=&query=&unread=&flagged=&has_attachments=&since=&before=&limit=&offset= |
GET | /api/v1/messages/:id | full message: headers, text body, html body, attachment metadata |
GET | /api/v1/messages/:id/raw | the RFC 5322 bytes, message/rfc822 |
POST | /api/v1/messages | send. {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/:id | move 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 }
]
}message_id_headeris the RFC 5322Message-IDof this message. A reply needs it
asin_reply_to, andreferencesis this message'sreferenceswithmessage_id_headerappended. A client that does not receivemessage_id_headermust send the reply without threading headers rather than
inventing a value.html_bodyis sanitised server-side, unconditionally:<script>,<style>and<iframe>bodies, everyon*handler,javascript:/vbscript:/file:URLs and
non-imagedata:URLs are removed before the body is returned. There is
deliberately no configuration switch — a setting that turns off HTML sanitisation
is a setting that turns a mail client into a remote code execution vector. The
sanitiser only ever removes, never rewrites, so it cannot introduce markup; it is
a filter rather than a full parser, which is why a client must still render the
result inside a sandbox.- A message the caller does not own is a
404, never a403— the API does not
confirm that someone else's message exists.
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.
| Method | Path | Notes |
|---|---|---|
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/:id | any 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
| Method | Path | Notes |
|---|---|---|
POST | /api/v1/attachments | multipart/form-data, field name file; streams to the blob store, returns {id, filename, content_type, size_bytes, sha256} |
GET | /api/v1/attachments/:id | streams the bytes, supports Range and ETag |
DELETE | /api/v1/attachments/:id | only while still unreferenced |
GET | /api/v1/attachments/:id/meta | metadata 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.
| Method | Path |
|---|---|
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:
POST /api/v1/auth/login(or the client equivalent) → tokens.GET /api/v1/mailboxes→ the addresses the user may send from.POST /api/v1/attachmentsfor each file; collect the ids.POST /api/v1/messageswithfrom,to,subject,text/htmland the
attachment ids. The server writes the message into the sender's Sent folder,
enqueues onemail_queuerow per recipient, publishesmail.sent, and returns{message_id, queued, recipients}.GET /api/v1/queue?status=retry,failed(or the client's own Outbox view) to
watch delivery; the server pushesdelivery.updatedevents over the socket as
each attempt completes.