Ferroma
客户端 · 文档

官方 Ferroma 客户端

读者对象:实现client/的所有人、需要判断某个功能该属于桌面客户端还是服务端 的所有人,以及所有测试多设备行为的人。

Ferroma Client 是面向 Windows、Linux 与 macOS 的官方桌面应用(Android 与 iOS 属于第二期)。它由平台无关的共享核心加一层薄薄的外壳组成,因为同一套账户、 同步引擎、缓存、发件箱与搜索必须在三个平台上表现一致,此后还要再加两个。本文档 规定项目书 §24 与 §25 的共享核心架构、§25 的目录职责、§26 的 SQLite 缓存模式与 Server = Source of Truth规则、同步引擎及其待处理操作队列、离线模式(§27)、 含八个状态的发件箱状态机(§28)、附件缓存与流式传输(§29)、本地搜索与服务端 兜底(§30)、多账户(§31)、基于.well-known/ferroma的自动发现(§32)、设备 管理(§33)、通知(§34,含 APNs/FCM 预留)、设置界面(§52)以及 UI 方案(§51)。

状态:设计规范。ferroma-client crate 仍是骨架:client/src/lib.rs只声明了 模块清单,client/src/main.rs是个占位实现,而Cargo.toml已经引入了设计所需的 依赖(带sqlite feature 的sqlxreqwesttokio-tungsteniteclapdirs)。因此下面每个模块都标注了_(计划中)_,§3 中的 SQLite 模式是一份 规范,不是已有的迁移。它使用的协议冻结在fcp.mdapi.md,而 契约的服务端只在ferroma-api已实现的范围内实现了这两者,而它同样尚未实现。


1. 范围与同 IMAP 的关系

项目书 §53 划定了这条线,fcp.md §1 重复了一遍:

                    Ferroma
                       │
          ┌────────────┼────────────┐
          ▼            ▼            ▼
      Client API      IMAP         SMTP
          │            │            │
          ▼            ▼            ▼
     官方客户端    Thunderbird   Outlook
                   Apple Mail
客户端协议原因
官方 Ferroma 客户端基于 HTTPS + WebSocket 的 FCP需要带游标的增量同步、服务端草稿、设备管理、分块附件与推送,这些 IMAP 一概无法表达
Thunderbird、Apple Mail、Outlook、iPhone Mail、AndroidIMAP4rev1 + SMTP它们已经存在、能正常工作,Ferroma 不能把它们弄坏(imap.md §11)

官方客户端从不使用 IMAP 或 SMTP。它没有 IMAP 解析器,不打开 143 端口,也不 实现 SMTP 提交。一切都经/api/v1/client

这是一次刻意的收窄:它意味着桌面客户端只需要一种传输、一套认证方案、一套错误词汇 和一种同步模型,也意味着 IMAP 层里的服务端缺陷影响不到它。代价是 FCP 接口必须 完整:只存在于 IMAP 之上的功能,官方客户端就不可能拥有。


2. 架构:共享核心 + UI

项目书 §24.2:

                    Ferroma Client
                          │
             ┌────────────┴────────────┐
             │                         │
        Shared Core                    UI
             │                         │
      ┌──────┼──────┐          ┌───────┼───────┐
      ▼      ▼      ▼          ▼       ▼       ▼
    Sync    API     DB        Windows  Linux   macOS

值得专门写下来的那条规则:UI 是核心的消费者,绝不参与核心的内部。 UI 不打开 套接字、不持有游标,也不写缓存。它调用核心,并对核心发出的事件作出反应。

   ┌──────────────────────────── UI shell (per platform) ─────────────────────┐
   │  window, folder pane, list, reader, composer, notifications, settings    │
   └────────────────────────────────┬─────────────────────────────────────────┘
                                    │  core API + a stream of state changes
   ┌────────────────────────────────▼─────────────────────────────────────────┐
   │                        ferroma-client core (Rust)                        │
   │                                                                          │
   │   account ── api ── sync ── database ── mail ── draft ── outbox           │
   │                    │                     │                               │
   │                    └── attachment ── search ── notification ── device     │
   │                                                                          │
   │             settings (local + server-mirrored)                           │
   └────────────────────────────────┬─────────────────────────────────────────┘
                                    │
                       ┌────────────┴────────────┐
                       ▼                         ▼
                SQLite cache               HTTPS + WebSocket
                       │                         │
                       └────────────┬────────────┘
                                    ▼
                          Ferroma Server (FCP)

为什么用 Rust 核心,而不是每个平台各写一套原生代码:

性质后果
同步、缓存与发件箱只有一份实现客户端里最难的逻辑只写一次、只测一次,而不是三次
各处 SQLite 模式相同缓存里的 bug 只有一个,迁移路径也只有一条
各处的 FCP 客户端相同协议变更只改一处
平台 UI 可以自由选择Windows、Linux 与 macOS 各得原生外壳;增加移动端外壳不必碰核心
ferroma-core与服务端共用FerromaError、类型化 id、Cursor与地址解析就是同一批类型(architecture.md §2)

核心是一个库(client/src/lib.rs);平台外壳是链接它的独立二进制或独立进程。 项目书 §6.3 的建议是:若项目偏重 Rust 原生客户端就用 Slint,否则用 Tauri + Web UI。这个选择属于 UI 而不属于核心,核心绝不能依赖它。注意当前的 清单:client/Cargo.toml只依赖ferroma-core,不依赖ferroma-storage,也不依赖 sqlx的 PostgreSQL feature。桌面客户端绝不能链接 PostgreSQL 驱动或 Maildir。

