Ferroma
协议 · 文档

Ferroma 中的 IMAP

适合谁读: 正在实现 ferroma-imap 的人,正在针对 Thunderbird 或 Apple Mail 编写兼容性测试的人,以及用户无法让邮件客户端登录的任何运维者。

Ferroma 是面向第三方客户端的 IMAP4rev1 服务器(RFC 3501):Thunderbird、 Apple Mail、Outlook、iPhone Mail 与 Android 客户端都是一等公民,不要求它们说 FCP (项目书 §53)。本文档规定首个版本发布哪些命令、会话状态机、文件夹命名(包括 INBOX 特例与 Maildir++ 映射)、UID 与 UIDVALIDITY 语义、标志词汇表及其 Maildir 编码、 支持的 FETCH 数据项与 SEARCH 键、IDLEAPPEND 限制,以及兼容性目标清单。

状态: 设计规范。ferroma-imap crate 是按本文档实现的;标有 (计划中) 的章节描述 已被规定但尚未发布的行为。截至现在,本文件中的一切都属于 _(计划中)_: crates/ferroma-imap/src/lib.rs 还是一个骨架。它所依赖的文件夹、标志、Maildir 与搜索行为已经实现,位于 crates/ferroma-storage/src/maildir.rscrates/ferroma-storage/src/repository/mailboxes.rscrates/ferroma-storage/src/repository/messages.rscrates/ferroma-mail/src/flags.rs


1. 协议基线

属性取值
协议IMAP4rev1,RFC 3501
问候* OK [CAPABILITY …] <imap.banner>,例如 * OK [CAPABILITY IMAP4rev1 …] Ferroma IMAP4rev1 ready
端口imap.port,143,明文配合 STARTTLS
隐式 TLS 端口imap.imaps_port,993,0 表示禁用
行终止符CRLF
字面量{n} 同步字面量;也接受 {n+} 非同步字面量,因此 APPEND 可以流水线发送
认证LOGIN(用户名 + 密码)、AUTHENTICATE PLAINAUTHENTICATE LOGIN
加密只用 rustls,见 architecture.md §8

imap.require_tls_for_login(默认 false)会在未加密的连接上以 NO [PRIVACYREQUIRED] 拒绝 LOGINAUTHENTICATEdocker-compose.prod.ymlFERROMA__IMAP__REQUIRE_TLS_FOR_LOGIN 设为 'true';生产部署应当保持这一设置。 LOGIN 以明文发送密码,而没有 CRAM-MD5SCRAM-* 可以退而求其次(见 §8)。

只用 rustls,不搞 LOGINDISABLEDAUTH=PLAIN 的那套花样。require_tls_for_login 开启时,不会通告 LOGIN 在明文上可用;客户端会在 CAPABILITY 中看到 STARTTLS,并应当使用它。


2. 命令:首版与计划中

项目书 §12 把命令集一分为二。

2.1 首版

CAPABILITY   LOGIN    LOGOUT   NOOP
LIST         LSUB
SELECT       EXAMINE  STATUS
FETCH        STORE
SEARCH       UID

再加上任何客户端要能用就离不开的几条:

命令进入首版的原因
AUTHENTICATEThunderbird 与 Apple Mail 默认用 AUTHENTICATE PLAIN,而不是 LOGIN
STARTTLS在 143 端口上加密通信的唯一途径
CLOSE若干客户端在 LOGOUT 之前会发送它;没有它它们会记一条错误
CHECK一个空操作的检查点,但它缺失会在某些客户端表现为协议错误

2.2 计划中

APPEND       COPY         MOVE         EXPUNGE      IDLE
命令门控状态
APPENDimap.max_append_size(计划中)
COPY(计划中)
MOVEimap.enable_move_(计划中)_,RFC 6851
EXPUNGEstorage.soft_delete(计划中)
IDLEimap.enable_idleimap.max_idle_secs_(计划中)_,RFC 2177
UIDPLUSUID EXPUNGEEXPUNGE (计划中)RFC 4315
NAMESPACE(计划中)RFC 2342;Thunderbird 会请求它,并能容忍 NO
SORT / THREAD不在 v1 计划内Thunderbird 会优雅回退
CONDSTORE / QRESYNC表结构已就绪(folders.highest_modseqmessages.modseq),协议未实现(计划中)
COMPRESS=DEFLATE不在 v1 计划内
NOTIFY不在 v1 计划内IDLE 一次一个文件夹地覆盖同样的需求
ACLQUOTAMETADATA不在 v1 计划内配额在服务端强制执行,不需要 IMAP 扩展
CATENATEBINARYMULTIAPPEND不在 v1 计划内

这些门控是真实的配置键,并在启动时校验: crates/ferroma-core/src/config.rs 中的 ImapConfig::enable_idleImapConfig::enable_moveImapConfig::max_idle_secsImapConfig::max_append_size

2.3 CAPABILITY

通告的清单由配置生成,绝不硬编码:

