Ferroma
协议 · 文档

Ferroma 中的 SMTP

本文读者: 所有实现、测试或排查 ferroma-smtp 的人,以及任何想弄清远端服务器 为何拒收自己邮件的运维者。

本文覆盖 SMTP 的两个方向。收信侧既包括从其它 MTA 接收邮件的监听器,也包括从你自己 用户的邮件客户端接收邮件的 submission 监听器。发信侧是解析 MX 记录、把你用户的邮件 投递到远端主机的队列工作器。本文规定命令集、会话状态机、每一种失败对应的应答码、 各项限制以及每项限制在哪一层强制执行、Ferroma 前置的 Received: 头字段、重试计划、 4xx 与 5xx 的分类规则,以及退信生成。

状态: 设计规范。ferroma-smtp crate 是照着本文档实现的;标注 (计划中) 的 小节描述的是已经规定但尚未交付的行为。就目前而言,本文件的全部内容都是 _(计划中)_: crates/ferroma-smtp/src/lib.rs 只是一个骨架,声明了 server/client/MX/policy 的 模块边界。它提到的配置键、限制值和错误变体确实已经存在—— config/ferroma.toml 中的 [smtp][limits]crates/ferroma-core/src/limits.rs 中的 Limitscrates/ferroma-core/src/error.rs 中的 FerromaError,以及 migrations/0001_initial.sql 中的 mail_queue / delivery_attempts 两张表。


1. 端口与监听器角色

端口配置键角色TLS
25smtp.port收信 MX。接收来自其它 MTA 的邮件。未认证的对等端只能投递到本地域。明文,提供 STARTTLS
587smtp.submission_portSubmission。供你用户的邮件客户端使用。MAIL FROM 之前必须先认证。明文,策略要求 STARTTLS
465smtp.smtps_port隐式 TLS。策略与 587 相同;握手在最前面。0 表示关闭该监听器。从第一个字节起就是 TLS

smtp.smtps_port != 0tls.enabled = false 时,当两个启用的 SMTP 端口冲突时, 或者当 smtp.port0 时,Config::validate() 会拒绝启动。见 crates/ferroma-core/src/config.rs

连接到达的是哪个端口属于会话的一部分,而不是另一条代码路径:它选择的是*策略配置档* (认证是否强制,以及未认证的事务是否可以向非本地域投递)。


2. 命令集(§9)

项目书 §9.1 把命令集拆成两个阶段。

阶段命令状态
MVPEHLOHELOMAIL FROMRCPT TODATARSETNOOPQUIT第一个版本
第二阶段AUTHSTARTTLSsubmission 在第一个版本即支持;25 端口上的 STARTTLStls.enabled 后立即支持

实现额外接受的命令,以及定义它们的 RFC:

命令RFC说明
VRFY5321 §4.1.1.6(计划中) 应答 252 2.5.2 Cannot VRFY user——绝不确认某个地址是否存在
HELP5321 §4.1.1.4(计划中) 214 2.0.0 加一行摘要
EXPN5321 §4.1.1.7(计划中) 502 5.5.1 Command not implemented
STARTTLS3207仅在 tls.enabled 且尚未加密时可用
AUTH4954只支持 PLAINLOGIN
BDAT3030(计划中) 502——不宣告 CHUNKING

其它任何输入都得到 500 5.5.2 Command unrecognized

命令行


3. 状态机

项目书 §9.2 给出该枚举;已交付的实现原样使用它,并补上 STARTTLS 需要的加密标志。

/// crates/ferroma-smtp/src/server/state.rs
pub enum SmtpState {
    Connected,      // 已发出问候语,尚未收到任何内容
    Greeted,        // EHLO/HELO 已被接受
    MailFrom,       // MAIL FROM 已被接受,尚无收件人
    RcptTo,         // 至少一个 RCPT TO 已被接受
    Data,           // 已发出 354,正在读取以点号终止的正文
    Authenticated,  // SASL 成功(与上面五个状态正交)
}

迁移:

                    ┌──────────────┐
   connect ────────►│  Connected   │ 220 <smtp.banner>
                    └──────┬───────┘
        EHLO/HELO ─────────┤          250-… / 250 SIZE n
                           ▼
                    ┌──────────────┐
                    │   Greeted    │◄──── RSET (from any state)
                    └──────┬───────┘
      MAIL FROM:<…> ───────┤          250 2.1.0
                           ▼
                    ┌──────────────┐
                    │   MailFrom   │
                    └──────┬───────┘
      RCPT TO:<…> ─────────┤          250 2.1.5  (repeatable)
                           ▼
                    ┌──────────────┐
                    │    RcptTo    │
                    └──────┬───────┘
         DATA ─────────────┤          354 End data with <CR><LF>.<CR><LF>
                           ▼
                    ┌──────────────┐
                    │     Data     │  (body with limits.data_timeout_secs)
                    └──────┬───────┘
        end-of-data ───────┴─────────► 250 2.0.0 Ok: queued as <id>  → Greeted
                                   └──► 4xx / 5xx                    → Greeted