状态:核心已经建成,外壳还没有。 §2 以及 §3 到 §13 的全部内容都已交付并通过 测试:330 个测试函数覆盖同步、SQLite 缓存、发件箱、离线队列、本地搜索、附件、 多账户、自动发现与设置,由真实 CLI(ferroma-client)驱动,并在验收运行中对着 真实服务端跑。不存在的只有三栏窗口(§14)。它将要建立其上的接缝是ui.rsClientHandleopenfoldersmessagesopen_messagesendsyncsearchoutbox_countsload_settings/save_settings),外加一个 subscribe()ClientEvent的广播,用于同步进度与新邮件,外壳驱动这些,此外什么 都不拥有。

两条候选工具链都在把这项工作推迟之前在开发主机上实测通过编译,因此可以按优劣 来做选择,而不是按机器支持什么来做选择:

工具链探测结果
Tauri 2.11tauri + tauri-build,WebView2 后端)cargo check约 2 分 25 秒通过;wrytaowebview2-com均可构建
Slint 1.18slint,软件渲染器与 femtovg 渲染器)cargo check约 2 分 18 秒通过;不需要系统 webview

Windows 工具链具备所需的 C++ 生成工具与 Windows SDK,本机操作系统也有 WebView2, 所以 Tauri 可行;但 Slint 完全不依赖系统 webview,这是 §6.3 更倾向的、更 Rust 原生的答案。


3. SQLite 缓存(§26)

3.1 表

项目书 §26 列出了这些表。每张表在服务端都有对应物,而客户端这一份是*缓存*,这一点 决定了哪些列存在、哪些不存在:

服务端对应物是否缓存?
accounts—(客户端独有)本机已配置账户的列表;服务端没有对应物,因为它就是服务端
mailboxesmailboxes + folders已扁平化:客户端希望「文件夹 id → 名称、计数、uid_validity」在一行里
messagesmessages仅元数据:id、flags、subject、sender、日期、大小、has_attachments
message_headersmessage_recipients + 解析出的头部供阅读器使用的完整头字段列表,按需获取
attachmentsattachments元数据,加上字节下载完成后的本地缓存路径
draftsdrafts完整内容:草稿是唯一必须挺过离线的东西
outboxmail_queue(只读镜像)待处理操作与待发送项,各带其operation_id
sync_stateclient_sync_states每账户/邮箱/文件夹的游标
devicesdevices供设置界面使用的设备列表
settingssettings本地 UI 设置,外加服务端取值的一份镜像

另有一张表是项目书 §26 没有列出、但设计需要的:

用途
search_index§30 的本地搜索索引;一张覆盖 subject、sender、recipients 与正文文本的 FTS5 虚拟表

3.2 一份具体模式

_(计划中)_,这是缓存应有的形态,好让同步引擎的操作都是单行 upsert,UI 的查询 都走索引。

-- 每个已配置账户一行。
CREATE TABLE accounts (
    id              INTEGER PRIMARY KEY,
    server_id       INTEGER NOT NULL,              -- users.id on the server
    email           TEXT    NOT NULL UNIQUE,
    display_name    TEXT,
    api_base        TEXT    NOT NULL,              -- https://mail.example.com/api/v1
    access_token    TEXT,                          -- 见 §3.4
    refresh_token   TEXT,
    token_expires_at INTEGER,
    device_uid      TEXT    NOT NULL,              -- 每次安装保持稳定
    device_id       INTEGER,                       -- 服务端 devices.id
    paused          INTEGER NOT NULL DEFAULT 0,
    created_at      INTEGER NOT NULL,
    last_sync_at    INTEGER
);

-- 每个账户的每个文件夹一行。镜像 folders 以及来自
-- GET /api/v1/client/mailboxes 的计数。
CREATE TABLE mailboxes (
    id              INTEGER PRIMARY KEY,
    account_id      INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    server_id       INTEGER NOT NULL,              -- folders.id
    address_id      INTEGER NOT NULL,              -- 服务端的 mailboxes.id
    name            TEXT    NOT NULL,              -- "INBOX", "Archive/2026"
    special_use     TEXT,                          -- "\Sent", "\Drafts", …
    message_count   INTEGER NOT NULL DEFAULT 0,
    unseen_count    INTEGER NOT NULL DEFAULT 0,
    uid_validity    INTEGER NOT NULL DEFAULT 0,
    uid_next        INTEGER NOT NULL DEFAULT 0,
    subscribed      INTEGER NOT NULL DEFAULT 1,
    UNIQUE (account_id, server_id)
);

-- 邮件元数据。不是正文:正文按需获取(§5)。
CREATE TABLE messages (
    id              INTEGER PRIMARY KEY,
    account_id      INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    server_id       INTEGER NOT NULL,              -- messages.id,即 API 的 message_id
    folder_id       INTEGER NOT NULL,              -- 上面的 mailboxes.id
    uid             INTEGER NOT NULL,
    uid_validity    INTEGER NOT NULL,              -- uid 所属的代次
    rfc_message_id  TEXT,
    thread_id       TEXT,
    subject         TEXT,
    sender          TEXT,
    sender_name     TEXT,
    snippet         TEXT,
    flags           TEXT NOT NULL DEFAULT '',      -- "seen,flagged",Flags::to_db_string 形式
    size_bytes      INTEGER NOT NULL DEFAULT 0,
    has_attachments INTEGER NOT NULL DEFAULT 0,
    attachment_count INTEGER NOT NULL DEFAULT 0,
    internal_date   INTEGER NOT NULL,
    sent_at         INTEGER,
    body_cached     INTEGER NOT NULL DEFAULT 0,
    UNIQUE (account_id, server_id)
);