* CAPABILITY IMAP4rev1
             LOGINDISABLED        (仅当 imap.require_tls_for_login 且未加密)
             STARTTLS             (仅当 tls.enabled 且尚未加密)
             AUTH=PLAIN AUTH=LOGIN
             IDLE                 (仅当 imap.enable_idle)
             MOVE UIDPLUS         (仅当 imap.enable_move)
             UNSELECT
             LITERAL+
             UIDPLUS              (`EXPUNGE` 发布之后)
             CHILDREN             (Maildir++ 有真实的层级)

LOGINDISABLED 是 RFC 3501 §7.2.1 表达“LOGIN 在此连接上不可用”的方式;它是在 保留 LOGIN 于清单的同时被通告的,因为两者都看不到的客户端会把它当成坏掉的服务器。


3. 会话状态

   ┌──────────────────────────┐
   │          未认证          │◄── 连接,“* OK [CAPABILITY …]”
   └────────────┬─────────────┘
                │  LOGIN / AUTHENTICATE 成功
                │  (在此处 STARTTLS 会回到未认证)
                ▼
   ┌──────────────────────────┐
   │          已认证          │◄── CLOSE,或一次失败的 SELECT
   └────────────┬─────────────┘
                │  SELECT / EXAMINE
                ▼
   ┌──────────────────────────┐
   │          已选中          │  同一时刻只有一个邮箱
   └────────────┬─────────────┘
                │  LOGOUT(从任何状态)
                ▼
   ┌──────────────────────────┐
   │          已登出          │  “* BYE”,然后关闭 TCP
   └──────────────────────────┘

实现必须遵守的规则:

规则说明
每个会话只有一个已选邮箱已处于 Selected 时再 SELECT 会静默取消第一个并选中第二个。不存在隐式 CLOSE
SELECT 是读写,EXAMINE 是只读该模式属于会话状态,并门控 STOREEXPUNGENO [READ-ONLY]
状态属于会话,而非全局每个会话都有自己的已选邮箱、自己的标签命名空间和自己所处命令流水线的位置
在错误状态下发出的合法命令带提示的 BAD,绝不静默空操作:BAD Command not valid in this state
NOOP 在任何状态下都合法包括 Selected;待发的未标记更新也是在这里冲出的
未标记响应SELECT 时给出 EXISTSRECENTFLAGSUIDVALIDITYUIDNEXTPERMANENTFLAGSSelected 期间给出 EXPUNGEFETCHEXISTSRECENT
标签回显每个带标签的应答都逐字重复客户端的标签,而 * 不会
imap.idle_timeout_secs会话在此期间什么都没发就会收到 * BYE Autologout; idle for too long,随后套接字关闭。默认 1800。

会话结构体 _(计划中)_:

/// crates/ferroma-imap/src/session.rs
pub struct ImapSession {
    pub connection_id: Uuid,
    pub remote_addr: SocketAddr,
    pub state: ImapState,               // NotAuthenticated | Authenticated | Selected | Logout
    pub encrypted: bool,
    pub authenticated_user: Option<UserId>,
    pub session_id: Option<SessionId>,
    pub mailbox_id: Option<MailboxId>,  // 当前打开的地址
    pub folder_id: Option<MailboxId>,   // 当前选中的文件夹
    pub read_only: bool,
    /// 会话已标记 \Deleted 但尚未清除的 UID。即使有 UIDPLUS 也需要它,
    /// 因为未请求 UIDPLUS 的客户端仍然期望 EXPUNGE 恰好移除
    /// 它标记过的那些邮件。
    pub deleted_uids: BTreeSet<i64>,
}

4. 文件夹

4.1 其背后的表结构

文件夹是 folders 表中的行,而不是在请求时遍历的目录 (migrations/0001_initial.sql):

含义
folders.mailbox_id拥有该文件夹的地址
folders.nameIMAP 名称,例如 Archive/2026
folders.parent_id层级结构的自引用;顶层文件夹为 NULL
folders.special_use\Sent\Drafts\Trash\Junk\Archive\All\FlaggedNULL
folders.subscribedLSUBLIST 之别
folders.uid_validityfolders.uid_next见 §5
folders.highest_modseqCONDSTORE 预留 (计划中)
folders.message_countunseen_counttotal_bytesFoldersRepository::recount 保持最新的计数

索引:folders_name_key (mailbox_id, name) 唯一:同一地址的两个文件夹不能重名; folders_special_use_key (mailbox_id, special_use) WHERE special_use IS NOT NULL:每个 地址最多一个 \Sent

表结构的 CHECKspecial_use 限定在上述清单内。INBOXspecial_use = NULL 存储。

4.2 INBOX 特例

