SMTP 的点号填充(dot-stuffing)与行结束规则是怎样的?为什么解析不当会出问题?

1 SMTP 的点号填充(dot-stuffing)与行结束规则是怎样的?为什么解析不当会出问题?
问题从哪来:用带内标记表示「数据结束」

SMTP 的 DATA 命令没有长度字段。发送方发出 DATA、收到中间响应后开始传输报文,报文的结束靠一个带内标记来表示:一个只包含单个点号的行,即 CRLF、点号、CRLF 这一序列。

这种「用数据流中的特定内容表示结束」的设计立刻引出一个问题:如果报文正文里本来就有一行只有一个点号怎么办?它会被接收方误认为传输结束,导致报文被从中间截断,其后的内容则被当作 SMTP 命令来解释。

RFC 5321 §4.5.2 以「透明性(Transparency)」为题解决这一问题,其规则是发送方填充、接收方去除

  • 发送侧:在传输报文时,对每一个以点号开头的行,在行首额外插入一个点号。
  • 接收侧:在判定结束标记之后、把报文交给下一环节之前,对每一个以点号开头的行去掉行首的那一个点号。

注意规则针对的是所有以点号开头的行,而不仅仅是「只有一个点号的行」。这一点常被简化理解,是自研发送代码出错的高频原因。

行的定义:CRLF 不是可选项

透明性规则的前提是双方对「一行」的理解一致。RFC 5321 §2.3.8 明确了行的概念:行是以 CRLF(回车换行序列)结束的字符串。规范同时强调,CR 与 LF 不得单独出现在需要表示行结束以外的场合——即报文数据中不应存在裸 CR 或裸 LF。

现实中裸换行大量存在,来源包括:在类 Unix 系统上用只输出 LF 的脚本直接拼接报文;从文件读取内容后未做行结束符转换;某些库在拼装 MIME 分部时使用了平台默认换行。由此产生的后果:

  • 行边界判定不一致:一方把裸 LF 当作行结束,另一方不当作,于是「以点号开头的行」的集合在两方看来就不同,填充与去填充无法配对。
  • 结束标记判定不一致:一方认为 LF、点号、LF 构成结束标记,另一方要求严格的 CRLF、点号、CRLF。这正是报文边界被不同实现切在不同位置的根源。
  • DKIM 签名失效:RFC 6376 定义的签名覆盖信头与正文,正文规范化对空白与行结束高度敏感。中途任何一跳改写了行结束符,签名即告失效,表现为「本域签的名,对端验不过」这类难以定位的故障。

工程结论:报文在进入 SMTP 传输前必须完成行结束符规范化,不要指望中间环节替你修。

常见故障与其表现
  • 正文被截断:报文中存在以点号开头的行而发送方未做填充。接收方在遇到独立点号行时判定结束,其后内容丢失。典型触发场景是正文中包含缩进的代码、配置片段、以点号开头的列表项,或纯文本表格。
  • 多出一个点号:发送方填充了,接收方或某中间环节未去除(或去除了两次)。表现为收到的邮件中某些行莫名多出或少掉一个行首点号。
  • 会话卡死直至超时:结束标记未被正确识别,接收方一直等待更多数据,最终因超时断连,发送方进入重试循环,同一封邮件被反复投递。
  • 内容被当作命令执行:报文被提前判定结束后,其余数据落在命令解析上下文中。这是最严重的一类后果,可能导致本不该被投递的收件人或本不该被接受的内容进入邮件流。
  • 签名与内容校验不一致:中间环节对行做了改写(补 CR、去点号、重新折行),使 DKIM 校验失败或使网关看到的内容与最终投递内容不同。
排错方法
  1. 看字节,不要看渲染结果cat 与邮件客户端都不会显示 CR 与 LF 的区别。应使用 hexdump -Cod -c 直接观察字节序列,确认行结束是 0d 0a 还是裸 0a这一步不做,后面全是猜测。
  2. 构造最小复现样本:用一封正文中包含「一行只有一个点号」和「一行以点号开头后接文字」的邮件做基准测试,逐跳投递并比对收到的内容。
  3. 逐跳二分定位:邮件路径上常有多个环节(应用 → 提交服务 → 网关 → 中继 → 投递)。在每一跳落地一份副本,比对哪一跳改变了字节,即可锁定问题组件。
  4. 手工会话验证:用 openssl s_client -starttls smtp -connect host:587 建立会话后手工输入 DATA 与测试正文,观察服务端行为。手工输入时务必确认终端发送的是 CRLF。
  5. 核对 DKIM 规范化方式:若同时存在签名失效,比对签名时与验证时的正文字节,重点看尾部空行与行结束符。
  6. 检查中间设备:部分安全设备会重写正文(插入免责声明、改写链接、重新编码)。这类改写若未正确处理行边界与填充,会引入本节所述的全部问题。
实现与配置层面的建议
  • 优先使用成熟的 MTA 与库:点号填充与行结束处理属于「看起来简单、边界情况极多」的逻辑。自行拼接 SMTP 会话字符串是这类故障最主要的来源,应尽量交给成熟实现。
  • 发送前统一规范化:在报文进入传输层之前,把所有裸 CR、裸 LF 统一转换为 CRLF,再交给 SMTP 客户端。
  • 接收侧严格但可观测:对不合规的行结束序列,明确策略是拒绝还是规范化后接受;无论选哪种,都要记录日志,否则问题会长期静默存在。
  • 考虑使用 BDAT:RFC 3030 定义的 CHUNKING 扩展提供 BDAT 命令,以显式长度传输数据块,从而完全避免带内结束标记与点号填充。在双方都支持时,这从机制上消除了本节讨论的整类问题;但公网对端支持情况不一,不能作为唯一方案。
  • 提交与中继分开验证:按 RFC 6409(STD 72)用户提交走独立路径,其行为可能与入站中继不同,变更后需分别回归测试。
  • 把边界样本纳入回归测试:把「点号开头行」「独立点号行」「裸 LF」「超长行」「尾部空行」做成固定测试用例,在每次 MTA 升级或网关策略变更后自动跑一遍。

参考:RFC 5321《Simple Mail Transfer Protocol》§2.3.8 Lines、§4.1.1.4 DATA、§4.5.2 Transparency,J. Klensin,2008 年 10 月,https://www.rfc-editor.org/rfc/rfc5321.html ;RFC 5322《Internet Message Format》,P. Resnick 编,2008 年 10 月,https://www.rfc-editor.org/rfc/rfc5322.html ;RFC 3030《SMTP Service Extensions for Transmission of Large and Binary MIME Messages》,G. Vaudreuil,2000 年 12 月,https://www.rfc-editor.org/rfc/rfc3030.html ;RFC 6376《DomainKeys Identified Mail (DKIM) Signatures》,D. Crocker、T. Hansen、M. Kucherawy 编,2011 年 9 月,STD 76,https://www.rfc-editor.org/rfc/rfc6376.html ;RFC 6409《Message Submission for Mail》,2011 年 11 月,STD 72,https://www.rfc-editor.org/rfc/rfc6409.html