Authenticated 不是该序列中的一个位置;它是一个标志,能跨 RSET 保留,并改变会话 被允许做什么。AUTH 在 Greeted 及之后的状态合法,在 Connected 中永远不合法 (503 5.5.1 Send HELO/EHLO first),在 Data 期间也永远不合法。

各项守卫,以及守卫触发时的应答:

守卫触发条件应答
helo_requiredMAIL FROM 在未发 EHLO/HELO 时到达503 5.5.1 Send HELO/EHLO first
顺序RCPT TO 出现在 MAIL FROM 之前503 5.5.1 Need MAIL FROM before RCPT TO
顺序DATA 时没有任何已被接受的收件人503 5.5.1 Need RCPT TO before DATA
require_auth_on_submissionsubmission 端口上的 MAIL FROM 未经 AUTH530 5.7.0 Authentication required
require_tls_for_auth未加密连接上的 AUTH538 5.7.11 Encryption required for requested authentication mechanism
嵌套 MAIL事务中出现第二条 MAIL FROM503 5.5.1 Sender already specified
嵌套 DATA已经处于 Data 时又来 DATA不可能:正文读取器会一直消费到终止符

4. 会话结构体

项目书 §9.3:

/// crates/ferroma-smtp/src/server/session.rs
pub struct SmtpSession {
    /// Unique per accepted connection; also the `connection_id` log field.
    pub connection_id: Uuid,
    /// TCP peer address. Taken from `X-Forwarded-For` only when
    /// `api.trust_proxy_headers` is set and the peer is a trusted proxy.
    pub remote_addr: SocketAddr,
    /// The handler the connection is talking to.
    pub port: SmtpPort,          // Mx | Submission | Smtps
    pub state: SmtpState,
    /// The name the peer announced. Used in `Received:`, never trusted.
    pub helo: Option<String>,
    /// `true` once STARTTLS completed. Gates `require_tls_for_auth`.
    pub encrypted: bool,
    /// Set by a successful `AUTH`; the account whose quota and rate limits apply.
    pub authenticated_user: Option<UserId>,
    /// The session row created by `AuthService::open_session(SessionKind::Smtp)`.
    pub session_id: Option<SessionId>,
    /// `MAIL FROM` reverse-path, empty for a null sender (`<>`, a bounce).
    pub envelope_from: Option<String>,
    /// Accepted recipients, in order, after aliases and catch-all expansion.
    pub recipients: Vec<String>,
    /// `SIZE=` announced on `MAIL FROM`, when the client sent one.
    pub declared_size: Option<u64>,
    /// `BODY=` / `SMTPUTF8` parameters seen on `MAIL FROM`.
    pub body_8bit: bool,
    pub smtp_utf8: bool,
}

每个可以被记录的字段都出现在项目书 §40 列出的结构化字段中(connection_idremote_ipheloauthenticated_usersenderrecipientmessage_idresultduration)。ferroma-corelogging 模块安装携带这些字段的订阅者。

会话是每连接一份的,并且活在一个 Tokio 任务上。它没有任何共享成分,因此状态机无需 加锁;*确实*共享的计数器(连接总数、按 IP 的速率窗口)放在监听器里。


5. 宣告的 EHLO 扩展

EHLO 的应答是每个能力一行,并且整组扩展只在 smtp.advertise_extensions = true 时才发送;当它为 false 时,会话以 250-<hostname> 加一个光秃秃的 250 OK 应答, 这正是那种会因扩展而噎住的远古客户端所需要的。

何时宣告含义
250-<server.hostname>总是问候行
250-PIPELININGsmtp.advertise_extensions客户端可以批量发送命令而不等待应答
250-SIZE <limits.max_message_size>smtp.advertise_size可接受的 DATA 最大字节数
250-8BITMIMEsmtp.advertise_extensions接受 BODY=8BITMIME
250-ENHANCEDSTATUSCODESsmtp.advertise_extensions应答携带 x.y.z 状态码(见 §11)
250-SMTPUTF8smtp.advertise_extensions接受 UTF-8 的本地部分
250-DSN(计划中) policyMAIL FROMRCPT TO 上处理 RET/ENVID 参数
250-STARTTLStls.enabled 且尚未加密客户端可以原地升级
250-AUTH PLAIN LOGINtls.enabledsmtp.require_tls_for_auth = falseSASL 机制
250-HELP(计划中)已实现 HELP
250 CHUNKING从不未实现 BDAT;不要宣告它

smtp.require_tls_for_auth = true 时,在未加密的连接上完全不给 AUTH。宣告一个 会话会拒绝的机制,比不宣告它更糟:那会让客户端在明文发出了它本不该发出的凭据之后 才失败。


6. 开放中继策略,以及它为何是默认值