RFC 3501 §5.1 让 INBOX 在三个方面特殊,三点都已实现:

  1. INBOX 不区分大小写。 select inboxSELECT Inbox
    SELECT INBOX 是同一个文件夹。FoldersRepository::find_by_nameINBOX
    不区分大小写的比较,对其余一切做区分大小写的比较,而
    crates/ferroma-storage/src/maildir.rs 中的 normalise_folder 会在名称到达
    文件系统之前把拼写规范化:
    normalise_folder("") == "INBOX"normalise_folder("/") == "INBOX"
    normalise_folder("/Sent/") == "Sent"
  2. INBOX 不能被创建、重命名或删除。 CREATE INBOXNO
    (概念上它已经存在);RENAME INBOXNODELETE INBOX
    NOMaildir::delete_folderMaildir::rename_folder 也会拒绝它,因此
    即使某个协议层忘了这层保护,保护依然成立:
    StorageError::Invalid("INBOX cannot be deleted")
  3. INBOX 总是排在第一位,在 LIST 中以及在 Maildir::list_folders 中都是如此。
    客户端把第一个文件夹显示为收件箱;一个把 Archive 排在 INBOX 之前的 LIST
    在用户看来就是坏的。

INBOX 不携带 special_useFoldersRepository::ensure_standard 故意用 None 创建它:RFC 6154 把 \Inbox 保留给包含全部邮件的文件夹,而仅靠 special_use 映射文件夹的客户端必须退回到匹配名称 INBOX

4.3 标准文件夹集合

FoldersRepository::ensure_standard(以及 Maildir::ensure_mailbox)会创建:

文件夹special_useMaildir 目录(storage.layout = "maildir"
INBOXNULL<root>/<domain>/<local>/Maildir/
Sent\Sent<root>/<domain>/<local>/Maildir/.Sent/
Drafts\Drafts<root>/<domain>/<local>/Maildir/.Drafts/
Trash\Trash<root>/<domain>/<local>/Maildir/.Trash/
Junk\Junk<root>/<domain>/<local>/Maildir/.Junk/
Archive\Archive<root>/<domain>/<local>/Maildir/.Archive/

幂等,且与自身竞争也是安全的:第二个调用者的 INSERT 输给唯一索引,随即被忽略。

4.4 Maildir++ 映射

嵌套的 IMAP 文件夹是有层级的,分隔符为 /。Maildir++ 把整条路径编码成一个目录名, 用点分隔:

Archive/2026     ↔     .Archive.2026
Archive/2026/Q1  ↔     .Archive.2026.Q1

maildir_folder_name(folder) 实现其中一个方向 (format!(".{}", folder.replace('/', "."))),Maildir::list_folders 实现另一个方向(name.trim_start_matches('.').replace('.', "/"))。

这种嵌套*在磁盘上是扁平的*:.Archive.2026.Archive 的兄弟,而不是它的子目录。 这就是 Maildir++ 的含义,也是任何邮件工具无需理解 IMAP 层级就能读取这棵树的原因。 它还意味着文件夹名中 . 出现的位置不能与分隔符产生歧义:一个字面名为 a.b 的 文件夹与嵌套的 a/b 会冲突。Ferroma 按每个 Maildir++ 实现的做法解决这个问题: IMAP 名称才是权威,它保存在 folders.name 中;目录名是推导出来的。. 的 名称会被忠实存储并原样提供,磁盘上的目录只是一种编码。Maildir::folder_dir 每次 访问都调用 maildir_folder_name,因此映射从不缓存,也就不可能漂移。

文件夹名在到达文件系统之前会被校验。 sanitize_component 应用于每一个路径段,因此 Archive/../../etc 是逐段被拒绝的,而不是靠对整个字符串做模式匹配:

// crates/ferroma-storage/src/maildir.rs
fn maildir_folder_name(folder: &str) -> Result<String> {
    for part in folder.split('/') {
        sanitize_component(part)?;
    }
    Ok(format!(".{}", folder.replace('/', ".")))
}

sanitize_component 拒绝 """."".."、任何包含 /\\0: 的内容(见 §10),否则返回 StorageError::Invalid

4.5 LISTLSUBSTATUSSUBSCRIBE

命令行为
LIST "" "*"所有文件夹,INBOX 在最前,其余按不区分大小写的字母序。\HasChildren / \HasNoChildren 来自 parent_id
LIST 属性对自身没有邮件的父文件夹给出 \HasChildren\HasNoChildren\Noselect (计划中)
LIST "" "Archive/%"% 匹配一层,* 匹配任意深度,依据 RFC 3501 §6.3.8
LSUB满足 folders.subscribed = true 的文件夹。默认即为已订阅
SUBSCRIBE / UNSUBSCRIBEFoldersRepository::set_subscribed。绝不影响文件夹是否存在
STATUS mailbox (MESSAGES RECENT UIDNEXT UIDVALIDITY UNSEEN)读取 folders.message_countunseen_countuid_nextuid_validity。在 Authenticated 状态下合法,对已选邮箱同样合法
带结尾分隔符的 CREATE依据 RFC 3501 §6.3.3,也会为父文件夹创建层级

5. UID 与 UIDVALIDITY

两个数字,两种不同的职责。把它们搞混是经典的 IMAP 缺陷。

UIDUIDVALIDITY
作用域单个文件夹内唯一且单调递增标识某个文件夹 UID 空间的一个*代*
存储messages.uidUNIQUE (folder_id, uid)folders.uid_validity
分配者folders.uid_next在创建邮箱时分配,默认 1
在以下情况保持稳定标志变更、同一文件夹*内*的移动(并不存在这种移动)任何情况都不稳定,只有 UID 空间被重建时它才会变
绝不重用MessagesRepository::move_to_folder 在目标文件夹分配一个全新 UID;旧 UID 永不重新发放

5.1 分配是原子的

存储邮件的 INSERT 在同一条语句里分配它的 UID,并且持有该文件夹的行锁:

WITH next_uid AS (
    UPDATE folders SET uid_next = uid_next + 1, updated_at = NOW()
     WHERE id = $1
     RETURNING uid_next - 1 AS uid
)
INSERT INTO messages (folder_id, mailbox_id, uid, …)
SELECT $2, $3, next_uid.uid, … FROM next_uid

crates/ferroma-storage/src/repository/messages.rs。因此同时投递进同一个文件夹的两封 邮件不可能拿到同一个 UID,崩溃也不会留下一个会被后续邮件填补的空洞。 copy_to_foldermove_to_folder 使用同一个 CTE。

5.2 UID 永不重用,被清除的 UID 永久消失

MessagesRepository::find_by_uidlist_by_uids 会过滤 expunged_at IS NULL:一旦客户端清除了某个 UID,再次引用它就是 NO,而不是把一个 被复活的邮件交出去。expunge() 设置 expunged_at,并按 uid 排序返回受影响的 行,因为 PostgreSQL 的 UPDATE 不保证顺序,而 IMAP 的 EXPUNGE 响应必须按 UID 升序(RFC 3501 对未标记 EXPUNGE 实际要求的是按下标降序;见 §6.3)。

5.3 什么会改变 UIDVALIDITY

FoldersRepository::set_uid_validity(id, uid_validity) 的存在,正是为了 UID 空间被 重建的那些情形:

事件UIDVALIDITY
文件夹被创建分配一次,默认 1
正常运行不变
文件夹被重命名不变,它是同一个 UID 空间
邮件被清除不变
数据丢失后从 Maildir 重建递增
从重建之前取得的备份恢复递增
ferroma storage verify --repair 给文件夹重新编号递增

实现遵循的规则是:只要任何操作可能让旧的 UID → 邮件映射出错,就递增 UIDVALIDITY。 保守地多递增一次,代价是客户端重新同步一个文件夹;漏掉一次递增,代价是客户端拿到 错误的邮件。

5.4 UIDVALIDITY 变化时客户端必须做什么

项目书 §55 的“服务器即唯一事实来源”让这一点毫无歧义,FCP 契约在 fcp.md §4 中如此陈述:

  1. 发现 SELECT 应答中的 UIDVALIDITY(或
    GET /api/v1/client/mailboxes 中的值)与该文件夹缓存的值不同。
  2. 丢弃该文件夹所有已缓存的 UID,以及所有仅靠 UID 标识的缓存邮件。以
    rfc_message_id 或服务器的 message_id 为键的邮件正文可以保留。
  3. 从游标 0 重新同步该文件夹。UID 会被重新发放。
  4. 绝不假设变更之前的某个 UID 仍指向同一封邮件。

服务端的一条推论:因为客户端会丢弃它的缓存,Ferroma 不能随意递增 UIDVALIDITY。 每次投递都递增它,会让每个客户端在每封新邮件到来时重新下载整个文件夹。


6. 标志与关键字

6.1 词汇表

RFC 3501 §2.3.2 定义了六个系统标志:

\Seen     \Answered     \Flagged     \Deleted     \Draft     \Recent

再加上关键字:客户端发来的其它一切:$Junk$label1$ForwardedNonJunkferroma_mail::Flags 把六个系统标志存为一个 u8 位集合(位 0 \Seen、1 \Answered、2 \Flagged、3 \Deleted、4 \Draft、 5 \Recent),把关键字按插入顺序存为一个 Vec<String>。关键字在入库时转小写并 去重,这正是 Flags::to_db_string() 具备确定性的原因。

Flags::parse 接受 (\Seen \Flagged $Label1)、不带括号的裸形式、重复空格以及 带引号的关键字。未知的 \Foo 名称会作为关键字存储,客户端的私有标志能在一个从未 听说过它们的服务器上往返而存活。

6.2 三种呈现,一套标志

呈现方法示例使用位置
IMAPFlags::to_imap_string()(\Seen \Flagged "$Label1")FETCH FLAGSSTORE 应答、PERMANENTFLAGS
数据库Flags::to_db_string()seen,flagged,$label1messages.flags
Maildirflags_to_maildir()FScur/ 中的文件名

messages.flagsTEXT NOT NULL DEFAULT '',表结构注释把它描述为空格分隔的列表。 Flags::to_db_string() 写入的是逗号分隔的值(parts.join(",")),转小写并去掉 反斜杠:\Seenseen$Label1$label1。该列是不透明文本;逗号形式就是 已实现的写入方产出的形式,也是 Flags::from_db_string() 读回的形式。把 Flags::to_db_string / from_db_string 当作触碰该列的唯一正确方式;针对它手写 SQL 就是等着出缺陷。

\Recent 是棘手的那一个。它是一个系统标志,但它是*会话状态*,不是已存储的状态: RFC 3501 说,如果这是第一个看到该邮件的会话,它就是 \Recent。Maildir 用目录来 表达它:一封尚未被邮件客户端看过的邮件在 new/ 中,看过的在 cur/ 中。因此 Ferroma 从目录推导 \Recent,而 flags_to_maildir("recent") 返回空字符串,因为 \Recent 绝不能出现在文件名里。

6.3 Maildir ↔ IMAP 标志映射

两套词汇表恰好在两个函数里相遇,即 Maildirflags_to_maildirmaildir_to_flags,此外别无他处。如果哪个字母出了错, crates/ferroma-storage/src/maildir.rs 是唯一需要改动的地方。

IMAP 标志Maildir 字母数据库形式说明
\DraftDdraft
\FlaggedFflagged
\AnsweredRanswered
\SeenSseen
\DeletedTdeletedT 意为 trashed(已丢弃),这正是 \Deleted 在 Maildir 中的含义
\Recent(无)recentnew/cur/ 推导;无法存进文件名
关键字 $Junk(无)$junk关键字只存在于数据库中;Maildir 无处存放它们
P“passed”(已通过);读取时接受,从不写入

写入顺序是 Maildir 约定的 D F P R S T,实现为 DFRST

pub fn flags_to_maildir(flags: &str) -> String {
    // … if has("draft") { out.push('D') } if has("flagged") { out.push('F') }
    //    if has("answered") { out.push('R') } if has("seen") { out.push('S') }
    //    if has("deleted") { out.push('T') }
}

maildir_to_flags("FS") == "flagged seen";未知字母被忽略 (maildir_to_flags("XYZ") == ""),这正是另一个工具写入的存储仍然可读的原因。

关键字按设计就是有损的那一部分。 在文件夹之间移动邮件会根据标志集合重写它的 Maildir 文件名,而自定义关键字没有字母。它们留在 messages.flags 里;文件名只是 不提它们。因此重新读取时,关键字以数据库为准,字母以 Maildir 为准,这就是为什么 iter_messages 返回的 maildir_flags 是一份修复输入,而不是唯一事实来源。

SELECT 时的 PERMANENTFLAGS 通告 (\Answered \Flagged \Deleted \Seen \Draft \*)\* 是 RFC 3501 表达“接受任意关键字”的方式,而 Ferroma 确实接受它们。

6.4 STORE 语义

形式行为
STORE 1:5 +FLAGS (\Seen)添加
STORE 1:5 -FLAGS (\Seen)移除
STORE 1:5 FLAGS (\Seen)替换
STORE 1:5 +FLAGS.SILENT (…)同上,但没有未标记的 FETCH 应答
UID STORE …同上,按 UID 寻址
STORE 中的 \Recent被忽略,不算错误:它不可设置

每一个真正改动了东西的 STORE 都按此顺序做三件事:

  1. MessagesRepository::set_flags / add_flags / remove_flags:改数据库行,而
    那是所有其它界面读取的东西。
  2. Maildir::set_flags(relative_path, flags):重命名文件,当标志集合变为空或
    非空时在 new/cur/ 之间移动它。
    它返回可能是新的路径,如果发生了变化,调用方必须通过
    MessagesRepository::set_storage_path 把它持久化。
  3. EventBus::publish(EventScope::User(user_id), Event::mail_flag_changed(…)),然后
    追加一条 change_log,让已同步的客户端无需轮询就能得知。

第 2 步是幂等的:计算出的名称未变时 set_flags 返回原路径,因此重复的 STORE 不会让文件系统反复折腾。

BODY[…] 在未带 .PEEK 的情况下被获取时,FETCH 会隐式写入 \Seen,而一次 \Seen 变迁同样会发布 Event::mail_read。想窥探的客户端(每个渲染列表的邮件 客户端)发送 BODY.PEEK[]

6.5 Maildir 无法表达的东西,以及会发生什么

情形行为
设置了一个关键字存入 messages.flags;文件名不变
所有标志被清除文件从 cur/ 移回 new/,这正是客户端把邮件标为未读时所期望的
设置了一个标志文件从 new/ 移到 cur/,并追加 2,<字母>
只读模式生效(EXAMINESTORENO [READ-ONLY]

7. FETCH 数据项

数据项支持来源
FLAGSmessages.flags,经 Flags::to_imap_string()
UIDmessages.uid
RFC822.SIZEmessages.size_bytes
INTERNALDATEmessages.internal_date
ENVELOPE从已存储的头字段解析:DateSubjectFromSenderReply-ToToCcBccIn-Reply-ToMessage-ID
BODY / BODY[]来自 Maildir 的完整 RFC 5322 字节
BODY[HEADER]头字段块用 Maildir::read_prefix 读取,再用 ferroma_mail::Headers 解析
BODY[HEADER.FIELDS (…)]选定的头字段,用 fold_header_line 重新折行
BODY[HEADER.FIELDS.NOT (…)]上述的补集
BODY[TEXT]空行之后的一切
BODY[<section>] / BODY[<section>]<partial>MIME 部件寻址,以及用于可续传下载的 BODY[]<0.1024> 部分获取
BODY.PEEK[…]同上,但不设置 \Seen
BODYSTRUCTURE先支持不可扩展形式;扩展数据(BODYSTRUCTURE)为 (计划中)
BODY(不可扩展的 BODYSTRUCTURE
MODSEQ(计划中)messages.modseq 已存储并建立索引,供 CONDSTORE 使用
BINARY[…]不通告 BINARY
X-GM-*不模拟 Gmail 扩展

三条实用规则:

序列集与 UID 集接受完整的 RFC 3501 §9 文法:11:51:*1,3,5:7*(文件夹中最高的 UID 或序列号)。在空文件夹上使用 * 是错误,而不是空集合。


SEARCH 以空格分隔的匹配*序列号*列表作答;UID SEARCH 以 UID 作答。SEARCH 并不很适合关系型存储,因此这种转换是显式的:

转换
ALLexpunged_at IS NULL
SEEN / UNSEENflags 包含 / 不包含 seen
ANSWERED / UNANSWEREDanswered
FLAGGED / UNFLAGGEDflagged
DELETED / UNDELETEDdeleted
DRAFT / UNDRAFTdraft
RECENT / OLDnew/cur/:属于推导,因此在 SQL 阶段之后用 Rust 求值
KEYWORD <kw> / UNKEYWORD <kw>messages.flags 中对转小写后的关键字做精确匹配
FROM <s>messages.sender / message_recipients,要求 kind = 'sender'
TO <s>CC <s>BCC <s>message_recipients.address,要求 kind 匹配
SUBJECT <s>messages_subject_fts_idx,一个建立在 to_tsvector('simple', coalesce(subject, '')) 上的 GIN 索引
BODY <s>_(计划中)_,需要正文索引;Maildir 对邮件正文没有索引
TEXT <s>_(计划中)_,头字段加正文
HEADER <name> <s>_(计划中)_,头字段没有以关系形式存储;messages 只保留一个反规范化的子集
LARGER <n> / SMALLER <n>messages.size_bytes
BEFORE <date> / ON <date> / SINCE <date>messages.internal_date
SENTBEFORE / SENTON / SENTSINCEmessages.sent_at
UID <set>messages.uid
NEWOLDRECENT由上述键组合而成
NOT <key><key1> <key2>(AND)、OR <key1> <key2>组合而成
<sequence set> <key>组合而成
CHARSET UTF-8接受;其它一律 NO [BADCHARSET (UTF-8)]

MessagesRepository::search(MessageSearch) 是这一切背后的查询构造器;API 的 /api/v1/messages 过滤器与 Admin 的搜索使用同一个函数,因此对相同字段的一次 SEARCH 与一次 API 查询返回相同的邮件。这就是分层规则在真正起作用:IMAP 层只贡献 解析器。

实现 BODY/TEXT 搜索意味着要么在 PostgreSQL 中索引邮件正文,要么每次查询都遍历 Maildir。两者对 v1 都不可接受,因此它被明确列为 _(计划中)_,使用它的客户端会得到 NO [CANNOT] BODY search is not supported,而不是错误的结果。Admin/API 一侧提供了 一个确实可用的主题与头字段搜索。


9. IDLE 与 29 分钟规则

RFC 2177 建议客户端持有 IDLE 不超过 29 分钟,并要求服务器终止它。 config/ferroma.toml

[imap]
enable_idle = true
# 客户端持有 IDLE 的最长时间,单位为秒(RFC 2177 建议小于 30 分钟)。
max_idle_secs = 1740

1740 秒正好是 29 分钟。

C: A001 IDLE
S: + idling
      … 会话保持 Selected 并推送未标记更新 …
C: DONE
S: A001 OK IDLE terminated
规则说明
+ idling立即发送;此时连接处于类似字面量的续行状态,只有 DONE 合法
推送什么* n EXISTS* n RECENT* n FETCH (FLAGS …)* n EXPUNGE,即从 ferroma-events 到达该会话的事件的一个子集
数据来源EventBus::subscribe_filtered(EventScope::User(user_id)),每个会话的每个已选文件夹一个订阅
服务端超时到达 imap.max_idle_secs 时服务器发送 * BYE Idle timeout 并关闭。行为良好的客户端会重新发起 IDLE,这正是让手机上的套接字保持存活的原因
DONE 之外的任何东西BAD
imap.enable_idle = false 时的 IDLEBAD,且不通告 IDLE
Authenticated 中(未选中任何文件夹)的 IDLEBAD,没有可报告的文件夹
会话空闲超时imap.idle_timeout_secs(1800)是另一回事:它是会话*什么都不发*的超时,而在活跃文件夹上的 IDLE 并不算“什么都不发”

事件总线只在进程内。一个 IDLE 在某个文件夹上的会话会收到同一进程内所做更改的 推送;另一个共享数据库的进程所做的更改不会被推送,而是在下一次 NOOPCHECKSELECT 时到达,并且对重新连接的客户端始终可见。这与 architecture.md §6 和 security.md 中提到的限制相同。

IDLE 正是大多数客户端不需要 CONDSTORE 的原因:服务器推送发生了什么变化,客户端 就不必轮询 STATUS 去发现。


10. APPEND 与 Windows 的 info 分隔符

10.1 APPEND 限制

限制取值应答
最大字面量imap.max_append_size,26214400(25 MiB)NO [TOOBIG] Literal too large
字面量语法{n}{n+}
日期时间APPEND "Sent" (\Seen) "16-Sep-2026 09:12:31 +0000" {n}无法解析的日期是 BAD,而不是被忽略
标志服务器接受的任何标志或关键字不可接受的标志是 BAD
目标文件夹必须存在NO [TRYCREATE],即 RFC 3501 §6.3.11 的信号,表示客户端应先 CREATE
配额写入之前调用 MailboxesRepository::check_quotaNO [OVERQUOTA]
结果邮件存入 Maildir、插入行、分配 UID、记录变更通告 UIDPLUS 时为 OK [APPENDUID <uidvalidity> <uid>]

APPEND 原样存储字面量。与提交不同,它不会在前面加上 Received: 头字段:这封邮件 不是经由 SMTP 到达这里的,凭空发明一跳会破坏客户端试图保留的头字段链。它确实会设置 标志,据此把文件移入 cur/new/,并在目标文件夹分配一个全新的 UID。

APPENDDrafts 是第三方客户端的“保存草稿”最终与官方客户端的 POST /api/v1/client/drafts 落到同一处的方式;两者绝不能分叉,因此服务端草稿会以 \Draft 标志镜像到 Drafts 文件夹(fcp.md §7)。

10.2 Windows 上的 ;: 分隔符

Maildir 文件名在一个“info”段之后携带它的标志:

1758012751.M4821P3210.mail:2,S
└────── 基础名 ──────┘ └┬┘└┬┘
                     版本  标志

在 Unix 上分隔符是 :,这是二十年来每个 Maildir 实现都在用的事实标准。在 Windows 上它不能是 :,因为 NTFS 把 name:stream 当作备用数据流:创建一个字面名为 1758012751.M4821P3210.mail:2,S 的文件要么失败,要么静默地创建出不是普通文件的 东西,标志会不可见,或者写入直接失败。因此 Windows 上的 Maildir 实现同样长久以来 改用 ;

Ferroma 两者都支持,位于 crates/ferroma-storage/src/maildir.rs

/// 规范的 Maildir “info” 分隔符(没有 RFC 的事实标准,Dovecot 等)。
pub const INFO_SEPARATOR_UNIX: char = ':';

/// 在文件名中保留 `:` 的文件系统上使用的分隔符。
pub const INFO_SEPARATOR_WINDOWS: char = ';';

/// 本次构建写入时使用的 “info” 分隔符。
pub fn info_separator() -> char {
    if cfg!(windows) { INFO_SEPARATOR_WINDOWS } else { INFO_SEPARATOR_UNIX }
}

规则,以及为什么两个常量都存在:

函数行为
info_separator()本次构建写入时用哪个:Windows 上 ;,其它地方 :
with_info(base, flags)info_separator() 追加 {sep}2,{flags};没有标志时原样返回 base
split_info(file_name)两种分隔符都接受,而且都会尝试,因此在 Linux 上写入的存储能在 Windows 上读,反之亦然
assert_eq!(split_info("1234.M1P.host:2,S"), ("1234.M1P.host", "S"));
assert_eq!(split_info("1234.M1P.host;2,FS"), ("1234.M1P.host", "FS"));
assert_eq!(split_info("1234.M1P.host"), ("1234.M1P.host", ""));
// 有些工具会省略 `2,` 版本标记。
assert_eq!(split_info("1234.M1P.host:S"), ("1234.M1P.host", "S"));

三条推论:

  1. 邮件存储是可移植的。Maildir/ 从 Linux 服务器复制到 Windows 工作站并让
    Ferroma 指向它,不需要重写任何一个文件名,读取接受两种形式。新的写入使用本地
    分隔符,因此一棵树合法地可以同时含有两种,而 split_info 能处理。
  2. split_info 忽略单字符前缀。 if base.len() <= 1 { continue; }
    之所以存在,是因为 Windows 路径开头的 C: 看起来与 info 分隔符一模一样;
    没有这道防线,路径前缀会被误认为标志。
  3. 挂载方式很重要。 一个 CIFS/SMB 共享上的 Maildir 挂载到 Linux 后,在服务器是
    Linux 时会以 : 写入,而如果远端是 Windows,写入仍可能在 NTFS 层失败。布局
    设置是按部署刻意区分的:storage.layout = "maildir" 使用 Maildir/.Folder
    子目录,而 "maildirperfolder" 每个文件夹一个 Maildir
    <local>/INBOX<local>/Sent),没有带点的目录名,在文件名规则特殊的
    文件系统上这是更安全的选择。

sanitize_component 会拒绝它收到的每个路径组件中的 :,这防止*文件夹*名把 info 段 偷带进目录名。邮件文件名由 unique_filename 构造,绝不来自用户输入。


11. 兼容性目标

项目书 §12:*“Thunderbird、Apple Mail、Outlook、iPhone Mail、Android 邮件客户端能够逐步兼容”*,即与这五者逐步兼容。

客户端平台用到的东西说明
ThunderbirdWindows、Linux、macOSCAPABILITYLOGIN/AUTHENTICATE PLAINLISTLSUBSELECTFETCHSTORESEARCHIDLEAPPEND五者中最严格的:它用 LSUB 构建文件夹面板、在已选文件夹上用 IDLE、把 APPEND 发往 Drafts,并在重新同步后用 UID SEARCH。它会请求 NAMESPACE,并能容忍 NO
Apple MailmacOS同一套,外加 STATUS 轮询与 UID FETCH期望用 \Sent / \Drafts / \Trash / \Junk 特殊用途标记来安置文件夹
OutlookWindowsCAPABILITYLOGINSELECTFETCHSTOREAPPENDIDLE对未通告的扩展容忍度更低;必须针对真实构建做测试
iPhone MailiOSCAPABILITYLOGINSELECTFETCHSTOREAPPENDIDLESEARCH最依赖 IDLE 的一个;IDLE 一坏,看起来就像“邮件收不到”
Android 邮件客户端AndroidCAPABILITYLOGINSELECTFETCHSTORESEARCHK-9 Mail 与 FairEmail 是现实的目标;两者都能处理 MOVE 缺失

首版的兼容性门槛,来自项目书 §44:

CAPABILITY   LOGIN   SELECT   FETCH   STORE   SEARCH   UID   IDLE

其中每一项都必须针对真实客户端演练,而不能只针对一致性脚本。 openssl s_client -crlf -connect host:143 加上手工敲出来的会话,能抓住测试套件抓不到 的大部分问题。

与特殊用途标记(§4.3)的配合,是让文件夹安置在五者上都能工作的关键:一个按 \Sent 映射、而不是去猜名称 Sent 的客户端,在某账户的文件夹叫 Sent Items 时 依然能对上。


12. 手工测试 IMAP

# 问候、能力清单,以及一次完整的登录/选择/获取,未加密。
openssl s_client -crlf -connect 127.0.0.1:143

# 隐式 TLS。
openssl s_client -connect 127.0.0.1:993

# 只取一个运行中服务器的能力清单。
printf 'a CAPABILITY\r\nb LOGOUT\r\n' | openssl s_client -quiet -crlf -connect 127.0.0.1:143

示例会话输出:

* OK [CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN AUTH=LOGIN IDLE MOVE UIDPLUS LITERAL+ CHILDREN] Ferroma IMAP4rev1 ready
a LOGIN [email protected] "…"
a OK LOGIN completed
b LIST "" "*"
* LIST (\HasNoChildren) "/" "INBOX"
* LIST (\HasNoChildren \Sent) "/" "Sent"
* LIST (\HasNoChildren \Drafts) "/" "Drafts"
* LIST (\HasNoChildren) "/" "Archive"
* LIST (\HasChildren) "/" "Archive/2026"
b OK LIST completed
c SELECT INBOX
* 412 EXISTS
* 3 RECENT
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft \*)] Flags permitted
* OK [UIDVALIDITY 1] UIDs valid
* OK [UIDNEXT 118] Predicted next UID
c OK [READ-WRITE] SELECT completed
d UID FETCH 117 (FLAGS RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM)])
* 117 FETCH (UID 117 FLAGS (\Seen) RFC822.SIZE 24831 INTERNALDATE "16-Sep-2026 09:12:44 +0000" BODY[HEADER.FIELDS (SUBJECT FROM)] {68}
Subject: Invoice for September
From: Bob <[email protected]>
)
d OK UID FETCH completed
e LOGOUT
* BYE Ferroma IMAP4rev1 server signing off
e OK LOGOUT completed

关于“IMAP 登录失败”,见 deployment.md §11。


13. 相关文档

主题文档
为什么 IMAP 与 FCP 同时存在;官方客户端自己的协议fcp.mdclient.md
文件夹与邮件表、Maildir 投递、完整性检查storage.md
同步游标、墓碑、冲突消解sync.md
TLS、require_tls_for_login、威胁模型security.mddeployment.md
crate 分层、事件总线、请求生命周期architecture.md
Ferroma · MIT OR Apache-2.0 · 由 docs/ 生成