Ferroma
协议 · 文档

Ferroma HTTP API

两类使用者共用一个路由:

接口面基础路径使用者
管理 API/api/v1Webmail、Admin 面板、脚本
客户端 API(FCP)/api/v1/client官方桌面客户端

两者都是基于 HTTP/1.1 与 HTTP/2 的纯 JSON。TLS 由 Ferroma 自身终止 (api.tls_port),或由前置的反向代理终止。

本页的所有内容都由ferroma-api实现;客户端接口面的协议级细节 (版本、游标、实时帧格式)见 fcp.md


1. 约定

1.1 内容类型与编码

除非另有说明,请求与响应都是application/json; charset=utf-8。附件以 application/octet-stream流式传输,其真实Content-Type放在元数据中。字段名是 snake_case

1.2 认证

接口面机制
管理 APIAuthorization: Bearer <access_token>ferroma_session cookie(Webmail)
客户端 APIAuthorization: Bearer <access_token>

访问令牌是由POST /auth/login签发的 HS256 JWT。它们是短时效的 (api.access_token_ttl_secs,默认一小时);同时获得的刷新令牌用来签发新的访问令牌。 刷新令牌是一次性的:刷新会轮换它们并使先前的值失效。

Admin 端点额外要求用户具有is_admin

1.3 错误

每一次失败都返回同一个信封:

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

code稳定且机器可读(它字面上就是 FerromaError::code());message供人阅读,可能变化。details是可选的, 仅当确有结构化内容要表达时才出现。

HTTPcode含义
400invalid_input, parse_error, protocol_error请求格式错误
401unauthorized凭据缺失、过期或已吊销
403forbidden已认证但无权(例如不是管理员)
404not_found没有该邮件、邮箱、草稿或设备
409conflict唯一性或状态冲突;原始请求仍在处理中的操作;比保留历史更旧的同步游标
413limit_exceeded, mailbox_full邮件过大、收件人过多,或收件人邮箱已满
426unsupported客户端的X-Ferroma-Protocol低于client.min_protocol_version;需要升级
429rate_limited放慢速度;遵循Retry-After
500storage_error, internal_error程序缺陷或数据库故障
501unsupported已规定但尚未实现的能力,且协议升级也无济于事
502dns_error, network_error, timeout上游失败

对于客户端已经完成的操作,它看到的不是409。见 §1.5。

1.4 分页

列表端点接受limit(默认 50,最大 500)与offset,并返回:

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

增量同步使用分页;它使用游标(§5)。

1.5 幂等

改变状态的请求接受幂等键:管理接口面上是Idempotency-Key头字段, 客户端 API 上是请求体中的operation_idPOSTPATCHDELETE都接受它。

服务器记录该操作,并在重复请求时重放原始响应 (相同的状态码、相同的响应体),因此超时后重试的客户端不会重复发送 或重复删除。幂等键保留时长为client.tombstone_retention_days

首次  POST /api/v1/client/messages  {"operation_id":"op_9f2c…", …}  -> 201 {"message_id":4821,…}
重试  POST /api/v1/client/messages  {"operation_id":"op_9f2c…", …}  -> 201 {"message_id":4821,…}

有三种结果值得区分,因为客户端必须对它们作出不同的反应:

情形响应客户端应做什么
幂等键是新的操作照常执行正常处理
幂等键已经完成记录下来的响应,原样重放视为成功;不要重试
幂等键的原始请求仍在运行(或其进程在请求中途退出)409 conflict"operation … has not finished; retry later"稍后用同一个幂等键重试

资源已经不存在之后才重试的DELETE会应答404;正在清空排队删除的客户端 必须把它视为成功,因为期望的终态已经成立。正是这一点让DELETE 无需单独的墓碑 API 也安全。

1.6 限流

429响应携带以秒为单位的Retry-After。提交与登录按账号限流; API 按令牌限流。

1.7 时间戳

UTC 下的 RFC 3339 / ISO 8601,例如2026-09-16T12:00:00Z。除 operation_idop_…)与device_uid(客户端生成的字符串)之外,ID 都是整数。


2. 健康检查与发现

GET /api/v1/health

无需认证。驱动容器健康检查。