项目书 §9.4 与 §54:

recipient domain is a local domain   →  accept (subject to quota and limits)
recipient domain is anything else    →  require a successful AUTH first

具体到 RCPT TO 的处理中:

连接收件域结果
未认证,25 端口本地(domains.name 匹配,domains.enabled接受,本地投递
未认证,25 端口非本地550 5.7.1 Relaying denied
已认证,任意端口本地接受
已认证,587/465 端口非本地接受,入队到 mail_queue
已认证,25 端口非本地接受并入队——账号已认证,因此这是提交,不是中继

为什么默认值就是它。 开放中继不是一处配置上的不便;它是一台洗白垃圾邮件的机器, 几小时内就会被列入黑名单,并把运维者自己的正常邮件一起拖下水。每一位邮件管理员都 见过它发生,损害是以数月的投递率来计量的,而不是以分钟的停机。因此,让安全的行为 成为默认值、让不安全的行为因打错字而不可达,值得多花那一点点配置:没有任何 allow_relay = true 键可以被偶然找到并设置,而 [../AGENTS.md](../../AGENTS.md) §4.6 把这条规则列为不可协商。

由同一套推理得出的两个相邻策略决定:


7. 各项限制及其强制执行位置

每一项限制都在协议边缘强制执行,并且在邮件核心中复查,因此改用 API 而不是 SMTP 也无法绕过它。crates/ferroma-core/src/limits.rs 是唯一定义处; config/ferroma.toml 中的 [limits] 是唯一的取值来源。

限制配置键默认值强制执行位置应答或错误
邮件大小limits.max_message_size26214400 (25 MiB)DATA 正文读取器,依据 SIZE 扩展并按八位组计数;Maildir 写入前由邮件核心复查552 5.3.4 Message size exceeds fixed maximum message size
提前声明的大小MAIL FROM SIZE=MAIL FROM 时,在 354 之前552 5.3.4 Message size exceeds fixed maximum message size
每事务收件人数limits.max_recipients100RCPT TO 计数器452 4.5.3 Too many recipients
全局同时连接数limits.max_connections100监听器 accept 循环421 4.3.2 Too many connections, try again later,随后关闭
每 IP 同时连接数limits.max_connections_per_ip10监听器 accept 循环,按源地址421 4.3.2 Too many connections from your address
每 IP 每分钟收信命令数limits.smtp_rate_limit100命令循环,按 IP 的滑动窗口421 4.7.0 Too many commands, slow down
每账号每小时 submission 邮件数limits.submission_rate_limit50接受已认证事务时452 4.7.0 Submission rate limit exceeded
每账号每天邮件数limits.daily_send_limit500接受时,从 mail_queue 统计,而非从内存452 4.7.0 Daily send limit exceeded
邮箱配额users.quota_bytesmailboxes.quota_byteslimits.mailbox_quota1 GiBMaildir 写入前的 MailboxesRepository::check_quota(mailbox_id, needed)452 4.2.2 Mailbox full——临时性错误,因此发件人在用户腾出空间后会重试
命令超时smtp.command_timeout_secs300命令循环,每次读取421 4.4.2 Timeout waiting for command,随后关闭
DATA 超时smtp.data_timeout_secs600正文读取器421 4.4.2 Timeout waiting for data,随后关闭
MIME 嵌套深度limits.max_mime_depth20MIME 解析器(ferroma-mail 中的 ParseLimits邮件被接受进 Junk 而非被拒绝 (计划中)
每封邮件附件数limits.max_attachments50附件提取552 5.3.4 Too many message parts (计划中)
单个附件大小limits.max_attachment_size26214400附件提取552 5.3.4 Attachment too large (计划中)

max_connections_per_ip > max_connections 时,当 max_attachment_size > max_message_size 时,当 max_message_size0 时,或者当 max_mime_depth 落在 1–100 之外时,Limits::validate() 会拒绝启动。因此 [limits] 里的一个错字会在启动时 让服务器停下,而不是悄悄禁用一项限制。

按账号的限制才是对滥用真正要紧的那些:否则一个被攻陷的已认证账号就能被当作垃圾邮件 加农炮,速率只受主机上行带宽制约。submission_rate_limitdaily_send_limit接受时检查,并且每日计数取自 mail_queue 的行而不是内存计数器,因此重启不会把它 清零。


8. AUTH PLAINAUTH LOGIN

项目书 §15 规定口令存储使用 Argon2id,SMTP 使用 AUTH PLAIN / AUTH LOGIN

机制线上形式说明
PLAINAUTH PLAIN <base64(\0authcid\0passwd)>,或先 AUTH PLAIN 再一个 334 续行携带同一份数据块一次往返;续行形式是客户端在收到 334 之后使用的形式
LOGINAUTH LOGIN334 VXNlcm5hbWU6 → base64 用户名 → 334 UGFzc3dvcmQ6 → base64 口令两次往返;提示语是 Username:Password: 的常规 base64

初始应答 =(RFC 4954 §4)表示「空」,对两种机制都是解析错误: 501 5.5.4 Invalid base64 data

认证成功:

  1. 会话解码凭据并调用
    AuthService::login(email, password, SessionKind::Smtp, ip, user_agent, None)
  2. AuthService 对着存储的 Argon2id PHC 字符串校验
    PasswordHasher::verify),当 PasswordHasher::needs_rehash 判断存储参数已过期时
    透明地重新哈希,并通过 LoginAttemptsRepository 记录本次尝试。
  3. 成功后会话获得 authenticated_user,以及来自 sessions 表、kind = 'smtp'
    一个 SessionId
  4. 应答为 235 2.7.0 Authentication successful

失败应答——在「无此用户」与「口令错误」之间刻意不可区分,正如 AuthService::login 所做的那样:

情况应答
口令错误、账号不存在、账号已禁用535 5.7.8 Authentication credentials invalid
达到 limits.max_failed_logins,或账号被锁定454 4.7.0 Temporary authentication failure
窗口期内来自单一源 IP 的失败次数超过 3 × limits.max_failed_logins454 4.7.0 Temporary authentication failure
base64 格式错误、未知机制501 5.5.4 Invalid base64 data / 504 5.5.4 Unrecognized authentication type
在一次成功的 AUTH 之后再来 AUTH503 5.5.1 Already authenticated
在明文连接上 AUTH,而 smtp.require_tls_for_auth = true538 5.7.11 Encryption required for requested authentication mechanism

require_tls_for_auth

config/ferroma.toml 中默认为 false,这样一次裸 cargo run 无需证书即可工作。 docker-compose.prod.yml 设置 FERROMA__SMTP__REQUIRE_TLS_FOR_AUTH: 'true',生产 MX 应当保持这一设置:AUTH PLAINAUTH LOGIN 都用 base64 发送口令,而那是编码, 不是加密。在明文 587 端口上,被动观察者能读到明文口令。开启该标志后,在 STARTTLS 完成之前,AUTH 既不被宣告也不被接受。

Config::allows_plaintext_auth() 的存在,是为了让调用方无需从 TLS 标志再推导一遍 就能问出这个问题。


9. STARTTLS、SMTPS 与 submission 角色

端口发生什么策略
25EHLO 宣告 STARTTLS;客户端发送 STARTTLS,得到 220 2.0.0 Ready to start TLS,双方重新协商。会话回到 Connected,客户端必须重新发送 EHLO来自其它 MTA 的邮件被机会性地接受。拒绝明文收信会丢掉所有不做 TLS 的主机发来的邮件。
587同样的升级路径,但适用 submission 策略:require_auth_on_submission 意味着在 AUTH 成功之前 MAIL FROM 被拒绝,require_tls_for_auth 意味着在 TLS 成功之前 AUTH 被拒绝。Submission。不会 STARTTLS 的客户端无法发信。
465从第一个八位组起就是 TLS(SMTPS)。不宣告 STARTTLS——没有东西可升级。Submission。

那些容易弄错、因此被明确规定的细节:


10. Received: 头字段

smtp.add_received_header = true(默认值)时,收信邮件会被前置一个 Received: 头字段。它是前置而不是后置:Received: 头字段按最新在前的顺序累积,最上面的那条 是 Ferroma 知道的第一跳。

Received: from <helo> (<reverse-dns> [<remote-ip>])
        by <server.hostname> (Ferroma <version>)
        with ESMTPS id <connection_id>
        for <recipient>
        ; <date>

渲染示例(示意输出):

Received: from mail.example.net (mail.example.net [203.0.113.25])
        by mail.example.com (Ferroma 0.1.0)
        with ESMTPS id 0f4c9a12-6b1e-4d3f-9a77-2c1f0e5b8d41
        for <[email protected]>
        ; Tue, 16 Sep 2026 09:12:31 +0000

逐字段规则:

字段来源规则
from <helo>SmtpSession::helo对等端宣告的名字——不可信,仅展示,绝不用于任何决策
(<reverse-dns> [<remote-ip>])remote_addr 的 PTR 查询查询失败时整段省略;方括号里始终是字面 IP
by <host>server.hostname必须是合法 DNS 名,否则 Config::validate() 拒绝启动
with <protocol>会话SMTP(明文)、ESMTP(EHLO,明文)、ESMTPS(EHLO + TLS)、ESMTPSA(EHLO + TLS + AUTH)、ESMTPA(EHLO + AUTH,无 TLS)
id <connection_id>SmtpSession::connection_id与日志中出现的同一个 UUID,因此一行 Received: 与一行日志可以关联起来
for <recipient>第一个被接受的收件人有多个收件人时省略(否则会泄露其它收件人),这是 RFC 5321 §4.4 对多收件人邮件的做法
; <date>接受时刻的时钟RFC 5322 date-time,始终为 UTC、时区为 +0000

该头字段绝不包含两样东西:对等端的 IP 取自套接字,而不是对等端发来的任何头字段; 并且对等端发来的任何头字段都绝不会被复制进生成的那一行。

当设置了 policy.add_auth_resultspolicy.add_auth_results 时, Authentication-Results 会被单独加入;见 security.md §8。


11. 发信投递

项目书 §10 给出流水线;项目书 §11 给出队列状态与重试计划。

 SMTP submission / Webmail / Client API
                │
                ▼
            Mail Core                renders RFC 5322, signs DKIM,
                │                    writes the Sent copy
                ▼
            mail_queue                one row per recipient
                │
                ▼
        QueueRepository::claim_due()  status pending|retry → delivering
                │
                ▼
            DNS MX                    per recipient domain
                │
                ▼
       remote MX hosts, in preference order, :25
                │
                ▼
        SMTP client (EHLO → STARTTLS if offered → MAIL → RCPT → DATA)
                │
                ├── success  ──► delivered   + delivery_attempts row
                └── failure  ──► retry | failed, per §12

11.1 队列状态及承载它们的列

mail_queue.status 带有一个 CHECK 约束,恰好列出这些取值: pendingdeliveringdeliveredretryfailedcancelled

  pending ──► delivering ──┬──► delivered
     ▲                     │
     │                     ├──► retry ──► delivering ──► …
     └─────────────────────┘
                           └──► failed   (attempts exhausted, or a 5xx)
含义
mail_queue.message_id本次投递所针对的存储副本;ON DELETE CASCADE
mail_queue.sender信封反向路径
mail_queue.recipient信封正向路径——每个收件人一行,因此一个坏收件人不会拖慢其它收件人
mail_queue.attempts / max_attempts迄今尝试次数 / 上限(queue.max_attempts,12)
mail_queue.next_attempt_at调度器何时可以取走该行;mail_queue_due_idx 恰好索引 WHERE status IN ('pending','retry')
mail_queue.last_errorlast_status_codelast_status_text最近一次失败,供 Admin 队列界面使用
mail_queue.remote_mx尝试过的主机
mail_queue.delivered_at最终成功的时刻

每次尝试还会写入一行 delivery_attemptsqueue_idattemptremote_mxstatus_codestatus_texterrorduration_ms),Admin 的「Delivery Logs」 界面读的就是它。尝试是历史;队列行是状态。

