Ferroma HTTP API
两类使用者共用一个路由:
| 接口面 | 基础路径 | 使用者 |
|---|---|---|
| 管理 API | /api/v1 | Webmail、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 认证
| 接口面 | 机制 |
|---|---|
| 管理 API | Authorization: Bearer <access_token>或ferroma_session cookie(Webmail) |
| 客户端 API | 仅Authorization: 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是可选的, 仅当确有结构化内容要表达时才出现。
| HTTP | code | 含义 |
|---|---|---|
| 400 | invalid_input, parse_error, protocol_error | 请求格式错误 |
| 401 | unauthorized | 凭据缺失、过期或已吊销 |
| 403 | forbidden | 已认证但无权(例如不是管理员) |
| 404 | not_found | 没有该邮件、邮箱、草稿或设备 |
| 409 | conflict | 唯一性或状态冲突;原始请求仍在处理中的操作;比保留历史更旧的同步游标 |
| 413 | limit_exceeded, mailbox_full | 邮件过大、收件人过多,或收件人邮箱已满 |
| 426 | unsupported | 客户端的X-Ferroma-Protocol低于client.min_protocol_version;需要升级 |
| 429 | rate_limited | 放慢速度;遵循Retry-After |
| 500 | storage_error, internal_error | 程序缺陷或数据库故障 |
| 501 | unsupported | 已规定但尚未实现的能力,且协议升级也无济于事 |
| 502 | dns_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_id。POST、PATCH 与DELETE都接受它。
服务器记录该操作,并在重复请求时重放原始响应 (相同的状态码、相同的响应体),因此超时后重试的客户端不会重复发送 或重复删除。幂等键保留时长为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_id(op_…)与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_today与sent_today覆盖当前 UTC 日。 这四项都供 Admin 看板使用,看板必须把缺失的数字渲染为—,而不是 编造出来的零。
数据库不可达时返回503与"status": "degraded";此时 queue与clients块被省略,而不是报告为零。
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.txt与GET /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/:id | display_name、enabled、is_admin、quota_bytes、password中的任意一项 |
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/:id | enabled、description、catch_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取值为ok、warn、fail或skip。
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
}users、domains、mailboxes与messages是计数而非容量,而且 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/revoke与DELETE /api/v1/devices/:id的行为与 客户端 API 中的对应端点完全一致:设备被标记为已吊销,它持有的每个会话都被 吊销,并发布device.revoked。
5. 邮箱、邮件与附件
5.1 邮箱与文件夹
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /api/v1/mailboxes | 调用者的地址 |
GET | /api/v1/mailboxes/:id/folders | 带message_count、unseen_count、special_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/raw | RFC 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 }
]
}message_id_header是本邮件的 RFC 5322Message-ID。回复需要把它
作为in_reply_to,而references是本邮件的references
追加message_id_header后的结果。没有收到message_id_header的客户端发送回复时必须不带串接头字段,而不是
自己编造一个值。html_body在服务端无条件净化:<script>、<style>与<iframe>的内容体、每一个on*处理器、javascript:/vbscript:/file:URL 以及
非图片的data:URL 都会在正文返回之前被移除。这里
刻意不提供配置开关:一个能关闭 HTML 净化的设置,
就是一个把邮件客户端变成远程代码执行载体的设置。
净化器只做*移除*,从不改写,因此它无法引入标记;它是
过滤器而不是完整的解析器,所以客户端仍必须在
沙箱中渲染结果。- 调用者不拥有的邮件是
404,绝不是403:API 不会
确认他人的邮件是否存在。
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/attachments | multipart/form-data,字段名file;流入二进制存储,返回{id, filename, content_type, size_bytes, sha256} |
GET | /api/v1/attachments/:id | 流式返回字节,支持Range与ETag |
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 与官方客户端都遵循的流程:
POST /api/v1/auth/login(或客户端中的对应端点)→ 令牌。GET /api/v1/mailboxes→ 用户可以用来发信的地址。- 对每个文件执行
POST /api/v1/attachments;收集返回的 id。 - 带
from、to、subject、text/html与附件 id 调用POST /api/v1/messages。服务器把邮件写入发件人的 Sent 文件夹,
为每个收件人入队一条mail_queue行,发布mail.sent,并返回{message_id, queued, recipients}。 - 用
GET /api/v1/queue?status=retry,failed(或客户端自己的发件箱(Outbox)视图)
观察投递;每次尝试完成时,服务器通过 socket 推送delivery.updated事件。