CREATE INDEX messages_folder_date_idx ON messages (folder_id, internal_date DESC);
CREATE INDEX messages_account_idx     ON messages (account_id, internal_date DESC);
CREATE INDEX messages_rfc_id_idx      ON messages (account_id, rfc_message_id)
    WHERE rfc_message_id IS NOT NULL;
CREATE UNIQUE INDEX messages_folder_uid_key ON messages (folder_id, uid);

-- 供阅读器使用的完整头字段列表。随正文一起获取,不随列表获取。
CREATE TABLE message_headers (
    id          INTEGER PRIMARY KEY,
    message_id  INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    ordinal     INTEGER NOT NULL DEFAULT 0,
    name        TEXT    NOT NULL,
    value       TEXT    NOT NULL
);
CREATE INDEX message_headers_message_idx ON message_headers (message_id, ordinal);

-- 附件元数据,加上本地缓存位置。
CREATE TABLE attachments (
    id              INTEGER PRIMARY KEY,
    account_id      INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    server_id       INTEGER NOT NULL,              -- attachments.id
    message_id      INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    filename        TEXT,
    content_type    TEXT,
    size_bytes      INTEGER NOT NULL DEFAULT 0,
    sha256          TEXT,                          -- 服务端的内容寻址地址
    content_id      TEXT,
    is_inline       INTEGER NOT NULL DEFAULT 0,
    cached_path     TEXT,                          -- NULL = 尚未下载
    cached_bytes    INTEGER NOT NULL DEFAULT 0,
    cached_at       INTEGER
);
CREATE INDEX attachments_message_idx ON attachments (message_id);
CREATE INDEX attachments_sha_idx     ON attachments (account_id, sha256);

-- 草稿:完整内容,因为草稿必须挺过离线。
CREATE TABLE drafts (
    id              INTEGER PRIMARY KEY,
    account_id      INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    server_id       INTEGER,                       -- 首次同步前为 NULL
    subject         TEXT,
    body_text       TEXT,
    body_html       TEXT,
    recipients_json TEXT NOT NULL DEFAULT '[]',
    attachments_json TEXT NOT NULL DEFAULT '[]',
    in_reply_to     TEXT,
    references_json TEXT NOT NULL DEFAULT '[]',
    updated_at      INTEGER NOT NULL,
    server_updated_at INTEGER,
    dirty           INTEGER NOT NULL DEFAULT 0    -- 1 = 本地编辑尚未推送
);

-- 发件箱:待处理操作与待发送项(§6、§8)。
CREATE TABLE outbox (
    id              INTEGER PRIMARY KEY,
    account_id      INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    operation_id    TEXT    NOT NULL UNIQUE,       -- 用户操作时生成
    kind            TEXT    NOT NULL,              -- send | mark_read | move | flag | delete | draft_save …
    payload_json    TEXT    NOT NULL,
    state           TEXT    NOT NULL DEFAULT 'pending',
    attempts        INTEGER NOT NULL DEFAULT 0,
    last_error      TEXT,
    next_attempt_at INTEGER,
    created_at      INTEGER NOT NULL,
    updated_at      INTEGER NOT NULL
);
CREATE INDEX outbox_due_idx ON outbox (account_id, next_attempt_at)
    WHERE state IN ('pending', 'retrying');

-- 游标。每账户/邮箱/文件夹一行;folder_id = 0 是账户级,
-- 镜像 client_sync_states 的 COALESCE(folder_id, 0)。
CREATE TABLE sync_state (
    id          INTEGER PRIMARY KEY,
    account_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    mailbox_id  INTEGER NOT NULL,
    folder_id   INTEGER NOT NULL DEFAULT 0,
    cursor      INTEGER NOT NULL DEFAULT 0,
    last_full_sync_at INTEGER,
    UNIQUE (account_id, mailbox_id, folder_id)
);

CREATE TABLE devices (
    id              INTEGER PRIMARY KEY,
    account_id      INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    server_id       INTEGER NOT NULL,
    device_uid      TEXT    NOT NULL,
    name            TEXT,
    platform        TEXT,
    client_version  TEXT,
    last_seen_at    INTEGER,
    revoked         INTEGER NOT NULL DEFAULT 0,
    UNIQUE (account_id, server_id)
);

CREATE TABLE settings (
    account_id  INTEGER NOT NULL DEFAULT 0,         -- 0 = 应用级
    key         TEXT    NOT NULL,
    value       TEXT    NOT NULL,
    PRIMARY KEY (account_id, key)
);

-- 本地搜索(§9)。对用户会搜索的字段建 FTS5。
CREATE VIRTUAL TABLE search_index USING fts5(
    subject, sender, recipients, body,
    content='',                                     -- 无内容:文本由我们自己保存
    tokenize='unicode61'
);

3.3 Server = Source of Truth

项目书 §26 与 §55,在sync.md §2 中进一步展开。把它应用到上面的模式上, 得到四条规则:

  1. 每一行都是服务端所拥有数据的缓存。 server_id才是身份;本地的id是实现
    细节,绝不能当成服务端 id 发给服务端。
  2. sync_state.cursor是客户端唯一权威拥有的东西。 它记录*本次安装*读到了
    哪里,对其他任何设备都没有意义。
  3. outbox行是意图,不是事实。 在服务端确认某个操作之前,这一行只是一个
    愿望。UI 可以展示乐观结果,但绝不能把它呈现为已确认。
  4. drafts.dirty是唯一能在冲突中胜出的本地写入,见 §7。