11.2 MX 解析

(计划中) ferroma-smtp::mx::MxResolver 使用 hickory-resolver(由 [dns] 配置)。

  1. 查询收件域的 MX
  2. 按 preference 升序排序,相同 preference 内部用稳定的随机次序打破平局,这样重复
    尝试不会总是先打同一台主机——这也是 RFC 5321 §5.1 所推荐的,并且能防止一台死掉的
    MX 吸走所有重试。
  3. 按顺序尝试主机。 连接失败、4xx 问候语或 TLS 失败都会在同一次尝试内转向下一
    台主机。只有当每一台主机都失败时,这次尝试才被记为失败。
  4. Null MX。 单条 MX 0 . 表示该域不接受任何邮件。这是永久性失败(failed
    并且退信):在本地等价于 556 5.1.10,即 FerromaError::Invalid
  5. 没有 MX,但有 A/AAAA 记录。 按照 RFC 5321 §5.1 回退到地址本身。只有当
    回退也失败时,该域才不可投递。
  6. 既没有 MX 也没有地址。 FerromaError::Dns——按 §12.5 分类,会被归为临时性:
    一个域可能正处于注册过程中。
  7. CNAME 链由解析器跟进,受 dns.attempts 限制。
  8. 超时取自 [dns]timeout_secsattemptstcp_fallback)。