{
  "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统计存活的 Webmail/API/客户端会话(sessions中 既未吊销也未过期的行),active_devices统计未吊销的 devices行。queue.received_todaysent_today覆盖当前 UTC 日。 这四项都供 Admin 看板使用,看板必须把缺失的数字渲染为,而不是 编造出来的零。

数据库不可达时返回503"status": "degraded";此时 queueclients块被省略,而不是报告为零。

GET /api/v1/version

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

GET /.well-known/ferroma

无需认证的自动发现(项目书 §32)。客户端从用户所输入地址的 域名处获取它。

{
  "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.txtGET /api/v1/domains/:id/dns

见 §4.4。


3. 认证(管理接口面)

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 }
}

device_name缺失(浏览器流程)时设置ferroma_sessioncookie。 凭据错误返回401 unauthorized;失败达到 limits.max_failed_logins次后返回429 rate_limited,账号被锁定 limits.login_lockout_secs

POST /api/v1/auth/refresh

{ "refresh_token": "rt_…" } → 一对全新的令牌。被出示的刷新令牌 随即失效。重复使用刷新令牌会吊销整个令牌族并返回401

POST /api/v1/auth/logout

吊销当前会话。204 No Content

GET /api/v1/auth/me

已认证的用户本身,外加其地址:

{
  "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": "…" }。吊销其它所有会话。


4. 管理

仅限 Admin。普通用户得到403 forbidden

4.1 用户

方法路径说明
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/:iddisplay_nameenabledis_adminquota_bytespassword中的任意一项
DELETE/api/v1/users/:id级联:地址、文件夹、邮件、队列行
GET/api/v1/users/:id/mailboxes该用户拥有的地址
POST/api/v1/users/:id/mailboxes{domain, local_part, is_primary?, quota_bytes?},创建 Maildir 与标准文件夹

4.2 域名

方法路径说明
GET/api/v1/domains
POST/api/v1/domains{name, description?}
GET/api/v1/domains/:id
PATCH/api/v1/domains/:idenableddescriptioncatch_all
DELETE/api/v1/domains/:id在仍有地址存在时拒绝执行,除非?force=true

4.3 别名

方法路径
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 诊断