实际含义是:客户端总能从服务端重建整个数据库。不存在丢失后不可接受的客户端数据, 只有两个例外:游标,丢了要重新同步;发件箱,丢了就丢了用户敲进去的内容。这就是 outbox是最受悉心对待的表的原因。

3.4 令牌存储

项目书 §38 把「Token 安全存储」(secure token storage)列入客户端层的控制项。规则 如下:

平台位置
Windows凭据管理器(windows-credentials / DPAPI)
macOS钥匙串(security框架)
Linux通过平台的 keyring crate 使用 Secret Service(libsecret / gnome-keyring)

不放在 SQLite 里明文保存,也不放在配置文件中。 上面的accounts表有 access_token / refresh_token列,是因为设计需要一个引用它们的位置;在有 keyring 的平台上,它们保存的是一个不透明句柄;在没有 keyring 的平台上,设计必须拒绝保存 刷新令牌,而不是未加保护地写进磁盘。

device_uid不是密钥。它是本次安装的稳定标识,生成一次后一直保留,正是它让设备 吊销能对准正确的安装(fcp.md §9)。


4. 目录职责(§25)

项目书 §25 给出了目录清单与各核心职责。

模块职责绝不能做的事
app/进程生命周期:单实例锁、启动、关闭,以及把核心与 UI 串起来的顶层事件循环包含业务逻辑
account/添加/移除/暂停/重新认证账户;持有每账户的核心上下文直接讲 HTTP,那是api/的职责
api/FCP HTTP 客户端:请求构造、X-Ferroma-*头字段、401时的令牌刷新、WebSocket 连接与重连、重试策略知道邮件是什么
sync/同步引擎:读取sync_state.cursor、分页GET /client/sync、在事务中应用变更、推进游标;检测空洞与uid_validity变化把协议负载解析成本地模型以外的任何东西
mail/邮件模型与对已缓存邮件的操作:列表查询、标志变更、移动、会话聚合实现同步或 HTTP
draft/草稿生命周期:创建、编辑、自动保存、删除;镜像到服务端与 Drafts 文件夹发送
outbox/发件箱状态机(§8):入队、尝试、退避、重试、失败,并与服务端的mail_queue状态对账上传附件,那是attachment/的职责
attachment/内容寻址的本地缓存、带进度的流式下载、分块可续传上传(§7)决定何时需要某个文件,那是mail/或 UI 的职责
search/本地 FTS 索引与查询解析;服务端兜底(§9)成为唯一的搜索路径,兜底存在就是为此
notification/桌面 toast 与角标计数;APNs/FCM 预留(§11)渲染邮件
database/SQLite 连接池、迁移与类型化访问器包含本属于功能模块的查询
device/设备列表、吊销,以及「退出此设备」(§12)
settings/§13 的设置界面,读写settings
ui/平台外壳(§14):窗口、面板、撰写器、对话框打开套接字、持有游标,或直接写缓存

由这张表得出两条不变式:


5. 同步引擎

机制与理由见sync.md;这里讲客户端这一侧的形状。

   ┌────────────────────── sync engine (one task per account) ─────────────────┐
   │                                                                           │
   │   loop:                                                                   │
   │     for each folder of the account:                                       │
   │        cursor = SELECT cursor FROM sync_state WHERE …                      │
   │        page   = api.get_sync(mailbox_id, folder_id, cursor, limit)         │
   │        BEGIN;                                                             │
   │          for change in page.changes: apply(change)     # idempotent         │
   │          UPDATE sync_state SET cursor = page.next_cursor                   │
   │        COMMIT;                                                            │
   │     until not page.has_more                                               │
   │                                                                           │
   │   then: drain the outbox (§8)                                             │
   │   then: wait for a WebSocket frame, a timer, or a UI request to sync      │
   └───────────────────────────────────────────────────────────────────────────┘

客户端义务,照抄自sync.md §4,因为它们很容易做错:

义务被忽略时的后果
在一个事务里按顺序应用每条变更,然后存入游标崩溃后会重新拉取那一页;这无害,*正因为*处理器是幂等的
每个处理器都是幂等的(INSERT … ON CONFLICT、标志用绝对值而不是切换)重放一页会产生错误状态
一直分页直到has_more为 false大账户只同步了一部分,看起来像被截断
检测seq空洞并从0重新同步无声的永久数据丢失
检测uid_validity变化并丢弃该文件夹的 UID 索引服务端重建后会打开错误的邮件
绝不把游标推进到某条应用失败的变更之后那条变更再也不会被投递
遇到/sync返回409 conflict时,丢弃该文件夹缓存并从0同步该文件夹永远无法恢复

邮件正文不在变更流里。 message_created只带 id 与 flags(fcp.md §3 第 4 项);正文按需用GET /api/v1/client/messages/:id…/raw获取。正是这一点让 一个 5 万封邮件邮箱的首次同步成为元数据操作,而不是下载若干 GB。

首次同步会显示进度,办法是把已应用的变更数与GET /api/v1/client/mailboxes 返回的文件夹message_count相比较;这是 UI 渲染出有意义进度条的唯一办法,因为 服务端不会事先报告变更总数。

5.1 待处理操作

离线操作不在同步引擎里排队;它们进入outbox,同步引擎在每次成功同步之后将其排空 (sync.md §10)。这个分离很重要:

把两者混在同一个队列里,会让某一条目到底是「服务端告诉我的」还是「我想让服务端 做的」变得含糊,而这两者的重试策略正好相反。


6. 离线模式(§27)

项目书 §27 列出了离线可用的功能:

查看已同步邮件       view synced mail
本地搜索             local search
查看已缓存附件       view cached attachments
写邮件               compose
保存草稿             save a draft
回复                 reply
删除                 delete
标记已读             mark read
能力离线行为重连之后
查看已同步邮件完全由messages + message_headers + body_cached提供无事可做
查看未缓存的邮件「此邮件离线不可用」,客户端必须这么说,而不是显示一个空的阅读器按需获取
本地搜索search_index,不走服务端不变
查看已缓存附件来自attachment.cached_path不变
查看未缓存附件不提供;下载进入队列下载
撰写/回复全本地;附件引用本地文件由发件箱发送
保存草稿写入draftsdirty = 1推送,并适用 §7 的冲突策略
删除/标记已读/加标志/移动乐观地应用到缓存,每个动作一行outbox记录,带其operation_id在服务端应用;相应变更返回并确认它
发送该邮件是outbox中的一行send;它出现在发件箱视图,而不是已发送上传、接受、入队

不可协商的规则:


7. 冲突

完整论述见sync.md §9。客户端这一侧的看法:

实体策略客户端做什么
标志后写者胜应用传入的message_updated,若自己的待处理标志变更已被确认则丢弃它
文件夹归属后写者胜双向应用message_moved
邮件是否存在服务端绝对优先墓碑即删除,即使正文已经缓存
文件夹列表服务端优先folder_created / folder_deleted应用到缓存
草稿内容后写者胜,并且告知客户端收到conflict.detected时,把本地版本保留为副本,或者提示用户;绝不静默重试
本地设置从不同步窗口大小、主题、缓存预算都留在本地
服务端设置服务端优先GET /client/account重新同步

草稿这一种是用户唯一可能丢失已输入文字的场合,这也是fcp.md §7 规定服务端 要报告它覆盖了什么的原因:

{ "id": 44, "updated_at": "2026-09-16T12:00:01Z",
  "conflict": { "detected": true, "server_updated_at": "2026-09-16T11:59:58Z" } }

忽略该字段的客户端,正在静默丢弃用户写的一段话。最低可接受的行为是把落败的版本 另存为一份本地草稿,并告诉用户发生了这件事;更好的行为是把两者都显示出来,让用户 自己选。


8. 发件箱(§28)

项目书 §28 给出了流程与八个状态。

Compose
   ↓
Local Outbox
   ↓
Uploading
   ↓
Server
   ↓
Mail Queue
   ↓
SMTP Delivery

8.1 八个状态