对单一主机的连接并发数由 queue.max_connections_per_host(4)封顶:用四百条并行连接 猛砸一台远端 MX,正是一台邮件服务器把自己搞进黑名单的方式。

11.3 重试计划(§11)

queue.retry_schedule_secs = [60, 300, 900, 3600, 21600, 86400]——一分钟、五分钟、 十五分钟、一小时、六小时、二十四小时。最后一项会一直重复,直到 attempts 达到 queue.max_attempts(12)。

QueueConfig::backoff_for_attempt(attempt) 就是该规则的实现:它把索引钳制到计划末尾, 因此第 7、8、… 次尝试都等待 86 400 秒,并在计划为空时返回 60。

尝试之前的延迟累计经过时间(约)
1立即(接受时)0
260 s1 min
3300 s6 min
4900 s21 min
53600 s1 h 21 min
621600 s7 h 21 min
786400 s31 h 21 min
8–12各 86400 s最多约 5 天 7 小时

尝试四天半是 MTA 的常规姿态:长到远端服务器周末的一次故障不会退掉你用户的邮件, 又短到发件人最终会知道邮件没送到。queue.retention_days(30)管辖 delivered 行在 那之后保留多久;它与重试窗口无关。

调度器以 queue.poll_interval_secs(10)轮询,并发运行 queue.workers(4)条投递。


12. 4xx5xx,以及 FerromaError::is_temporary()

分类不是在 SMTP 层决定的。它就是 FerromaError::is_temporary()——crates/ferroma-core/src/error.rs 中的一个函数——它是 SMTP 应答类别与队列「重试还是失败」决定背后的唯一真相来源。

pub fn is_temporary(&self) -> bool {
    matches!(
        self,
        FerromaError::Io(_)
            | FerromaError::Network(_)
            | FerromaError::Dns(_)
            | FerromaError::RateLimited
            | FerromaError::Timeout(_)
            | FerromaError::Storage(_)
            | FerromaError::Internal(_)
    )
}
FerromaError 变体code()临时?对一次投递的含义
Ioio_error本地文件系统/套接字失败——再试一次
Networknetwork_error出站连接失败
Dnsdns_errorMX/A 查询失败或超时
RateLimitedrate_limited被限流,退避
Timeouttimeout超过超时上限
Storagestorage_error数据库或邮件存储失败——邮件本身没有问题
Internalinternal_error一个 bug;重试是保守的选择,并且该失败会大声记入日志
Configconfig_error不可用的配置——绝不是单封邮件层面的情况
Parseparse_error输入格式错误
NotFoundnot_found被引用的实体不存在
Conflictconflict唯一性或状态违例
Invalidinvalid_input校验失败
Unauthorizedunauthorized凭据缺失或错误
Forbiddenforbidden策略拒绝,例如中继被拒
LimitExceededlimit_exceeded大小、收件人数或配额
Protocolprotocol_error对等端违反了协议
Tlstls_error证书校验失败
Unsupportedunsupported已被规定但未实现

一句话规则:is_temporary() == true4xx 并重新入队; is_temporary() == false5xx 并失败。 一个协议层如果靠字符串匹配错误消息来 自行分类错误,那就是 bug;请新增变体或修正 is_temporary()

12.1 收信:Ferroma 对某对等端应答什么

情况应答类别
邮件已存储250 2.0.0 Ok: queued as <id>2xx
DATA 正文超过 limits.max_message_size552 5.3.4 Message size exceeds fixed maximum message size5xx,永久——重试不会让它变小
收件人数超过 limits.max_recipients452 4.5.3 Too many recipients4xx——RFC 5321 §4.5.3.1.10 规定这是临时性应答
未知本地域550 5.1.2 Relay access denied5xx
未知本地地址550 5.1.1 No such user here5xx
向非本地域中继,未认证550 5.7.1 Relaying denied5xx
邮箱超出配额452 4.2.2 Mailbox full4xx——用户可以腾出空间
数据库或 Maildir 写入失败451 4.3.0 Temporary local problem4xx——FerromaError::Storage,重试
邮件根目录所在磁盘写满452 4.3.1 Insufficient system storage4xx
存储时发生内部错误451 4.3.0 Temporary local problem4xx——FerromaError::Internal
超出速率限制421 4.7.0 Too many commands, slow down4xx,随后关闭
SPF 硬失败(-all)_(计划中)_550 5.7.23 SPF validation failed5xx
DMARC 失败且 p=reject (计划中)550 5.7.1 DMARC policy violation5xx
DKIM 校验失败且 dkim.verify_inbound (计划中)550 5.7.20 DKIM signature validation failed5xx

对上文直觉的两处更正,都是刻意的:

12.2 发信:远端应答对队列意味着什么

远端应答队列决定理由
最后一个点号之后的 2xxdelivered,写入 delivered_atdelivery_attempts完成
任意时刻的 4xxretrynext_attempt_at = now + backoff_for_attempt(attempts),记录 last_status_code/last_status_text远端要求我们稍后再来
任意时刻的 5xx立即 failed,不再尝试,若 queue.bounce_on_failure 则退信远端永久拒绝;继续重试就是滥用
连接被拒 / 超时 / 重置retryFerromaError::Network / Timeout试下一台 MX,然后退避
TLS 握手失败retry远端那边常常是在轮换证书
MX 查询失败retryFerromaError::DnsDNS 抖动是暂时的
所有 MX 主机都试过且都以 4xx 失败retry一次尝试覆盖每一台主机;退避作用于整个域
attempts 达到 max_attemptsfailed,若 queue.bounce_on_failure 则退信重试窗口已耗尽
多收件人邮件中某一位收件人收到远端 5xx那一行 mail_queue 失败;其它收件人不受影响每个收件人一行,正是为了让这种情况按收件人处理

12.3 增强状态码

宣告了 ENHANCEDSTATUSCODES(RFC 3463),因此每个应答在基本码之后都带一个 x.y.z 类别。Ferroma 发出的类别:

增强码含义来源
2.0.0其它/未定义状态,成功邮件被接受
2.1.0发件人正常MAIL FROM 被接受
2.1.5收件人正常RCPT TO 被接受
2.5.2无法 VRFY 用户VRFY
2.7.0安全策略正常AUTH 成功
4.2.2邮箱已满超出配额
4.3.0其它邮件系统状态本地存储/数据库失败
4.3.1邮件系统已满磁盘写满
4.3.2系统不接受网络邮件连接数封顶
4.4.2连接不良超时
4.5.3收件人过多limits.max_recipients
4.7.0安全策略,临时性速率限制、被限流的 AUTH
5.1.1目的邮箱地址错误未知本地地址
5.1.2目的系统地址错误未知本地域
5.3.4邮件对本系统过大大小限制
5.5.1无效命令顺序违例
5.5.2语法错误无法解析的命令行
5.5.4无效的命令参数base64 错误、参数错误
5.7.0安全策略,永久性要求认证
5.7.1投递未获授权中继被拒、发件人不属于该用户
5.7.8认证凭据无效口令错误
5.7.11要求加密明文 AUTH 被拒
5.7.20DKIM 签名校验失败 (计划中)收信 DKIM
5.7.23SPF 校验失败 (计划中)收信 SPF

smtp.advertise_extensions = false 时,增强码被省略,应答是光秃秃的三位数字码 加上没有类别的文本。

12.4 DSN

DSN(RFC 3461)会被宣告 _(计划中)_,并且 MAIL FROM 上的参数 (RET=FULL|HDRSENVID=)与 RCPT TO 上的参数(NOTIFY=ORCPT=)会记录到 队列行上。对某个收件人设置 NOTIFY=NEVER 会抑制该收件人的退信。在它实现之前, DSN 不会被宣告,那些参数也会被忽略,这正是 RFC 5321 §4.1.1.11 对不支持它们的 服务器所要求的做法。

12.5 完整的收信应答表

应答文本触发条件
220<smtp.banner>连接被接受
2202.0.0 Ready to start TLSSTARTTLS
2212.0.0 ByeQUIT
2352.7.0 Authentication successfulAUTH
2502.0.0 OkEHLO/HELO 最后一行、NOOPRSET
2502.1.0 OkMAIL FROM
2502.1.5 OkRCPT TO
2502.0.0 Ok: queued as <id>DATA 结束
2522.5.2 Cannot VRFY user, but will accept message and attempt deliveryVRFY
334<base64 prompt>AUTH 续行
354End data with <CR><LF>.<CR><LF>DATA
4214.3.2 Too many connections, try again laterlimits.max_connections
4214.3.2 Too many connections from your addresslimits.max_connections_per_ip
4214.4.2 Timeout waiting for commandsmtp.command_timeout_secs
4214.4.2 Timeout waiting for datasmtp.data_timeout_secs
4214.7.0 Too many commands, slow downlimits.smtp_rate_limit
4504.2.0 Mailbox busy, try again later临时锁竞争 (计划中)
4514.3.0 Temporary local problemFerromaError::Storage / Internal
4524.2.2 Mailbox full配额
4524.3.1 Insufficient system storage磁盘写满
4524.5.3 Too many recipientslimits.max_recipients
4524.7.0 Submission rate limit exceededlimits.submission_rate_limit
4524.7.0 Daily send limit exceededlimits.daily_send_limit
4544.7.0 Temporary authentication failure锁定,或按 IP 的失败洪流
5005.5.2 Command unrecognized未知动词
5005.5.2 Line too long命令行超过 512 个八位组
5015.5.4 Invalid base64 dataSASL 数据块格式错误
5015.5.4 Syntax error in parametersMAIL/RCPT 无法解析
5025.5.1 Command not implementedEXPNBDAT、无 TLS 时的 STARTTLS
5035.5.1 Send HELO/EHLO firsthelo_required
5035.5.1 Need MAIL FROM before RCPT TO顺序
5035.5.1 Need RCPT TO before DATA顺序
5035.5.1 Sender already specified第二条 MAIL FROM
5035.5.1 Already authenticated第二次 AUTH
5035.5.1 TLS already activeSTARTTLS 两次
5045.5.4 Unrecognized authentication typeAUTH CRAM-MD5
5305.7.0 Authentication requiredrequire_auth_on_submission
5355.7.8 Authentication credentials invalid凭据错误
5385.7.11 Encryption required for requested authentication mechanismrequire_tls_for_auth
5505.1.1 No such user here未知本地地址
5505.1.2 Relay access denied未知本地域
5505.7.1 Relaying denied未认证的中继尝试
5505.7.1 Sender address rejected: not owned by user外来 MAIL FROM
5505.7.23 SPF validation failed (计划中)SPF
5505.7.1 DMARC policy violation (计划中)DMARC
5505.7.20 DKIM signature validation failed (计划中)DKIM
5525.3.4 Message size exceeds fixed maximum message size大小限制
5525.3.4 Too many message parts (计划中)limits.max_attachments
5545.5.1 Pipelining violated命令跨 STARTTLS 流水线化
5545.7.1 Message rejectedcatch-all 策略拒绝 (计划中)

13. 退信生成

当一次投递以 failed 结束且 queue.bounce_on_failure = true 时,Ferroma 会向信封 发件人回发一封投递状态通知。项目书 §11 没有写明格式,因此本节就是规范。

13.1 退给谁

信封发件人行为
一个存在的本地地址退信投递进该邮箱的 INBOXFrom: MAILER-DAEMON@<server.hostname>
一个已不存在的本地地址退信被丢弃,以 warn 级别记入日志
一个远端地址一行新的 mail_queue,发件人为空(MAIL FROM:<>),适用同样的重试策略;退信自身若退信则被丢弃
空(<>永不退信——它本身已经是一封退信,而给它退信正是邮件环路开始的方式

空发件人规则很要紧:RFC 5321 §6.1 与 RFC 3464 都要求通知的反向路径为空,而一台 会给退信再退信的服务器,会乐于在两台配置错误的 MTA 之间制造无限环路。

13.2 退信是一封 DSN

multipart/report; report-type=delivery-status(RFC 3462/3464),包含:

部分内容类型内容
1text/plain; charset=utf-8人类可读的说明:哪一位收件人、为什么、以及尝试了多久
2message/delivery-status逐邮件字段(Reporting-MTAArrival-Date)与逐收件人字段(Final-RecipientAction: failedStatus: 5.1.1Diagnostic-Code: smtp; 550 5.1.1 No such user here
3message/rfc822(或 text/rfc822-headers原邮件的头字段,或在它较小时给出整封邮件

ferroma_mail::MessageBuilder 构建;原始字节按 messages.storage_path 取自 Maildir。Status: 字段携带 §12.3 的*增强*码,这样发件人的客户端可以依据类别而不是 文字描述来行动。

13.3 何时生成退信

条件退信?
最后一个点号处收到远端 5xx是,立即
RCPT TO 处收到远端 5xx是,对该收件人
attempts 达到 queue.max_attempts
Null MX
收件人是一个不存在的本地地址RCPT TO 时生成,不是由队列生成
queue.bounce_on_failure = false不退信;失败记录在 mail_queuedelivery_attempts 中并在 Admin 中展示
邮件来自本地提交且发件人仍处于连接中submission 已经返回 250;退信是唯一的反馈途径

退信在 In-Reply-ToReferences 中携带原 Message-ID,因此客户端可以把 「Undelivered Mail Returned to Sender」与用户实际发出的那封邮件串在一起。


14. 手工诊断 SMTP

项目书 §44 列出了工具。以下全部都可对本地运行的服务器使用;25 端口是收信监听器, 587 是 submission 监听器。

# Greeting, capabilities and a full transaction, unencrypted.
swaks --server 127.0.0.1 --port 25 --from [email protected] --to [email protected] --body "test"

# The same, forcing STARTTLS.
swaks --server 127.0.0.1 --port 587 --tls --auth PLAIN --auth-user [email protected] --auth-password '…'

# By hand: type EHLO, MAIL FROM, RCPT TO, DATA.
nc 127.0.0.1 25

# Which capabilities does the submission port advertise, and does it offer STARTTLS?
openssl s_client -starttls smtp -connect 127.0.0.1:587 -crlf

# Implicit TLS on 465.
openssl s_client -connect 127.0.0.1:465

# Is the MX record the one Ferroma will use?
dig +short MX example.com

问候语与能力交换的示意输出:

220 mail.example.com Ferroma ESMTP ready
EHLO client.example.net
250-mail.example.com
250-PIPELINING
250-SIZE 26214400
250-8BITMIME
250-ENHANCEDSTATUSCODES
250-SMTPUTF8
250-STARTTLS
250-AUTH PLAIN LOGIN
250 HELP
MAIL FROM:<[email protected]>
250 2.1.0 Ok
RCPT TO:<[email protected]>
250 2.1.5 Ok
DATA
354 End data with <CR><LF>.<CR><LF>
Subject: test

hello
.
250 2.0.0 Ok: queued as 4821
QUIT
221 2.0.0 Bye

关于「邮件送不到」和「队列在不断增长」,见 deployment.md §11 中 按症状编排的命令清单。


15. 相关文档

主题文档
端口、DNS 记录、TLS 终止、首次运行设置deployment.md
SPF、DKIM、DMARC、HTML 清洗、中继防御的理由security.md
字节最终落在哪里,以及 messages / mail_queue 模式storage.md
从客户端视角看重的重试状态机,Outboxsync.mdclient.md
把邮件入队的 API:POST /api/v1/messagesapi.md §5.2
crate 分层与请求生命周期architecture.md
Ferroma · MIT OR Apache-2.0 · 由 docs/ 生成