GET /api/v1/domains/:id/dns执行 Admin「DNS Health」 面板背后的实时检查(项目书 §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取值为okwarnfailskip

GET /api/v1/domains/:id/dkim返回要发布的 DNS 记录:

{ "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在不存在密钥对时生成一对。

4.5 邮件队列与投递日志

方法路径说明
GET/api/v1/queue?status=pending|delivering|delivered|retry|failed|cancelled&limit=&offset=
GET/api/v1/queue/:id一条记录外加它的尝试历史
POST/api/v1/queue/:id/retry立即重新入队一条失败的记录
DELETE/api/v1/queue/:id取消
GET/api/v1/queue/stats各状态的计数,外加next_due_at

4.6 存储、审计与设置

方法路径说明
GET/api/v1/storage见下面的结构
POST/api/v1/storage/gc清除无人引用的附件二进制对象与陈旧的tmp/文件
GET/api/v1/audit?actor_user_id=&action=&since=&limit=&offset=
GET/api/v1/settings由数据库支撑的设置
PUT/api/v1/settings/:key{ "value": … }

GET /api/v1/storage服务 Admin 的「Storage」界面与看板卡片:

{
  "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
}

usersdomainsmailboxesmessages是计数而非容量,而且 mailboxes统计的是地址,与 schema 一致。某部署无法报告的 数字会从对象中省略,而不是发送0

4.7 首次运行设置

GET /api/v1/setup{ "required": true }(在尚无管理员存在期间)。 POST /api/v1/setup{email, password, hostname, domain}创建第一个 管理员、域名及其主地址,然后返回一对普通令牌。管理员一旦存在,两个 端点都返回409 conflict;把api.enable_setup_wizard = false会完全禁用它们。

4.8 系统日志与设备

Admin 面板的「System Logs」与「Devices」界面(项目书 §36)需要 一个管理侧的视图;客户端 API 的设备路由只接受 Bearer 认证,且范围限于一个 账号。

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
}

它由进程内有界的环形缓冲区支撑,tracing层把事件写入其中, 级别为WARN及以上(可用?level=info下调到INFO),因此该面板 无需把日志运出主机即可工作。缓冲区保存最近 1000 条记录,并且在重启时丢失:这是诚实的取舍,响应通过 "buffer_entries""buffer_capacity""oldest_at"如实说明。过滤器: ?level=?target=?query=?since=?limit=?offset=

邮件正文、凭据与令牌永远不会写入该缓冲区;日志 层在存储之前会擦除看起来像不透明令牌的值(rt_…st_…)。

GET /api/v1/devices:服务器上的每一台设备,最近活动在前:

{
  "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
}

过滤器:?user_id=?include_revoked=?platform=POST /api/v1/devices/:id/revokeDELETE /api/v1/devices/:id的行为与 客户端 API 中的对应端点完全一致:设备被标记为已吊销,它持有的每个会话都被 吊销,并发布device.revoked


5. 邮箱、邮件与附件

5.1 邮箱与文件夹

方法路径说明
GET/api/v1/mailboxes调用者的地址
GET/api/v1/mailboxes/:id/foldersmessage_countunseen_countspecial_use的 IMAP 文件夹
POST/api/v1/mailboxes/:id/folders{name, parent?}
PATCH/api/v1/folders/:id{name?, subscribed?}
DELETE/api/v1/folders/:id拒绝INBOX

5.2 邮件

方法路径说明
GET/api/v1/messages?mailbox_id=&folder_id=&query=&unread=&flagged=&has_attachments=&since=&before=&limit=&offset=
GET/api/v1/messages/:id完整邮件:头字段、纯文本正文、HTML 正文、附件元数据
GET/api/v1/messages/:id/rawRFC 5322 字节,message/rfc822
POST/api/v1/messages发送。{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移入 Trash;带?permanent=true则彻底删除
POST/api/v1/messages/batch{operation: "read"|"unread"|"flag"|"unflag"|"move"|"delete", ids: [ … ], folder_id?}

发送会为每个收件人排队一条mail_queue行,并立即返回:

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

邮件超过limits.max_message_size时返回413 limit_exceeded, 每小时超过submission_rate_limit或每天超过daily_send_limit时 返回429 rate_limited

POST /api/v1/messages还接受draft: true,这会把邮件归档到 发件人的 Drafts 文件夹而不是入队。草稿不需要to, 这正是「先保存、回头再处理」的常见情形。

单封邮件的结构

GET /api/v1/messages/:id返回阅读者需要的一切,包括 回复必须携带的头字段:

{
  "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 草稿

草稿保存在服务端,因此同一份草稿会出现在用户登录的每一台设备上。 管理接口面与客户端接口面(fcp.md §7)彼此对应,两者操作的是同一批 记录。

方法路径说明
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创建字段的任意子集
DELETE/api/v1/drafts/:id

草稿也会作为一封携带\Draft的真实邮件镜像到邮箱的Drafts文件夹, 因此 IMAP 客户端也能看到它;从任一面删除它都会同时从两者中移除。

5.4 附件

方法路径说明
POST/api/v1/attachmentsmultipart/form-data,字段名file;流入二进制存储,返回{id, filename, content_type, size_bytes, sha256}
GET/api/v1/attachments/:id流式返回字节,支持RangeETag
DELETE/api/v1/attachments/:id仅在仍无人引用时可用
GET/api/v1/attachments/:id/meta不含字节的元数据

超过client.attachment_chunk_size的上传应使用 fcp.md §6 中的分块端点。GET /api/v1/client/attachments/:id/status(续传 客户端轮询的端点)应答:

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

received是服务器持有的分块索引列表,因此在上传中途 崩溃的客户端只需补发缺口部分。


6. 客户端 API(FCP)

官方客户端只使用这个接口面。完整的协议语义 (包括同步游标、实时帧格式与分块上传)见 fcp.md;这里是项目书 §19 给出的端点索引。

方法路径
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 升级)

客户端请求会表明自己的身份,服务器据此门控兼容性 (项目书 §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)

X-Ferroma-Protocol低于client.min_protocol_version的客户端会收到 426 Upgrade Required,并附带{ "error": { "code": "unsupported", "message": "client protocol 0 is no longer supported; upgrade to FCP/1" } }


7. 端到端发送一封邮件

Webmail UI 与官方客户端都遵循的流程:

  1. POST /api/v1/auth/login(或客户端中的对应端点)→ 令牌。
  2. GET /api/v1/mailboxes → 用户可以用来发信的地址。
  3. 对每个文件执行POST /api/v1/attachments;收集返回的 id。
  4. fromtosubjecttext/html与附件 id 调用
    POST /api/v1/messages。服务器把邮件写入发件人的 Sent 文件夹,
    为每个收件人入队一条mail_queue行,发布mail.sent,并返回
    {message_id, queued, recipients}
  5. GET /api/v1/queue?status=retry,failed(或客户端自己的发件箱(Outbox)视图)
    观察投递;每次尝试完成时,服务器通过 socket 推送delivery.updated事件。
Ferroma · MIT OR Apache-2.0 · 由 docs/ 生成