Draft   Pending   Uploading   Queued   Sending   Sent   Failed   Retrying
状态由谁设置含义转移到
Draft撰写器已撰写,尚未入队发送Pending(用户按下发送),或者草稿被删除
Pending用户已在本地排队,等待同步引擎UploadingFailed
Uploading发件箱附件正在传输(分块、可续传)QueuedRetryingFailed
Queued服务端对POST /client/messages返回的202/200已接受:每个收件人一行mail_queueSendingRetryingFailed
Sending服务端状态服务端正在投递给远端 MX(mail_queue.status = 'delivering'SentRetryingFailed
Sent服务端状态每个收件人都已投递(delivered终态
Retrying服务端状态暂时性失败;服务端会在next_attempt_at再试SendingFailed
Failed服务端状态,或本地校验永久失败或尝试次数耗尽(failed),或者请求被拒(413403终态(用户可以编辑后重发,这会创建新的发件箱行)

前四个由客户端驱动,后四个由服务端驱动。客户端通过应用同步里的delivery.updated 变更,以及在套接字可用时的相应 WebSocket 帧,达到SentRetryingFailed。因为套接字只是优化、游标才是真相来源(fcp.md §8),只听套接字 的客户端会让邮件永远卡在Sending。两条路径必须喂给同一个状态机。

8.2 转移规则

  Draft ──send──► Pending ──pick──► Uploading ──accepted──► Queued
                     │                  │                     │
                     │ reject           │ reject              │ server
                     ▼                  ▼                     ▼
                  Failed            Retrying ◄──────────── Sending
                                       │                     │
                                       └──attempt──────────► │
                                                             ▼
                                                           Sent
                                       │
                                       └──exhausted──► Failed
规则理由
operation_id在行创建时生成,绝不在发送时生成重试必须出示同一个 id,否则它什么也保护不了(sync.md §8.1)
附件在发送之前上传引用服务端不存在的附件 id 的send会失败
已上传的附件 id 存在发件箱行里重启后接着传,而不是重新上传(fcp.md §6)
429以外的4xx对该行是终态413 limit_exceeded不会变得不再成立
429严格遵守Retry-After服务端最清楚自己的限额
5xx与网络错误按指数退避并加抖动而且绝不丢弃该行
超时永远不算确认请求可能已经成功;重试带同一个operation_id,会拿到缓存的响应
只有在确认Sent之后,或者用户删除时,才移除该行「大概发出去了」不能变成「没了」

8.3 发件箱视图

项目书 §28 展示了用户看到的内容:

发件箱

正在发送    2
发送失败    1
已发送    152

它对应Sending/UploadingFailedSentFailed行必须可操作:显示原因 (last_error,有服务端增强状态码时一并显示),提供「编辑并重发」,并且绝不静默 过期。用户以为已经发出、却无声消失的邮件,是这个客户端可能有的最严重故障。


9. 附件(§29)

项目书 §29 列出了这些能力:

流式上传       streaming upload
分块上传       chunked upload
断点续传       resumable transfer
流式下载       streaming download
下载进度       download progress
本地缓存       local cache
缓存清理       cache eviction
文件大小限制   size limit
MIME 类型      MIME type
文件校验       integrity check

9.1 上传

小文件走简单端点;大文件与可续传传输走分块端点。两者都在fcp.md §6 中 规定。

路径何时使用端点
简单文件 ≤ client.attachment_chunk_size(默认 1 MiB,如GET /client/accountlimits所报告)POST /api/v1/client/attachments(multipart)
分块更大的文件,或者续传一次被中断的上传POST …/attachments/initPUT …/attachments/:id/chunk?index=NPOST …/attachments/:id/complete

分块路径的客户端义务:

附件在服务端是内容寻址的,所以共享同一个文件的两封邮件共享同一个二进制对象 (storage.md §6)。客户端可以在本地沿用同样的思路:按sha256缓存, 于是附在三份草稿上的同一个 PDF 在磁盘上只存一份。

9.2 下载与缓存

方面行为
流式GET /api/v1/client/attachments/:id是流式;支持Range,所以中断的下载可以续传
校验ETag就是该二进制对象的 SHA-256(fcp.md §6);客户端校验内容,不匹配就重新下载
进度来自Content-Length与已接收的字节数;Range续传从已知偏移开始
缓存键(account_id, sha256),所以相同的附件在本地自动去重
cached_path客户端数据目录下的文件,除非用户导出过,否则绝不放用户的下载文件夹
淘汰cached_at做 LRU,受最大缓存大小设置约束(§13)。绝不淘汰dirty草稿的附件,也绝不淘汰发件箱行的附件
离线已缓存的附件可以打开;未缓存的提供「在线时下载」
内联图片同受缓存约束,也同受security.md §10.2 的「阻止远程内容」规则约束:带远程 URL 的内联部分不会被获取

大小限制是limits.max_attachment_size(25 MiB)与每封邮件的 limits.max_attachments(50),两者都由服务端在GET /client/account的 limits 块 中报告。撰写器必须在本地、在上传之前就拒绝,好让用户在还在写这封邮件的时候就发现 问题。


10. 本地搜索(§30)

项目书 §30:

from:[email protected]
subject:invoice
attachment:pdf
after:2026-01-01

策略:

先搜索本地缓存      search the local cache first
       ↓
没有结果            no results
       ↓
请求服务器搜索      ask the server

10.1 运算符集合

客户端与服务端必须就查询语言达成一致,否则同一个查询会因为缓存当时有没有答案而 返回不同结果。服务端的集合冻结在fcp.md §10:from:to:subject:body:has:attachmentis:unreadis:flaggedbefore:after:folder:

运算符本地支持说明
from:messages.sender
to:需要收件人进索引;缓存为搜索保存了它们
subject:FTS 列
body:仅对已缓存的正文为是正文未缓存的邮件在本地无法匹配,这正是兜底存在的主要原因
has:attachmentmessages.has_attachments
is:unreadflags不含seen
is:flaggedflagsflagged
before: / after:internal_date
folder:folder_id

10.2 兜底

   user types a query
        │
        ▼
   parse locally (reject an unknown operator with a clear message)
        │
        ▼
   search_index over the cached rows
        │
        ├── results ──► show them, marked "from this device's cache"
        │
        └── no results ──► GET /api/v1/client/search?q=…&mailbox_id=…&limit=50
                                │
                                ├── results ──► show them, marked "from the server"
                                │                 (and offer to cache the bodies)
                                └── offline ──► say so explicitly

让兜底保持诚实的两条规则:

既然服务端能搜索,为什么还要本地优先?因为本地搜索离线可用,也因为对已经同步过的 邮箱它是即时的。服务端搜索存在的意义,是覆盖本地搜索服务不了的情况:从未下载过的 正文,以及已同步窗口之外的邮件。

客户端的索引是无内容的 FTS5,所以客户端还必须同时维护源文本(subject、sender、 recipients、已缓存的正文)。在正文被获取时把它加进索引,在正文缓存被淘汰时把它移出 索引,这正是让索引与用户离线时实际能读到的东西保持一致的办法。


11. 多账户(§31)

项目书 §31:

Accounts
├── Personal   → [email protected]
├── Work       → [email protected]
└── Other      → [email protected]
能力实现
添加账户account/,由自动发现驱动(§12);一行accounts记录
移除账户删除该行;级联删除它的邮件、草稿、发件箱行与已缓存附件。先确认:此操作不可逆,而缓存里可能存着某次failed发送的唯一副本
暂停同步accounts.paused = 1;同步任务停止分页。发件箱仍在排空,因为用户暂停同步并不意味着「别把我已经让你发的信发出去」
重新认证熬过一次刷新仍然存在的401;提示输入密码,并保留缓存与发件箱
编辑账户显示名称、服务端 URL、设备名称
查看同步状态每个文件夹的sync_state.last_full_sync_at,加上发件箱计数
每账户隔离每张表都带account_id,每个查询都按它过滤。一个账户绝不能看到另一个账户的邮件

多账户模型强制带来的两条设计规则:


12. 自动发现(§32)

项目书 §32:https://example.com/.well-known/ferroma。响应结构冻结在 api.md §2:

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

用户输入一个地址时客户端的流程:

   user types [email protected]
        │
        ▼
   1. GET https://example.com/.well-known/ferroma        (只看地址里的域名,
        │                                                  端口固定 443,只用 HTTPS)
        ├── 200 + JSON ──► 用 `api` 当 FCP 基址,记录 `protocol_version`
        │
        ├── 404 ──► Discovery::guessed:按惯例主机名给出
        │           api  = https://mail.example.com/api/v1
        │           imap = imap.example.com:993 (tls)
        │           smtp = mail.example.com:587 (tls)
        │           并标记为「猜测」,由界面请用户确认——不会拿它当发现结果。
        │
        └── 其他(5xx、TLS/传输失败、内容不是 JSON、缺少可用的 `api`)
                    ──► 报错,绝不猜测:猜错就等于把账号指向别人的服务器。
                        此时由手工面板接手(`Discovery::candidate_hosts` 会依次给出
                        mail.<domain>、imap.<domain>、<domain> 供选择)。
        ▼
   2. 手工配置:用户填服务器 URL,用 GET <base>/health 或
      GET <base>/client/account 校验(CLI:`account add … --server <URL>`)。

只有第 1 步会真的发起网络请求,而且只请求裸域一次:它不会再自动去试 mail.<domain>。要把部署放在非 443 端口上(例如 §5.4 末尾那种容器反代),让裸域的 443 把这份 JSON 吐出来即可——文档里的 api 字段本来就会指向真实端口。

规则:

规则理由
地址的域名获取,而不是从区域获取记录就在那里,而且那是用户唯一输入过的部分
只用 HTTPS;纯 HTTP 的发现响应被忽略否则网络上的攻击者可以把每个账户都重定向到自己的服务端
正常校验证书理由同上
未知字段忽略,缺失字段走兜底响应是带版本的,还会继续增长
官方客户端只使用apiimap/smtp条目的存在,是为了让同一条记录也能服务第三方客户端与将来的导入流程
客户端从GET /client/account记录protocol_version决定协商版本的是服务端(fcp.md §1)
地址域名与发现到的服务器不一致时要显示出来,而不是藏起来「你输入的是 example.com,但这台服务器是 mail.other.example」值得一次确认

项目书 §32 还为将来的兼容性预留了 autoconfigautodiscover(Thunderbird 与 Microsoft 的惯例)_(计划中)_;今天两者都没有提供。


13. 设备(§33)

项目书 §33 列出了 API 与字段。端点冻结在fcp.md §9。

操作端点效果
列出GET /api/v1/client/devices该账户的每一次安装,含last_seen_atlast_ipplatformclient_versionprotocol_versionrevoked
吊销POST /api/v1/client/devices/:id/revoke标记为已吊销,吊销它的会话,发布device.revoked
删除DELETE /api/v1/client/devices/:id移除该记录

客户端行为:


14. 通知(§34)

项目书 §34:

Mail Received
      ↓
 Event Bus
      ↓
Notification Service
      ↓
┌─────┴─────┐
▼           ▼
APNs       FCM

14.1 桌面端

平台机制
Windows通过 WinRT 通知 API 发送 toast 通知
Linuxfreedesktop 通知规范(libnotify / DBus)
macOSUNUserNotificationCenter

Event::mail_receivedmail.received)触发,它携带的正好是横幅所需的,不多 不少:

{ "seq": 1842, "type": "mail.received", "mailbox_id": 3, "message_id": 4822,
  "from": "[email protected]", "subject": "Re: Invoice", "snippet": "Thanks, got it." }

事件携带摘要片段,绝不携带正文architecture.md §6),因此 通知无法把邮件内容泄漏进操作系统的通知存储,而那在 Windows 与 macOS 上是持久化且 可搜索的。

规则:

14.2 移动端预留:APNs 与 FCM

项目书 §34 把 APNs 与 FCM 放在「移动端预留」之下。这些设计预留让将来加入移动端不必 改模式:

预留位置
client.push_enabled配置键,默认false;服务端宣告推送能力但不启用它
一个设备令牌列移动端上线时devices增加push_token TEXTpush_platform TEXT;该表已经存在,主键为(user_id, device_uid)
推送负载MailReceived相同的字段:id、fromsubjectsnippet、计数。没有正文
投递路径Event::MailReceived → 通知服务 → APNs/FCM。事件已经是集成点,所以通知服务是唯一的新组件
隐私规则锁屏通知显示发件人与主题。要看到正文需要解锁应用并经 FCP 获取

这些都没有实现。把它写下来的要点在于:Event::MailReceived已经携带了正确的字段, devices也已经有稳定的身份,所以增加推送不需要协议变更,只需要一个服务端发送方与 一个移动客户端。


15. 设置(§52)

项目书 §52 列出了各个分区,以及同步方面的选项。

分区内容
账户添加/移除/暂停/重新认证;每账户的服务端 URL 与设备名称
同步同步什么、同步多少(见下)
通知每账户与每文件夹的开关;免打扰;声音
外观主题(浅色/深色/跟随系统)、密度、字号
阅读默认用 HTML 还是纯文本;阻止远程内容;标记已读的延迟;会话视图
撰写每账户签名;回复引用样式;发送延迟/撤销窗口
附件自动下载策略;下载目录;自动下载的最大附件大小
搜索是否索引正文;是否兜底到服务端
存储缓存预算与缓存位置(见下)
安全令牌存储状态;「退出所有设备」;自动锁定
设备§13 的设备列表,带吊销
关于版本、protocol_version、服务端版本、构建、日志位置

