Skip to content

fix(smtp): 出站SMTP连接增加30秒事务超时,防止下游无响应导致永久卡死 - #361

Closed
0clickjacking0 wants to merge 3 commits into
Jinnrry:masterfrom
0clickjacking0:fix/outbound-smtp-transaction-timeout
Closed

0clickjacking0 wants to merge 3 commits into
Jinnrry:masterfrom
0clickjacking0:fix/outbound-smtp-transaction-timeout

Conversation

@0clickjacking0

Copy link
Copy Markdown
Contributor

问题:
Go 标准库 net/smtp 风格的客户端在读取服务器响应时没有任何超时。
如果目标邮件服务器接受 TCP 连接后不返回任何数据(不发送 220 问候、
或收到 DATA 后不回 250),读操作会永久阻塞。

在单连接串行发送模型下,一次这样的挂死会导致整条发送通道停摆。

修复:

  1. 新增常量统一管理出站超时:
    • outboundSMTPDialTimeout = 2s(TCP 建连,沿用原值)
    • outboundSMTPTransactionTimeout = 30s(命令交互,新增)
    • outboundSMTPQuitWriteTimeout = 250ms(QUIT 写入,沿用原值)
  2. 新增 newClientWithTimeout(),在建立连接后通过 conn.SetDeadline() 为整个命令交互阶段设置 30 秒截止线
  3. NewClient() 默认应用该超时;明文与 TLS 两条路径均生效
  4. 超时后按投递失败处理,由上层重试/退避机制恢复,连接被释放

效果:下游挂死从『永久卡死、通道停摆』变为『30 秒内失败并重试』。
配合并发发送(每条连接独立),单个挂死只影响一个并发槽。

测试:
新增 TestNewClientBoundsSilentServer,验证对静默服务器在
超时内失败返回,-race 通过。

问题:
Go 标准库 net/smtp 风格的客户端在读取服务器响应时没有任何超时。
如果目标邮件服务器接受 TCP 连接后不返回任何数据(不发送 220 问候、
或收到 DATA 后不回 250),读操作会永久阻塞。

在单连接串行发送模型下,一次这样的挂死会导致整条发送通道停摆,
且期间不产生任何错误日志,难以排查。

修复:
1. 新增常量统一管理出站超时:
   - outboundSMTPDialTimeout = 2s(TCP 建连,沿用原值)
   - outboundSMTPTransactionTimeout = 30s(命令交互,新增)
   - outboundSMTPQuitWriteTimeout = 250ms(QUIT 写入,沿用原值)
2. 新增 newClientWithTimeout(),在建立连接后通过
   conn.SetDeadline() 为整个命令交互阶段设置 30 秒截止线
3. NewClient() 默认应用该超时;明文与 TLS 两条路径均生效
4. 超时后按投递失败处理,由上层重试/退避机制恢复,连接被释放

效果:下游挂死从『永久卡死、通道停摆』变为『30 秒内失败并重试』。

测试:
新增 TestNewClientBoundsSilentServer,验证对静默服务器在
超时内失败返回,-race 通过。
@0clickjacking0
0clickjacking0 force-pushed the fix/outbound-smtp-transaction-timeout branch from 334531f to 40f9959 Compare August 28, 2026 12:31
@Jinnrry

Jinnrry commented Aug 30, 2026

Copy link
Copy Markdown
Owner

感谢pr,看了下代码,这个超时可能存在风险吧。这30s是整个smtp会话的Deadline,假设邮件里面带一个非常大的附件,比如带一个1G的附件,很容易就传输超过30s了。

翻了下RFC,按https://www.rfc-editor.org/rfc/rfc5321.html#section-4.5.3.2 这个建议,最好是按命令控制超时,不然很容易造成问题

评审意见指出:原实现在整个 SMTP 会话上设置 30s 统一 Deadline,
携带大附件(如 1GB 附件)的邮件传输很容易超过 30s 被误杀。

RFC 5321 §4.5.3.2 明确要求:
"An SMTP client MUST provide a timeout mechanism. It MUST use
 per-command timeouts rather than somehow trying to time the
 entire mail transaction."

本次修改移除会话级 Deadline,改为按阶段分别计时:

1. §4.5.3.2.1 初始 220 问候:5 分钟(NewClient,读完立即清除)
2. §4.5.3.2.2/3 单条命令(EHLO/HELO/MAIL/RCPT 等):5 分钟
   (cmdWithTimeout,每条命令独立计时,结束后清除)
3. §4.5.3.2.4 DATA 等待 354:2 分钟
4. §4.5.3.2.5 数据块写入:3 分钟/块
   (dataBlockWriter 按 32KB 分块刷新写超时,整体时限随
   邮件大小线性扩展,大附件不再被误杀;对端挂死时最后一块
   仍会在超时内失败释放连接)
5. §4.5.3.2.6 等待最终 250:10 分钟(避免过早超时引发重复投递)

超时值按 RFC 建议值取最小值,并用 var 声明便于调整。

测试:
- TestNewClientBoundsSilentServer:静默服务器在问候超时内失败
- TestPhasedTimeoutsAllowSlowMultiPhaseTransaction:每阶段都慢、
  总时长超过单阶段超时的健康下游不再被误杀
- TestStalledDataUploadFailsWithinDataBlockTimeout:354 后下游
  停止读取,上传在数据块超时内失败而非永久阻塞
全部 -race 通过。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants