Ferroma
内部实现 · 文档

存储

谁该读这份文档: 任何改ferroma-storage的人、任何针对 Ferroma 数据库写 SQL 的人, 以及任何需要思考配额、磁盘占用、垃圾回收或恢复的运维者。

Ferroma 使用两个存储。PostgreSQL 保存应用要查询的每一个事实,谁存在、哪封邮件在哪个 文件夹、一次投递处于什么状态。文件系统保存字节,Maildir 里的 RFC 5322 邮件与内容寻址 二进制存储里的附件。本文说明哪个存储对什么内容是权威的,逐表走一遍 schema 并给出服务 每条查询的索引,规定 Maildir 投递算法及其持久性保证,解释配额记账与执行位置,描述附件 的内容寻址与 GC,最后给出备份模型与完整性检查方案。

状态: 描述已实现的代码。ferroma-storage 已完成: crates/ferroma-storage/src/{database,maildir,attachment,error,models}.rscrates/ferroma-storage/src/repository/*.rs。schema 是 migrations/0001_initial.sql,它是仓库中唯一的迁移,也是下文每一个表与列的来源。 有两处是 _(计划中)_:ferroma storage CLI 子命令,以及驱动 GC 与完整性检查的 API 端点。凡给出 pg_dump/psql 命令的地方,都是今天就能运行的真实命令。


1. 两个存储,一个真相

                        ┌──────────────────────────────┐
                        │        PostgreSQL            │
   权威描述:           │  users, domains, mailboxes,  │
   *什么存在*           │  folders, messages,          │
                        │  message_recipients,         │
                        │  attachments, mail_queue,    │
                        │  delivery_attempts, devices, │
                        │  sessions, client_sync_      │
                        │  states, drafts, operations, │
                        │  change_log, audit_logs,     │
                        │  login_attempts, settings    │
                        └──────────────┬───────────────┘
                                       │  messages.storage_path
                                       │  attachments.storage_path
                                       ▼
                        ┌──────────────────────────────┐
   权威描述:           │        文件系统              │
   *字节本身*           │                              │
                        │  Maildir:                   │
                        │    <root>/<domain>/<local>/  │
                        │      Maildir/{cur,new,tmp}   │
                        │      Maildir/.Folder/{…}     │
                        │                              │
                        │  二进制存储:                │
                        │    <root>/ab/cd/<sha256>     │
                        └──────────────────────────────┘

这个分工写在 migrations/0001_initial.sql 的头部,并实现在 crates/ferroma-storage/src/lib.rs 中:

数据库对*什么存在*是权威的,文件系统对*字节本身*是权威的。有行无文件是 StorageError::BodyMissing;有文件无行则由 AttachmentStore::gcMaildir::sweep_tmp 回收。

问题答案来自
[email protected] 存在吗?mailboxes(+ domains.enabled
她有哪些文件夹?folders
有多少封未读?folders.unseen_count
邮件 4821 的主题是什么?messages.subject
邮件 4821 的 UID 是什么?messages.uid
邮件 4821 的字节是什么?Maildir 根下 messages.storage_path 指的文件
附件 9 的内容是什么?二进制存储根下 attachments.storage_path 指的文件
它投递成功了吗?mail_queue.status

根目录来自配置,不来自数据库:Config::maildir_root()storage.maildir_root<server.data_dir>/mailConfig::attachment_root()storage.attachment_root<server.data_dir>/attachments

每个路径列都是相对路径。 messages.storage_path 形如 example.com/alice/Maildir/cur/1758012751.M4821P3210.mail:2,S,相对于 Maildir 根, 且形式固定:始终使用正斜杠(Maildir::relative/ 拼接)。这正是数据目录可以迁移的 原因:移动根目录,更新 server.data_dir,所有路径依然能解析。


2. schema,逐表说明

只有一个迁移:migrations/0001_initial.sql,440 行,在 database.run_migrations = true 时于启动阶段应用。它面向 PostgreSQL 14+,只使用核心的 gen_random_uuid() 时代特性, 不需要安装任何扩展。

2.1 身份

users

类型说明
idBIGSERIAL PK
emailTEXT NOT NULL唯一,小写
password_hashTEXT NOT NULL一个 Argon2id PHC 字符串,见 security.md §2
display_nameTEXT
enabledBOOLEAN NOT NULL DEFAULT TRUE被停用的账户无法登录
is_adminBOOLEAN NOT NULL DEFAULT FALSE
quota_bytesBIGINT NOT NULL DEFAULT 10737418241 GiB
used_bytesBIGINT NOT NULL DEFAULT 0Maildir 总量的反规范化缓存
failed_loginsINTEGER NOT NULL DEFAULT 0连续失败次数
locked_untilTIMESTAMPTZrecord_login_failure 设置
last_login_atTIMESTAMPTZ
created_at, updated_atTIMESTAMPTZ NOT NULL DEFAULT NOW()

约束:users_email_lowercase CHECK (email = lower(email))users_email_not_blank CHECK (length(btrim(email)) > 3)users_quota_sane CHECK (quota_bytes >= 0)users_used_sane CHECK (used_bytes >= 0)

类型:crates/ferroma-storage/src/models.rs 中的 User;辅助方法 User::is_login_allowed(now) 读取 enabledlocked_until

domains

类型说明
idBIGSERIAL PK
nameTEXT NOT NULL唯一,小写
descriptionTEXT
enabledBOOLEAN NOT NULL DEFAULT TRUE被停用的域不接收任何邮件
catch_allTEXT接收本域内发往不存在邮箱的邮件的本地部分
dkim_selectorTEXT按域配置的选择器
dkim_private_key, dkim_public_keyTEXTPEM;见 security.md §9
created_at, updated_atTIMESTAMPTZ

domains_name_lowercasedomains_name_not_blankusers 的检查对应。

DKIM 私钥既存在这里的数据库中,也存在于配置的 [dkim] private_key_path 中。 两者都受支持;GET /api/v1/domains/:id/dkim 读取的是数据库列,而在设置 dkim.enabled 时签名器优先使用文件。两者都要备份,见 §8。

2.2 地址、别名、文件夹

mailboxes — 是一个地址,不是文件夹

类型说明
idBIGSERIAL PK
user_idBIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
domain_idBIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE
local_partTEXT NOT NULL小写
display_nameTEXT
enabledBOOLEAN NOT NULL DEFAULT TRUE
is_primaryBOOLEAN NOT NULL DEFAULT FALSE
quota_bytesBIGINTNULL = 继承属主的配额
created_at, updated_atTIMESTAMPTZ

这是 SMTP RCPT TO 的目标,也是配额记账的单位。它*不是* IMAP 文件夹;项目书 §37 的草案 为何被拆分,见 architecture.md §9.1。

aliases

类型说明
idBIGSERIAL PK
domain_idBIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE
local_partTEXT NOT NULL小写
targetTEXT NOT NULL完整目标地址;只写本地部分表示「同域」
enabledBOOLEAN NOT NULL DEFAULT TRUE
created_atTIMESTAMPTZ

folders — IMAP 文件夹

类型说明
idBIGSERIAL PK
mailbox_idBIGINT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE所属地址
nameTEXT NOT NULLIMAP 名称,例如 Archive/2026
parent_idBIGINT REFERENCES folders(id) ON DELETE CASCADE层级关系
special_useTEXT\Sent\Drafts\Trash\Junk\Archive\All\FlaggedNULL
subscribedBOOLEAN NOT NULL DEFAULT TRUELSUB
uid_validityBIGINT NOT NULL DEFAULT 1IMAP UID 世代
uid_nextBIGINT NOT NULL DEFAULT 1UID 分配器
highest_modseqBIGINT NOT NULL DEFAULT 1CONDSTORE 预留
message_countINTEGER NOT NULL DEFAULT 0计数缓存,由 recount 维护
unseen_countINTEGER NOT NULL DEFAULT 0计数缓存
total_bytesBIGINT NOT NULL DEFAULT 0计数缓存
created_at, updated_atTIMESTAMPTZ

约束:folders_name_not_blank,以及把 special_use 限制为上述七个值的 folders_special_use_known

2.3 邮件

messages

类型说明
idBIGSERIAL PK类型为 MessageId;API 以 message_id 返回
folder_idBIGINT NOT NULL REFERENCES folders(id) ON DELETE CASCADE权威的父级
mailbox_idBIGINT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADEfolders.mailbox_id 的反规范化副本
uidBIGINT NOT NULLIMAP UID,在文件夹内唯一
rfc_message_idTEXTRFC 5322 的 Message-ID 头字段,见 architecture.md §9.2
thread_idTEXTReferences 链的根 Message-ID
subjectTEXT已解码
senderTEXTFrom 地址
sender_nameTEXTFrom 显示名
snippetTEXT供列表视图与通知使用的短预览,不含正文
size_bytesBIGINT NOT NULL
storage_pathTEXT NOT NULL相对于 Maildir 根
checksum_sha256TEXT小写十六进制,当 storage.checksum = true 时写入
flagsTEXT NOT NULL DEFAULT ''Flags::to_db_string() 形式,例如 seen,flagged
internal_dateTIMESTAMPTZ NOT NULL DEFAULT NOW()IMAP INTERNALDATE
received_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Ferroma 接收它的时刻
sent_atTIMESTAMPTZDate 头字段,可解析时写入
has_attachments, attachment_countBOOLEAN / INTEGER为列表视图反规范化
is_draftBOOLEAN NOT NULL DEFAULT FALSE
modseqBIGINT NOT NULL DEFAULT 1CONDSTORE 预留
deleted_atTIMESTAMPTZ软删除(\Deleted),发生在 expunge 之前
expunged_atTIMESTAMPTZ已从客户端视图中消失;行与文件可能仍然存在
created_at, updated_atTIMESTAMPTZ

约束:messages_size_sane CHECK (size_bytes >= 0)messages_uid_sane CHECK (uid > 0)

两阶段删除值得展开说明,因为它正是「用户把它标记为已删除」与「邮件已经消失」的区别:

live            expunged_at IS NULL AND deleted_at IS NULL
\Deleted        deleted_at  IS NOT NULL   (IMAP STORE +FLAGS \Deleted, API DELETE)
expunged        expunged_at IS NOT NULL   (IMAP EXPUNGE, API DELETE ?permanent=true)

每条读取路径都过滤 expunged_at IS NULLfind_by_uidlist_by_uidslist_by_folderlist_unexpungedcount_by_foldernewestsearch。 行由 hard_delete 删除,且只在 Maildir 文件已 unlink 之后。

message_recipients

类型说明
idBIGSERIAL PK
message_idBIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE
kindTEXT NOT NULLtoccbccreply-tosender
addressTEXT NOT NULL
display_nameTEXT
ordinalINTEGER NOT NULL DEFAULT 0保留头字段中的顺序

message_recipients_kind_known 限制 kind。这张表存在的意义,是让 IMAP SEARCH TO/CC/BCC、API 的收件人过滤和 Admin 搜索变成一次带索引的查询,而不是每封 邮件重新解析一遍头字段。

attachments

类型说明
idBIGSERIAL PK类型为 AttachmentId
message_idBIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE
filenameTEXT
content_typeTEXT NOT NULL DEFAULT 'application/octet-stream'
size_bytesBIGINT NOT NULL
storage_pathTEXT NOT NULLab/cd/<sha256>,相对于二进制存储根
content_idTEXT内联部分的 Content-ID
is_inlineBOOLEAN NOT NULL DEFAULT FALSE
checksum_sha256TEXT与路径相同的摘要
created_atTIMESTAMPTZ

多行,一个 blob。 一行附件是(邮件、部分、元数据);它指向的文件与所有持有相同字节的 行共享。删除一封邮件会移除它的行,但未必移除 blob,原因见 §6。

2.4 发信队列

mail_queue

类型说明
idBIGSERIAL PK类型为 QueueId
message_idBIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE
user_idBIGINT REFERENCES users(id) ON DELETE SET NULL系统邮件为 NULL
senderTEXT NOT NULL信封反向路径
recipientTEXT NOT NULL每个收件人一行
statusTEXT NOT NULL DEFAULT 'pending'pendingdeliveringdeliveredretryfailedcancelled
attemptsINTEGER NOT NULL DEFAULT 0
max_attemptsINTEGER NOT NULL DEFAULT 12queue.max_attempts
next_attempt_atTIMESTAMPTZ调度器可以重试的时间
last_attempt_atTIMESTAMPTZ
delivered_atTIMESTAMPTZ
last_errorTEXT
last_status_codeINTEGER远端的 SMTP 应答码
last_status_textTEXT远端返回的文本
remote_mxTEXT尝试过的远端主机
created_at, updated_atTIMESTAMPTZ

mail_queue_status_knownstatus 限制为上述六个值,另有 mail_queue_attempts_sane CHECK (attempts >= 0)。重试时机见 smtp.md §11.3。

delivery_attempts

每次尝试一行:queue_id(外键,级联)、attemptremote_mxstatus_codestatus_texterrorduration_mscreated_at。这是 Admin 「Delivery Logs」界面读取的历史记录。它随每次重试增长,这正是 queue.retention_days (30)存在的原因,也是索引取 (queue_id, attempt) 而不是 (created_at) 的原因。

2.5 会话、设备、客户端同步状态

devices

iduser_id(外键,级联)、device_uid TEXT(客户端生成,按安装保持稳定)、 nameplatformclient_versionprotocol_versionlast_seen_atlast_ipcreated_atrevoked_at

devices_uid_key UNIQUE (user_id, device_uid) 让设备注册具备幂等性: DevicesRepository::upsert 可以在每次客户端启动时调用。

sessions

iduser_idkindwebapiclientimapsmtp,由 sessions_kind_known 限制)、token_hash TEXTdevice_id BIGINT REFERENCES devices(id) ON DELETE SET NULLipuser_agentcreated_atlast_seen_atexpires_atrevoked_at

原始令牌从不存储。 sessions.token_hash 是某个不透明刷新令牌的 SHA-256 (TokenService::hash),因此数据库转储不会把可用的会话直接交给攻击者。访问令牌是无状态 JWT,完全不在这张表里。

client_sync_states

iddevice_id(外键,级联)、mailbox_id(外键,级联)、folder_id(外键, 级联;NULL = 账户级)、cursor BIGINT NOT NULL DEFAULT 0updated_at

client_sync_states_key UNIQUE (device_id, mailbox_id, COALESCE(folder_id, 0))。 这个 COALESCE 是承重的:PostgreSQL 在唯一索引里把 NULL 视为互不相同,没有它,一个 设备就能累积出无限多的账户级行。SyncStatesRepository::get 对不存在的行返回 0, 其含义恰好是「从未同步过」。

2.6 草稿

drafts

iduser_idmailbox_idfolder_idmessage_id(Drafts 文件夹中镜像的那份 副本)、subjectbody_textbody_htmlrecipients JSONB DEFAULT '[]'attachments JSONB DEFAULT '[]'in_reply_toreference_ids JSONB DEFAULT '[]'created_atupdated_at

草稿既是行里的 JSON,*也是* Drafts 文件夹中的一封真实邮件,因此 IMAP 客户端与官方客户端 看到的是同一份草稿,见 fcp.md §7。

2.7 操作、变更日志、审计

operations — 幂等性

operation_id TEXT PRIMARY KEYuser_idkindstatusappliedfailed)、result JSONB(缓存的响应)、created_atcompleted_at

主键*就是*客户端生成的那个 op_… 字符串。begin() 是一条 INSERT … ON CONFLICT DO NOTHING RETURNING *,因此这次认领是原子的。

change_log — 同步日志

seq BIGSERIAL PKuser_idmailbox_idfolder_idmessage_id BIGINTkindpayload JSONBcreated_at

message_id 没有外键,这是刻意的。 schema 注释说明了原因: 「墓碑必须比行存活得更久」。一条 message_deleted 记录必须在 messages 行消失之后 仍然可读,否则离线客户端永远无法得知那封邮件已经不见。seq 就是同步游标,见 sync.md

audit_logs

idactor_user_id(外键 ON DELETE SET NULL,审计行比它记录的账户活得更久)、 actiontarget_typetarget_idipuser_agentdetails JSONB DEFAULT '{}'created_at

2.8 登录限流与设置

login_attempts

idemailipkind TEXT NOT NULL DEFAULT 'password'success BOOLEAN NOT NULLcreated_at。每次登录尝试都写一行,无论成功还是失败; AuthService::login 在做任何 Argon2 工作*之前*,先通过 LoginAttemptsRepository::count_failures_for_ip(ip, window) 读取它们,因此凭据洪水无法 烧掉 CPU。

settings

key TEXT PRIMARY KEYvalue JSONB NOT NULLupdated_at。由数据库支撑的设置, Admin 面板可以修改而无需重启。它们覆盖 ferroma.toml:这里的值是运行时旋钮, 不是配置,Config 中没有任何东西读这张表。


3. 索引,以及每个索引服务的查询

没有查询使用的索引就是纯粹的写入成本。以下是 migrations/0001_initial.sql 中的完整列表, 以及每个索引存在的理由。

索引服务
users_email_keyusers登录:UsersRepository::find_by_email
users_admin_idxWHERE is_adminusers部分索引:(很短的管理员列表)
domains_name_keydomainsRCPT TO 的域解析、DomainsRepository::find_by_name
mailboxes_address_keymailboxesSMTP 投递查找(domain_id, local_part) — 唯一,因此地址无法重复
mailboxes_user_idxmailboxeslist_by_user,调用者的地址列表
mailboxes_primary_keyWHERE is_primarymailboxes部分唯一:每个用户至多一个主地址
aliases_keyaliases(domain_id, local_part)RCPT TO 上的别名查找
folders_name_keyfolders(mailbox_id, name) 唯一 — find_by_name,以及阻止重复文件夹的守卫
folders_mailbox_idxfolderslist(mailbox_id)
folders_special_use_keyWHERE special_use IS NOT NULLfolders部分唯一:每个地址至多一个 \Sent(等)
messages_folder_uid_keymessages(folder_id, uid) 唯一find_by_uidlist_by_uids、按 UID 的 FETCH/STORE
messages_live_idxmessages(folder_id, internal_date DESC) WHERE expunged_at IS NULL — IMAP SELECT 视图与文件夹邮件列表
messages_mailbox_date_idxmessages(mailbox_id, internal_date DESC) — 「本地址的全部邮件」,Webmail 的 All-Mail 视图
messages_folder_date_idxmessages(folder_id, internal_date DESC) — 含已 expunge 行的文件夹列表(完整性检查、Admin)
messages_rfc_id_idxWHERE rfc_message_id IS NOT NULLmessages部分索引,非唯一find_by_rfc_message_id,用于会话归组与去重
messages_thread_idxWHERE thread_id IS NOT NULLmessages部分索引:会话分组
messages_sender_idxmessagesSEARCH FROM、API 的发件人过滤
messages_subject_fts_idxmessages基于 to_tsvector('simple', coalesce(subject, '')) 的 GIN — SEARCH SUBJECT、API 的 ?query=
message_recipients_message_idxmessage_recipients一封邮件的收件人
message_recipients_address_idxmessage_recipientsSEARCH TO/CC/BCC、「发给这个地址的邮件」
attachments_message_idxattachments一封邮件的附件
mail_queue_due_idxmail_queue(next_attempt_at) WHERE status IN ('pending','retry') — 调度器的「现在该发什么?」热路径
mail_queue_message_idxmail_queue一封邮件的队列行
mail_queue_status_idxmail_queueAdmin 按状态过滤队列
mail_queue_user_idxmail_queue(user_id, created_at DESC) WHERE user_id IS NOT NULL — 用户自己的 Outbox 视图
delivery_attempts_queue_idxdelivery_attempts(queue_id, attempt) — 某条队列行的尝试历史
devices_uid_keydevices(user_id, device_uid) 唯一 — 幂等的设备 upsert
devices_user_idxdevices某用户的设备列表
sessions_token_keysessions(token_hash) 唯一 — 刷新令牌查找
sessions_user_idxsessionsAdmin 中的「活跃会话」
sessions_expiry_idxWHERE revoked_at IS NULLsessions部分索引:过期清理器
client_sync_states_keyclient_sync_states(device_id, mailbox_id, COALESCE(folder_id, 0)) 唯一 — 游标读写
client_sync_states_device_idxclient_sync_states(device_id, updated_at DESC) — 「这个设备在同步什么?」
drafts_user_idxdrafts(user_id, updated_at DESC) — 草稿列表
operations_user_idxoperations(user_id, created_at DESC) — 操作历史
operations_created_idxoperations(created_at)purge_older_than
change_log_cursor_idxchange_log(user_id, seq)changes_since(user_id, after, limit),同步查询
change_log_mailbox_idxchange_log(mailbox_id, seq) — 按地址同步
change_log_folder_idxchange_log(folder_id, seq) — 按文件夹同步
change_log_created_idxchange_log(created_at) — 保留期清理
audit_logs_actor_idx, audit_logs_action_idx, audit_logs_created_idxaudit_logsAdmin 审计的三个过滤器
login_attempts_email_idx, login_attempts_ip_idxlogin_attempts(email, created_at DESC)(ip, created_at DESC) — 限流窗口
login_attempts_created_idxlogin_attempts(created_at) — 保留期清扫

加粗的五个位于关键路径上。如果某个查询计划在 messages_live_idxmail_queue_due_idx 上出现顺序扫描,问题出在查询,而不是索引。

新增索引之前要知道两件事:messages.flags 上没有索引,因此 SEARCH UNSEEN 是文件夹内 的过滤扫描,对邮箱规模的文件夹没问题,对百万行的文件夹不行;正文也没有索引,所以 SEARCH BODY 是 _(计划中)_(imap.md §8)。


4. Maildir 布局与投递算法

4.1 布局

storage.layout = "maildir"(默认值)产生项目书 §13 中的布局:

<maildir_root>/
└── example.com/                       <- domains.name,小写、已净化
    └── alice/                         <- mailboxes.local_part,小写、已净化
        └── Maildir/                   <- INBOX
            ├── cur/                   邮件客户端已读;标志位在文件名里
            ├── new/                   已投递但尚未被客户端读取
            ├── tmp/                   写了一半的文件;永不读取
            ├── .Sent/{cur,new,tmp}
            ├── .Drafts/{cur,new,tmp}
            ├── .Trash/{cur,new,tmp}
            ├── .Junk/{cur,new,tmp}
            ├── .Archive/{cur,new,tmp}
            └── .Archive.2026/{cur,new,tmp}   <- IMAP 的 "Archive/2026"

storage.layout = "maildirperfolder" 则为每个文件夹生成一个 Maildir: <root>/example.com/alice/INBOX/{cur,new,tmp}…/Sent/{cur,new,tmp},不使用带点的 目录名。在文件名规则特殊的文件系统上选择它,见 imap.md §10。

Maildir::SUBDIRS 就是字面量 ["cur", "new", "tmp"]Maildir::INBOX"INBOX"

4.2 文件名

1758012751.M4821P3210.mail:2,S
└────┬───┘ └──┬───┘ └─┬─┘ │ └┬┘
  unix 秒数   pid _    主机 │  标志位:S = \Seen
              计数器       版本标记 "2,"

Maildir::unique_filename 构造 <secs>.<pid>_<counter>.<hostname>,并通过 with_info 追加 {sep}2,{flags}。唯一性在进程内由 AtomicU64 计数器保证,跨进程由 pid 保证; 主机名先经过 sanitize_component,失败时回退为 ferroma

分隔符在 Unix 上是 :,在 Windows 上是 ;,读取时两者都接受。完整解释见 imap.md §10。

4.3 投递算法

Maildir::store(domain, local_part, folder, bytes, flags)

 1. dir = folder_dir(domain, local_part, folder)
       └─ 对域与本地部分做 sanitize_component,
          对文件夹做 maildir_folder_name  (路径穿越防御)
 2. create dir/{cur,new,tmp} if missing
 3. maildir_flags = flags_to_maildir(flags)      // "seen" -> "S"
 4. filename = unique_filename(maildir_flags)
 5. tmp_path   = dir/tmp/<filename>.tmp
 6. final_sub  = if maildir_flags.is_empty() { "new" } else { "cur" }
    final_path = dir/<final_sub>/<filename>
 7. std::fs::write(tmp_path, bytes)                  ← 整封邮件
 8. if storage.fsync_on_write: sync_all() on tmp_path
 9. std::fs::rename(tmp_path, final_path)            ← 原子步骤
10. if storage.fsync_on_write: sync_all() on dir/<final_sub>
11. return StoredMessage { path (relative), size, sha256 }

三个性质,以及它们各自为什么重要:

原子性。 同一文件系统内的 rename(2) 是原子的:读取者要么看不到文件,要么看到完整 的文件。因此一封邮件永远不会被观察到写了一半。tmp/ 这一步的全部理由就在这里,也正是 读取者绝不能查看 tmp/ 的原因:Maildir::iter_messages 跳过任何以 .tmp 结尾的文件, Maildir::sweep_tmp(older_than_secs) 删除残留。

持久性。 第 8 步和第 10 步冲刷数据*以及*目录项,因此断电不会留下一个指向未冲刷块的 rename。fsync 会牺牲吞吐;storage.fsync_on_write = true 是默认值,因为一台先应答 DATA 再丢失邮件的邮件服务器已经违背了它唯一的承诺。使用带电池保护存储的运维者可以关掉它。

名字的幂等性。 store 从不覆盖:每次调用都分配一个新的计数器值。set_flags 才是 幂等的那个,它计算出目标文件名,在无需移动时原样返回原路径。

4.4 Maildir API 的其余部分

函数行为
read(relative_path)整封邮件;文件缺失是 StorageError::BodyMissing,不是通用 IO 错误
read_prefix(relative_path, limit)limit 个字节,供只取头部的 FETCH 使用
delete(relative_path)unlink;已经不存在时返回 Ok(()),因此重试安全
set_flags(relative_path, flags)重命名文件,并随标志位集合变为非空或空而在 new/cur/ 之间移动;返回可能是新值的路径
move_message(relative_path, domain, local_part, to_folder, flags)读取、存入目标、删除源
iter_messages(domain, local_part, folder)cur/new/ 中的每个文件,跳过 .tmp;返回 MaildirEntry { path, size, maildir_flags, modified_secs }
usage(domain, local_part)邮箱根下的总字节数,用于配额核对
sweep_tmp(older_than_secs)删除早于阈值的废弃 tmp/ 文件
ensure_mailbox / create_folder / delete_folder / rename_folder / list_folders / folder_exists文件夹生命周期;INBOX 受保护,不可删除与重命名
absolute(relative_path)路径穿越闸门,见 §7

注意 move_message 的不对称:它是先复制再删除,而不是 rename。跨文件夹移动可能跨越 文件系统边界,而且复制路径也让目标能够为新标志位集合获得一个重新编码的文件名。两份副本 同时存在的窗口是无害的:数据库行随后在一条语句里更新,因此事务提交之后,没有任何东西 指向源文件。


5. 配额记账

两个数字:users.quota_bytes(默认 limits.mailbox_quota = 1073741824 = 1 GiB)与 mailboxes.quota_bytesNULL = 继承属主的配额)。FoldersRepository 不参与;配额按 地址计算,因为那才是 SMTP 投递的单位。

步骤位置
读取有效上限MailboxesRepository::quota(mailbox_id)
读取当前用量MailboxesRepository::used_bytes(mailbox_id)SELECT used_bytes FROM users
判定MailboxesRepository::check_quota(mailbox_id, needed) — 返回 StorageError::QuotaExceeded { mailbox_id, used, needed, limit }
写入后调整MailboxesRepository::add_usage(mailbox_id, delta_bytes)
从磁盘核对MailboxesRepository::recompute_usage(mailbox_id) — 用 Maildir::usage 遍历 Maildir 并写回总量

执行位置,以及从外部看到的执行结果:

路径检查时机结果
收信 SMTPMaildir 写入之前,DATA 完整之后452 4.2.2 Mailbox full临时失败,因此发件人会重试(smtp.md §12.1)
POST /api/v1/messages(发信)写 Sent 副本之前、入队之前413 limit_exceeded
POST /api/v1/attachments附件被挂到目标邮箱时413 limit_exceeded
IMAP APPENDliteral 落盘之前NO [OVERQUOTA]
IMAP/API 的标志位变更与移动不检查,它们不改变总量

used_bytes 是缓存,recompute_usage 是修正它的方式。它可能在文件写入与计数器更新之间 发生崩溃后漂移,也可能在运维者手工往 Maildir 里添加文件后漂移。核对方式如下:

-- 数据库认为的情况,按地址列出。
SELECT m.id, d.name || '@' || m.local_part AS address, u.used_bytes
  FROM mailboxes m
  JOIN domains d ON d.id = m.domain_id
  JOIN users   u ON u.id = m.user_id
 ORDER BY u.used_bytes DESC;
# 某个地址在磁盘上实际占用多少。
du -sb /var/lib/ferroma/mail/example.com/alice

如果两者不一致,recompute_usage 就是修复手段,而这个差值本身值得追查:明显为正的差值 意味着有文件在 Ferroma 背后被删除,明显为负的差值意味着一次被中断的写入。

storage.enforce_quota = false 完全关闭这项检查。它服务于迁移,也服务于宁可先全部投递、 之后再收拾的运维者;处于该模式的服务器会把磁盘写满。


6. 附件:内容寻址与 GC

AttachmentStore 位于 crates/ferroma-storage/src/attachment.rs

6.1 寻址

<attachment_root>/ab/cd/abcdef0123…      <- 内容的 SHA-256,十六进制、小写
                  └┬┘└┬┘└─────┬─────┘
               字节 0-1  2-3   完整摘要

AttachmentStore::path_for_digest(digest_hex) 构造该路径,并以 StorageError::Invalid 拒绝短于四个字符或含非十六进制字符的摘要。两级分片让任何一个目录都不会装下几十万个条目, 这正是遍历二进制存储根时 readdir 在 ext4 与 NTFS 上都便宜的原因。

按内容寻址带来的后果:

性质效果
相同内容只存一次一个在组织内被到处转发的 PDF 只占一个 blob,无论有多少 attachments 行指向它
store 是幂等的两次存入相同的字节会返回 StoredBlob { deduplicated: true } 而不写入
ETag 就是 SHA-256GET /api/v1/attachments/:id 无需额外工作就能提供强校验器,重复下载也永不重新传输(fcp.md §6)
blob 无法被静默损坏文件名*就是*校验和,对整个树跑 sha256sum 即可验证整个存储

6.2 写入一个 blob

1. digest   = sha256(data)
2. relative = path_for_digest(digest)          // "ab/cd/<sha256>"
3. if <root>/<relative> is already a file: return { deduplicated: true }
4. create_dir_all(parent)
5. tmp = parent/".{digest[4..16]}.{pid}.tmp"
6. write(tmp, data)
7. if storage.fsync_on_write: sync_all(tmp)
8. rename(tmp, final)      // Unix:内容相同时是原子覆盖
                           // Windows:先写入者胜出
9. return { path, size, sha256, deduplicated: false }

第 8 步是一场结果良性的竞争:如果两个并发写入者产生相同的摘要,rename 要么用相同的字节 覆盖(Unix),要么因为目标已存在而失败(Windows),而 Err(_) if final_path.is_file() 分支会清理临时文件并报告成功。无论哪种情况,字节都与文件名相符。

6.3 垃圾回收

删除一行 attachments 不会删除 blob。 它做不到:另一封邮件可能引用同一个摘要,而 这个存储没有引用计数。因此 AttachmentStore::gc(keep) 接收一个集合,一趟完成工作:

for every file under the blob root:
    if the name ends in .tmp          -> remove  (一次被中断的写入)
    if keep contains the file name    -> keep
    if keep contains the relative path-> keep    (两种形式都接受)
    otherwise                         -> remove

keep 集合由 AttachmentsRepository::referenced_paths() 产生 (SELECT DISTINCT storage_path FROM attachments),调用方是 POST /api/v1/storage/gc _(计划中)_,或今天的直接调用。给重新实现它的人两条规则:

Maildir::sweep_tmp(older_than_secs) 对邮件是等价物,但带一个阈值:比阈值更年轻的 tmp/ 文件可能属于另一个任务上仍在进行的投递,因此零阈值清扫只在已停止的服务器上安全。

6.4 验证二进制存储

# 每个 blob 的文件名都必须等于其内容的 SHA-256。
cd /var/lib/ferroma/attachments
find . -type f ! -name '*.tmp' -printf '%f %p\n' | while read digest path; do
    actual=$(sha256sum "$path" | cut -d' ' -f1)
    [ "$actual" = "$digest" ] || echo "CORRUPT: $path (name $digest, content $actual)"
done

一切正常时输出示意,命令不打印任何内容。附件messages.checksum_sha256 覆盖; 文件名就是校验和,因此这项检查完全不需要数据库。


7. 路径穿越防御

有三处会接收外部字符串并把它变成文件系统路径:域名、本地部分、文件夹名,以及邮件的 storage_path 与附件的 storage_path。三处都经过两道闸门之一。

7.1 sanitize_component

/// 拒绝任何可被用来逃出邮件根目录的内容。
pub fn sanitize_component(component: &str) -> Result<String> {
    let trimmed = component.trim();
    if trimmed.is_empty() {
        return Err(StorageError::Invalid("empty path component".into()));
    }
    if trimmed == "." || trimmed == ".." {
        return Err(StorageError::Invalid(format!("invalid path component: {trimmed}")));
    }
    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') {
        return Err(StorageError::Invalid(format!(
            "path component contains a separator: {trimmed}"
        )));
    }
    if trimmed.contains(':') {
        return Err(StorageError::Invalid(format!(
            "path component contains a colon: {trimmed}"
        )));
    }
    Ok(trimmed.to_string())
}

它在文件夹路径拼接之前对每一段调用(maildir_folder_name),在 Maildir::mailbox_dir 中对域与本地部分调用,在 Maildir::new 中对主机名调用。它的测试覆盖了有意思的输入:

for bad in ["..", ".", "a/b", "a\\b", "", "  ", "a:b", "x\0y"] {
    assert!(sanitize_component(bad).is_err(), "should reject {bad:?}");
}

拒绝 : 是刻意的,尽管 : 在 Unix 上合法:它是 Maildir 的信息分隔符,名为 a:b 的 文件夹会产生一个与带标志位文件名有歧义的目录名。在 Windows 上它同时还是 NTFS 的备用 数据流。

名字是按段校验的,因此 IMAP 文件夹 Archive/2026 可以通过(每一段都干净),而 Archive/../../etc 会在第三段被拒绝,而不是靠对整个字符串做模式匹配。

7.2 absolute — 相对路径闸门

两个存储各有一个,行为相同:

/// 把相对路径变成绝对路径,拒绝逃出根目录。
pub fn absolute(&self, relative_path: &str) -> Result<PathBuf> {
    let candidate = Path::new(relative_path);
    if candidate.is_absolute() {
        return Err(StorageError::Invalid(format!(
            "storage path must be relative: {relative_path}"
        )));
    }
    for component in candidate.components() {
        match component {
            std::path::Component::ParentDir
            | std::path::Component::RootDir
            | std::path::Component::Prefix(_) => {
                return Err(StorageError::Invalid(format!(
                    "storage path escapes the mail root: {relative_path}"
                )));
            }
            _ => {}
        }
    }
    Ok(self.root.join(candidate))
}

Maildir::absolute(邮件根)与 AttachmentStore::absolute(二进制存储根)是把存储的 storage_path 变成真实路径的唯一函数。readread_prefixdeleteset_flagsexists 都先调用它,因此即使数据库被攻陷,也没有任何代码路径能打开根目录之外的文件:

assert!(m.absolute("../../etc/passwd").is_err());
assert!(m.absolute("/etc/passwd").is_err());
assert!(s.absolute("../../secret").is_err());

Component::Prefix(_) 在 Windows 上很要紧:没有它,C:\Windows\... 会被当成相对路径 拼接到根目录上。

互补的函数是 Maildir::relative,它剥掉根目录并回退到 with_info 风格的规范化;不以 根目录开头的路径会得到 StorageError::Invalid("path outside the mail root"),而不是被 静默存成一个绝对路径。


8. 备份与恢复

完整的运维流程在 deployment.md §8。原则部分属于这里。

8.1 两半一起恢复

scripts/backup.sh 产出一个带时间戳的目录,其中包含:

文件内容
ferroma.dumppg_dump --format=custom --compress=6,整个数据库
schema.sqlpg_dump --schema-only,空但正确的结构
maildir.tar.gz邮件根的 tar -czf
config.tar.gzferroma.toml、DKIM 密钥、TLS 材料(排除 *.envcredentials*
MANIFESTferroma_backup_version=1created_atdatabasepostgres_versionferroma_versionhostname
SHA256SUMSsha256sum ./*,让恢复过程能证明归档在传输中完好

脚本自身的头部写明了规则:

只包含前两项之一的备份不是备份:数据库说某封邮件存在,Maildir 持有它的字节,单独恢复 任何一半,得到的要么是一个满是悬空行的邮箱,要么是一个满是孤儿文件的目录。

具体来说,两种失败模式是:

恢复了结果
只有数据库每条 messages.storage_path 都指向一个不存在的文件。每次读取都是 StorageError::BodyMissing;用户看到一个只有主题、没有正文的收件箱
只有 Maildir文件存在,但没有任何东西知道它们。文件夹显示为空;在运维者手工重新导入之前,这些字节是不可见的

两者都无法由应用恢复,这就是为什么 scripts/restore.sh 在你要求 --db-only--mail-only 时会打印警告,也是为什么 compose 的备份 sidecar 同时挂载两者。

8.2 在线备份的一致性

如果你需要完全一致的一对,停掉容器:

docker compose stop ferroma
docker compose run --rm backup
docker compose start ferroma

这是唯一能保证没有在途事务被拆到两半的方式。对大多数部署来说,在线备份已经足够正确, 因为孤儿文件是无害的,而漏掉的邮件会被发信方 MTA 重试。

8.3 恢复顺序

scripts/restore.sh 遵循项目书 §47,按依赖图要求的顺序执行:

0. verify      sha256sum -c SHA256SUMS   (abort on mismatch)
               print MANIFEST
1. database    pg_restore --no-owner --no-privileges --exit-on-error
               refuses a non-empty database unless FORCE_RESTORE=1
2. mail store  tar -xzf maildir.tar.gz
3. config      tar -xzf config.tar.gz

数据库放第一位,因为它是定义「应该存在什么」的那一半;Maildir 第二位,这样到服务器启动时 每一行都已经有了自己的文件。配置放最后,因此一次中途失败的恢复不会留下一个指向错误 TLS 证书的运行中服务器。

restore_db 拒绝覆盖已有内容的数据库,给出的消息里包含它找到的表数量:

database ferroma is not empty (19 tables). Set FORCE_RESTORE=1 to overwrite,
or restore into a fresh database.

(19 是 0001_initial.sql 创建的表数量;真实消息里的数字是 information_schema.tablespublic 报告的数量。)

这道守卫是脚本里最有价值的一行。静默合并两个邮件存储,正是运维者丢掉一周邮件的方式, 而没有任何自动化工具能分辨「在现有库上恢复」和「糟糕,连错数据库了」。

8.4 还必须保留什么

项目原因存放位置
ferroma.toml上限、端口、TLS 路径、api.public_urlserver.hostnameconfig/ferroma.toml,以只读方式挂载
[api] jwt_secret / FERROMA_JWT_SECRET没有它,每次重启都会让所有会话失效环境变量,在配置归档里
DKIM 私钥丢失意味着所有签名失效、DMARC 开始失败domains.dkim_private_key /etc/ferroma/dkim/*.private
TLS 证书与私钥丢失意味着在重新签发之前 TLS 一直中断/etc/ferroma/tls/
PostgreSQL 角色与数据库恢复需要有地方可恢复进去postgres 服务

JWT 密钥刻意被排除在 config.tar.gz 之外 (--exclude='*.env' --exclude='credentials*'),因为备份卷的保护可能弱于密钥存储。 把它放在你用于密钥的任何地方,见 security.md §10 与 deployment.md §4。


9. 完整性检查与修复

要验证的不变量:每条 live 的 messages 行在其 storage_path 处都有一个可读文件, 且邮件根下的每个文件都属于某一行。

9.1 找出没有正文的行

-- 候选集合:live 邮件。与文件系统比对。
SELECT m.id, m.mailbox_id, m.uid, m.size_bytes, m.storage_path
  FROM messages m
 WHERE m.expunged_at IS NULL
  ORDER BY m.id;
# 文件缺失的行。
psql "$DATABASE_URL" -Atc \
  "SELECT storage_path FROM messages WHERE expunged_at IS NULL" |
while read -r p; do
    [ -f "/var/lib/ferroma/mail/$p" ] || echo "MISSING: $p"
done

正文缺失在生产中表现为 StorageError::BodyMissing,它在 IMAP 上变成 NO [SERVERBUG] message body missing,在 API 上变成 404 not_found。这意味着文件系统 在 Ferroma 背后被改动过,或者一次恢复放回了一个比 Maildir 更新的数据库。

9.2 找出没有行的文件

# 邮件根下的孤儿候选:相对路径不在任何行中的文件。
psql "$DATABASE_URL" -Atc \
  "SELECT storage_path FROM messages" | sort > /tmp/known.txt
cd /var/lib/ferroma/mail
find . -type f ! -path '*/tmp/*' | sed 's|^\./||' | sort > /tmp/on_disk.txt
comm -13 /tmp/known.txt /tmp/on_disk.txt        # 在磁盘上,但不在数据库里

孤儿是被中断的投递以及崩溃后 hard_delete 的预期残留,而且它们是安全的那种失败方向。 它们不会被自动删除:一个因为恢复只做了一半而看起来像孤儿的文件,可能是某人邮件唯一的副本。

9.3 检查校验和

messages.checksum_sha256storage.checksum = true 时写入 (StoredMessage.sha256,小写十六进制):

psql "$DATABASE_URL" -Atc \
  "SELECT storage_path, checksum_sha256 FROM messages
    WHERE expunged_at IS NULL AND checksum_sha256 IS NOT NULL" |
while IFS='|' read -r p want; do
    got=$(sha256sum "/var/lib/ferroma/mail/$p" | cut -d' ' -f1)
    [ "$got" = "$want" ] || echo "CORRUPT: $p (want $want, got $got)"
done

9.4 检查计数器

folders.message_countunseen_counttotal_bytes,以及 users.used_bytes,都是 缓存。它们可以由其所汇总的数据修正:

-- 每个文件夹的计数器应该是多少。
SELECT f.id, f.name, f.message_count,
       COUNT(m.id) FILTER (WHERE m.expunged_at IS NULL) AS actual,
       COUNT(m.id) FILTER (WHERE m.expunged_at IS NULL
                            AND m.flags NOT LIKE '%seen%') AS actual_unseen,
       COALESCE(SUM(m.size_bytes) FILTER (WHERE m.expunged_at IS NULL), 0) AS actual_bytes
  FROM folders f
  LEFT JOIN messages m ON m.folder_id = f.id
 GROUP BY f.id, f.name, f.message_count
HAVING f.message_count <> COUNT(m.id) FILTER (WHERE m.expunged_at IS NULL)
 ORDER BY f.id;

FoldersRepository::recount(folder_id) 是修复手段:它重算全部三个计数器并返回更新后的 FolderMailboxesRepository::recompute_usageusers.used_bytes 做同样的事。

message_count 不对只是表面问题,文件夹列表会显示错误的数字。uid_next 不对则不是: 它是 UID 分配器,而 MessagesRepository::max_uidSELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder_id = $1)是它至少应达到的 值。如果 uid_next 曾经落后于 max_uid,下一次投递会分配一个已经存在的 UID,而 (folder_id, uid) 上的唯一索引会拒绝它。由于 messages_folder_uid_key,这是一次响亮 的失败,而不是静默覆盖。

9.5 每种发现该如何处理

发现处理
正文缺失从包含它的备份中恢复,或硬删除该行并记录日志。没有任何办法从元数据重建 RFC 5322 字节
孤儿文件放着不动,或移到隔离目录。在确认恢复完整之前不要删除
校验和不匹配文件在磁盘上变了。恢复它;不要「修」数据库
计数器漂移FoldersRepository::recount / MailboxesRepository::recompute_usage
uid_next 落后于 max_uid设置 uid_next = max_uid + 1 并递增 uid_validity,UID 空间已被篡改(imap.md §5.3)
无人引用的 blob用刚收集的 keep 集合跑 AttachmentStore::gc
残留的 tmp/ 文件在线服务器用 Maildir::sweep_tmp(3600),已停止的服务器用 sweep_tmp(0)

脚本与 API 提到了一个 ferroma storage verify 子命令来包装上述全部检查,以及一个 POST /api/v1/storage/gc 端点来运行 blob 回收器。两者都是 _(计划中)_:ferroma 二进制还没有实现子命令接口(server/src/main.rs 打印构建横幅后退出)。在它们存在之前, 本节中的 psql 与 shell 片段就是操作流程。


10. 决定存储形态的配置

默认值作用
server.data_dir./data两个根目录的基准
storage.maildir_root<data_dir>/mailMaildir 根
storage.attachment_root<data_dir>/attachments二进制存储根
storage.fsync_on_writetrue在应答 DATA 之前 fsync 邮件与其目录项
storage.layout"maildir""maildir"(Maildir++ 点号文件夹)或 "maildirperfolder"
storage.checksumtrue存储每封邮件与每个附件的 SHA-256
storage.enforce_quotatrue拒绝超出配额的写入
storage.soft_deletetrue移入 Trash 而不是立即 unlink
database.urlpostgres://ferroma:ferroma@localhost:5432/ferroma连接字符串
database.max_connections / min_connections20 / 2连接池上限
database.run_migrationstrue启动时应用 migrations/*.sql
database.log_statementsfalse绝不要在生产环境启用,它会打印邮件主题
limits.mailbox_quota1073741824新用户的默认配额

Config::validate() 在以下情况拒绝启动:storage.maildir_root 被设为空路径, database.url 不是 postgres:///postgresql:// URL,或 database.min_connections > database.max_connections


11. 相关文档

主题文档
为什么 mailboxesfolders 是分开的,以及 rfc_message_idarchitecture.md §9
Maildir++ 文件夹命名、UID/UIDVALIDITY、标志位映射imap.md §4、§5、§6
变更日志、游标、墓碑sync.md
SMTP 上的配额应答、重试调度、退信smtp.md §7、§11
备份命令、DNS、TLS、恢复演练deployment.md §8
什么从不记入日志、密钥处理security.md §10
Ferroma · MIT OR Apache-2.0 · 由 docs/ 生成