同步设置,来自项目书 §52:

同步全部邮件             sync all mail
仅同步最近 30 天         sync the last 30 days only
仅同步最近 90 天         sync the last 90 days only
附件自动下载             auto-download attachments
仅 Wi-Fi 下载            download over Wi-Fi only
最大缓存大小             maximum cache size

每一项如何落到设计上:

设置实现
同步窗口(全部/30/90 天)分页GET /client/sync时施加的谓词:internal_date落在窗口之外的变更记为已见(因此游标照常推进),但不存为邮件。被跳过邮件的正文永远不会被获取
自动下载附件覆盖「打开时下载」的默认值
仅 Wi-Fi桌面端:连接是否按流量计费;由平台 API 报告
最大缓存大小attachments.cached_path与已缓存正文的淘汰预算(§9.2),按cached_at做 LRU

设置存储:应用级的取值用settingsaccount_id = 0,每账户的取值用真实的 id。 服务端上也存在的取值(client.tombstone_retention_days会影响客户端的行为,但它是 服务端的策略)从服务端读取,并以只读方式显示;客户端若自己发明一套保留策略,就会 无声地偏离。


16. UI 方案(§51)

项目书 §51:

┌─────────────────────────────────────────────────────┐
│ Ferroma                         🔍   ⚙   👤         │
├───────────────┬─────────────────────┬───────────────┤
│               │                     │               │
│ 收件箱        │ 邮件列表            │ 邮件阅读      │
│ 已发送        │                     │               │
│ 草稿          │ ┌─────────────────┐ │               │
│ 垃圾箱        │ │ Alice           │ │               │
│ 回收站        │ │ Invoice         │ │               │
│               │ ├─────────────────┤ │               │
│ 文件夹        │ │ Bob             │ │               │
│               │ │ Meeting         │ │               │
│               │ └─────────────────┘ │               │
│               │                     │               │
└───────────────┴─────────────────────┴───────────────┘

三栏外壳:一条账户与文件夹侧栏、一份邮件列表、一个阅读器,顶部横贯一条工具栏 (搜索、设置、账户)。

面板内容数据来源
侧栏账户;每个账户下是它的文件夹,INBOX在最前,其余按名称排序;special_use标记驱动图标,因此名为Sent Items的文件夹仍被读作已发送mailboxes表(imap.md §4.3)
列表每封邮件一行:发件人、主题、摘要片段、日期、附件回形针、标志;虚拟化,因为一个文件夹可以有 5 万行messages,按internal_date DESC分页
阅读器头字段、纯文本或净化后的 HTML 正文、附件message_headers加上获取到的正文;HTML 经过净化器(security.md §10.2)
撰写器独立窗口或全宽浮层;收件人字段带本地收件人自动补全、主题、正文、附件芯片、发送/存草稿draftsattachments
发件箱§8 的状态机,带项目书 §28 的计数outbox
搜索覆盖在列表之上的浮层,带 §10 的运算符语法search_index,然后是服务端
设置§15 的十二个分区settings

由核心的设计推出的 UI 规则:

平台目标与阶段,来自项目书 §24.1 与 §50:

阶段平台
第一期(v0.7–v0.9)Windows、Linux、macOS
第二期Android、iOS

项目书 §50 的客户端里程碑:v0.7 是最小可用版本(登录、账户、收件箱、阅读、撰写、 回复、转发、删除、已读/未读、附件、基础同步),v0.8 离线、本地缓存、增量同步、 本地搜索、WebSocket,v0.9 多账户、设备管理、推送通知、草稿同步、发件箱。


17. 设计已经隐含的依赖

client/Cargo.toml已经声明了它们,这是关于预期形态最有力的证据:

Crate用途
ferroma-coreFerromaError、类型化 id、Cursor、地址解析,与服务端所用的是同一批类型
tokio异步运行时;每账户一个同步任务
sqlx(features sqliteruntime-tokio-rustlsmigratechrono本地缓存,带真正的迁移
reqwestrustls-tlsjsonstreammultipartFCP HTTP 客户端,含流式下载与 multipart 上传
tokio-tungstenite实时 WebSocket(GET /api/v1/client/events
serde / serde_json线上类型,与fcp.md一致
chrono时间戳;一切都是 UTC
clap客户端二进制自己的 CLI(账户管理、供测试用的无界面同步模式)
dirs缓存与附件存储在平台上的数据目录
sha2校验附件摘要,并为本地缓存去重
uuid生成device_uidoperation_id
tracing / tracing-subscriber与服务端相同的结构化日志,好让 bug 报告能附上客户端日志
base64hmac当某个平台需要构造请求签名时的令牌处理

注意不在其中的东西:没有ferroma-storage,没有ferroma-imap,没有 ferroma-smtpsqlx里也没有 PostgreSQL feature。客户端只链接共享核心 crate, 服务端那一侧别的什么都不链接(architecture.md §2)。


18. 相关文档

主题文档
FCP 线上格式:端点、游标、实时分帧、分块上传fcp.md
管理 API、自动发现、健康检查、错误信封api.md
同步模型:变更日志、先应用后推进、墓碑、失败矩阵sync.md
同一批账户上第三方客户端的 IMAP 行为imap.md
服务端上的附件内容寻址、配额、缓存淘汰storage.md §6
令牌盗用检测、HTML 净化器、已知缺口security.md
部署客户端所对话的服务端deployment.md
Crate 依赖图与分层规则architecture.md
Ferroma · MIT OR Apache-2.0 · 由 docs/ 生成