本章导读
数据层(第 116 章)定义了邮件是什么;SMTP 定义怎么发。xsmtp 的两层:协议原语(smtp:响应行解析、EHLO 能力合并、命令构建——不依赖网络,官方客户端与自定义状态机同一实现);客户端(smtp_client:同步会话状态机——READY → MAIL → RECIPIENT → DATA → READY 的 envelope 事务;不建隐藏 Engine/DNS 线程/固定缓冲,不负责构造 MIME)。四条主线:注入防御(命令层拒绝 CR/LF 与控制字符——SMTP 是头注入重灾区);流式 DATA(dot transparency 的增量状态机——跨片段保留行首状态、不建整报文副本);CHUNKING(BDAT 按字节声明直发——不扫描不转义);TLS 两形态(隐式 TLS 与 STARTTLS 升级——升级后能力快照不沿用)。
119.1引入
SMTP 是 1982 年的对话协议:客户端发命令(EHLO/MAIL FROM/RCPT TO/DATA)、服务端回三位码响应(250 OK/550 拒绝)。表面的简单之下是三个现代工程问题。其一:注入——早期实现的命令拼接把用户输入直接拼进命令行,含 CRLF 的输入能"夹带"额外命令(发件人伪造的经典路径);xrtSmtpCommandWrite 在入口拒绝 CR/LF 与控制字符、512 字节上限。其二:dot transparency——DATA 模式里正文行的行首 . 必须转义(.→..)、结束靠单独的 . 行;流式发送(不攒整报文)要求转义状态跨片段保持——上片段结尾是行首、下片段开头是 .,跨界的点必须转义。其三:二进制——dot transparency 与 CRLF 要求让 DATA 天然文本;BDAT(CHUNKING 扩展)按声明字节数直发原始块,二进制附件不经转义。
119.2概念
协议原语:响应、能力与命令
路径验证:xrtSmtpPathValid 验证 reverse/forward-path 内容(不含尖括号/空白/控制分隔符)——只验尖括号内部、不构造命令(职责切分:验内容归协议层、组命令归命令层)。
客户端:会话状态与所有权
xrtSmtpClientOpen:验证 220 banner → 发 EHLO →(仅当服务器明确 500/502/504 才按配置回退 HELO——EHLO 失败的其他码不回退)。状态机 READY → MAIL → RECIPIENT → READY(一笔 envelope);CHUNKING 在首块后进 CHUNK、LAST 成功回 READY。所有权:配置只在 Open 期间借用;Client 持传输与最后响应、借用 Engine/Resolver/TLS Context/Verifier(销毁不动共享对象)。线程:全部阻塞操作接受相对毫秒 int64+可选 xcancel;不能从所属 Engine 的 Worker 回调调用(第 98 章同款死锁防线);单 Client 不支持并发命令。三收尾:Quit(协议告别)/Close(跳过 QUIT 等传输关闭)/Abort(任意非空状态立即异常中止、重复成功、FAILED 保留最后响应供诊断)。
DATA 快速路径:流式 dot transparency
DataBegin/DataWrite/DataEnd 接受任意分块:内部增量状态机跨片段保留 CRLF 与行首状态、直接发送 dot-transparent 片段——不创建整报文副本(大附件流式的发送侧)。xrtSmtpClientData 连续输入便利入口:先完整验证 CRLF 再委托增量状态机。调用方仍负责 MIME 字段、传输编码与服务器 SIZE 限制——客户端管线路不管内容(内容归第 116 章模块)。
CHUNKING:BDAT 快速路径
服务器声明 CHUNKING 后:BdatBegin/BdatWrite/BdatEnd 按声明字节数直发原始片段——不扫描内容、不做 dot transparency、不追加 CRLF、零整块副本。每块同步读 250;4xx/5xx 后禁止继续发块(必须 RSET/关闭/中止)。声明长度与实际不一致:End 拒绝读响应、调用方可补足字节;无法补足必须中止。BINARYMIME 的组合形态:能力位只声明支持,消息类型经 Mail(..., "BODY=BINARYMIME", ...) 的通用参数传递——不为它复制 envelope API(任意 MAIL 参数组合能力保留)。
TLS:隐式与升级两形态
基础客户端明文;smtp_client_tls 层加两形态:XMAIL_SECURITY_TLS(隐式——banner 前完成握手,465 端口形态);XMAIL_SECURITY_STARTTLS(升级——EHLO 声明 STARTTLS、验证 220 切换、原流上接管 TLS、握手后重发 EHLO)。两条硬规则:升级不沿用升级前的能力快照(中间人可能剥能力——重协商才是真相);必须提供验证器——不静默跳过证书验证(第 85 章"不验证不是选项"的邮件版)。
119.3示例
本章命令在仓库根目录执行,构建器按清单选择模块、公共头和平台链接库,并输出构建及依赖测试日志。终端块摘录范例自身的输出。第二个程序在进程内启动回环服务器,只使用固定演示数据;真实服务的主机、端口、CA 与运行时凭据配置见相应客户端库的 README。
第一个完整程序:能力解析与命令构建
下面的程序来自 examples/smtp/protocol——协议原语的离线闭环:
#include <stdio.h> #include <xsmtp.h> /* 解析 EHLO 能力并构建一条安全命令。 */ int main(void) { xsmtpcapabilityview Capability; uint64 iCapabilities = 0; uint64 iSizeLimit = 0; char arrCommand[64]; size_t iSize; if ( !xrtSmtpCapabilityParse( XRT_STR_LITERAL("SIZE 10485760"), &Capability ) || !xrtSmtpCapabilityAdd( &Capability, &iCapabilities, &iSizeLimit ) || !xrtSmtpCommandWrite( XRT_STR_LITERAL("EHLO"), XRT_STR_LITERAL("client.example"), arrCommand, sizeof(arrCommand), &iSize ) ) { return 1; } printf("SIZE=%llu command=%.*s", (unsigned long long)iSizeLimit, (int)iSize, arrCommand); return 0; }
刚才发生了什么。 ① SmtpCapabilityParse("SIZE 10485760") 解析单条能力(名称+参数借用视图)→ CapabilityAdd 并入内置位——SIZE 的 64 位上限(10 MiB)被记录:后续 DATA 前可对照(超限别白发)。② SmtpCommandWrite("EHLO", "client.example", ...) 产 EHLO client.example\r\n——参数过注入检查(控制字符/CR/LF 拒绝)、512 字节上限。③ 离线可测是这个示例的要点:协议层不碰网络——响应/能力/命令三族原语都能单测(官方客户端就是这些原语的组装,行为一致无第二实现)。
第二个完整程序:本地提交
第二个程序来自 extlibs/xsmtp/examples/offline/main.c.
#include <xsmtp.h> #include <stdio.h> #include <string.h> #include <math.h> static inline double exampleTimerLimit(int64 Timeout) { return Timeout == XRT_WAIT_FOREVER ? INFINITY : xrtTimer() + (double)Timeout / 1000.0; } static inline bool exampleTimerExpired(double Limit) { return xrtTimer() >= Limit; } static inline int64 exampleTimerRemaining(double Limit) { double Ms; if (Limit == INFINITY) return XRT_WAIT_FOREVER; Ms = ceil((Limit - xrtTimer()) * 1000.0); return Ms <= 0 ? 0 : Ms >= 0x1p63 ? INT64_MAX : (int64)Ms; } /* 本地演示域名只解析到本进程的回环 SMTP 服务。 */ static xnetaddr ExampleAddress; typedef struct example_server { xnetlistener* Listener; double Deadline; bool Success; } example_server; static xnetaddrlist* exampleResolve(cstr sHost, xnetfamily Family, ptr pData) { xnetaddr Address = ExampleAddress; (void)Family; (void)pData; if ( strcmp(sHost, "smtp.example.invalid") != 0 ) return NULL; Address.Port = 0u; return xrtNetAddrListCreate(&Address, 1u); } static bool exampleSend(xnetstream* pStream, cstr sText, double Deadline) { size_t iSize = strlen(sText); for ( ;; ) { xnetresult Result = xrtNetStreamSend(pStream, sText, iSize); if ( Result == XNET_RESULT_OK ) return true; if ( (Result != XNET_RESULT_AGAIN) || !xrtNetStreamWait( pStream, XNET_STREAM_WAIT_WRITE,exampleTimerRemaining(Deadline), NULL) ) return false; } } static bool exampleExpect(xnetstream* pStream, cstr sExpected, double Deadline) { size_t iSize = strlen(sExpected); size_t iUsed = 0u; while ( iUsed < iSize ) { xnetbytes* pBytes = xrtNetStreamRecv( pStream, iSize - iUsed,exampleTimerRemaining(Deadline), NULL); xbytesview Bytes; if ( pBytes == NULL ) return false; Bytes = xrtNetBytesView(pBytes); if ( (Bytes.Size == 0u) || (memcmp(Bytes.Data, sExpected + iUsed, Bytes.Size) != 0) ) { xrtNetBytesDestroy(pBytes); return false; } iUsed += Bytes.Size; xrtNetBytesDestroy(pBytes); } return true; } /* 有界读取 DATA,逐字节读取可避免越过终止符消费下一条命令。 */ static bool exampleExpectMessage(xnetstream* pStream, double Deadline) { char sMessage[8193]; size_t iUsed = 0u; while ( iUsed < sizeof(sMessage) - 1u ) { xnetbytes* pBytes = xrtNetStreamRecv(pStream, 1u,exampleTimerRemaining(Deadline), NULL); xbytesview Bytes; if ( pBytes == NULL ) return false; Bytes = xrtNetBytesView(pBytes); if ( (Bytes.Size != 1u) || (Bytes.Data[0] == 0u) ) { xrtNetBytesDestroy(pBytes); return false; } sMessage[iUsed++] = (char)Bytes.Data[0]; xrtNetBytesDestroy(pBytes); if ( (iUsed >= 5u) && (memcmp(sMessage + iUsed - 5u, "\r\n.\r\n", 5u) == 0) ) { sMessage[iUsed] = 0; return strstr(sMessage, "Subject: Offline SMTP example") != NULL && strstr(sMessage, "Hello from loopback.") != NULL && strstr(sMessage, "sender@example.test") != NULL && strstr(sMessage, "recipient@example.test") != NULL; } } return false; } static int32 exampleServer(ptr pData) { example_server* pServer = (example_server*)pData; xnetstream* pStream = xrtNetListenerAcceptWait( pServer->Listener,exampleTimerRemaining(pServer->Deadline), NULL); bool bOk; if ( pStream == NULL ) return 1; bOk = exampleSend(pStream, "220 smtp.example.invalid ready\r\n", pServer->Deadline) && exampleExpect(pStream, "EHLO offline.example\r\n", pServer->Deadline) && exampleSend(pStream, "250 smtp.example.invalid\r\n", pServer->Deadline) && exampleExpect(pStream, "MAIL FROM:<sender@example.test>\r\n", pServer->Deadline) && exampleSend(pStream, "250 sender accepted\r\n", pServer->Deadline) && exampleExpect(pStream, "RCPT TO:<recipient@example.test>\r\n", pServer->Deadline) && exampleSend(pStream, "250 recipient accepted\r\n", pServer->Deadline) && exampleExpect(pStream, "DATA\r\n", pServer->Deadline) && exampleSend(pStream, "354 send message\r\n", pServer->Deadline) && exampleExpectMessage(pStream, pServer->Deadline) && exampleSend(pStream, "250 queued\r\n", pServer->Deadline) && exampleExpect(pStream, "QUIT\r\n", pServer->Deadline) && exampleSend(pStream, "221 closing\r\n", pServer->Deadline) && xrtNetStreamClose(pStream) && xrtNetStreamWait(pStream, XNET_STREAM_WAIT_CLOSE,exampleTimerRemaining(pServer->Deadline), NULL); pServer->Success = bOk; xrtNetStreamDestroy(pStream); return bOk ? 0 : 2; } int main(void) { xnetengineconfig EngineConfig; xnetresolverconfig ResolverConfig; xnetlistenconfig ListenConfig; xsmtpclientconfig ClientConfig; xmailmessage Message; xmailaddress To; example_server Server = { 0 }; xnetengine* pEngine = NULL; xnetresolver* pResolver = NULL; xnetlistener* pListener = NULL; xsmtpclient* pClient = NULL; xthread* pThread = NULL; double Deadline = exampleTimerLimit(INT64_C(10000)); bool bOk = false; xrtNetEngineConfigInit(&EngineConfig); EngineConfig.Backend = XNET_PORT_SELECT; EngineConfig.Workers = 1u; pEngine = xrtNetEngineCreate(&EngineConfig); if ( (pEngine == NULL) || !xrtNetEngineStart(pEngine) ) goto Done; xrtNetListenConfigInit(&ListenConfig); if ( !xrtNetAddrLoopback(&ListenConfig.Address, XNET_FAMILY_IPV4, 0u) ) goto Done; pListener = xrtNetListen(pEngine, &ListenConfig, NULL, NULL, NULL); if ( (pListener == NULL) || !xrtNetListenerLocal(pListener, &ExampleAddress) ) goto Done; xrtNetResolverConfigInit(&ResolverConfig); ResolverConfig.Workers = 1u; ResolverConfig.Lookup = exampleResolve; ResolverConfig.CacheEntries = 0u; pResolver = xrtNetResolverCreate(&ResolverConfig); if ( pResolver == NULL ) goto Done; Server.Listener = pListener; Server.Deadline = Deadline; pThread = xrtThreadCreate(exampleServer, &Server, 0u); if ( pThread == NULL ) goto Done; xrtSmtpClientConfigInit(&ClientConfig); ClientConfig.Net.Engine = pEngine; ClientConfig.Net.Resolver = pResolver; ClientConfig.Net.Host = "smtp.example.invalid"; ClientConfig.Net.Port = ExampleAddress.Port; ClientConfig.Hello = XRT_STR_LITERAL("offline.example"); pClient = xrtSmtpClientOpen(&ClientConfig,exampleTimerRemaining(Deadline), NULL); if ( pClient == NULL ) goto Done; xrtMailMessageInit(&Message); Message.From = (xmailaddress){ XRT_STR_LITERAL(""), XRT_STR_LITERAL("sender@example.test") }; To = (xmailaddress){ XRT_STR_LITERAL(""), XRT_STR_LITERAL("recipient@example.test") }; Message.To = &To; Message.ToCount = 1u; Message.Subject = XRT_STR_LITERAL("Offline SMTP example"); Message.Text = XRT_STR_LITERAL("Hello from loopback."); if ( !xrtSmtpSubmit(pClient, &Message,exampleTimerRemaining(Deadline), NULL) || !xrtSmtpClientQuit(pClient,exampleTimerRemaining(Deadline), NULL) ) goto Done; bOk = true; Done: if ( (pClient != NULL) && !bOk ) (void)xrtSmtpClientAbort(pClient); xrtSmtpClientDestroy(pClient); if ( pThread != NULL ) { bool bThreadDone = xrtThreadWait(pThread) == XWAIT_OK; bOk = bOk && bThreadDone && Server.Success && (xrtThreadExitCode(pThread) == 0); xrtThreadDestroy(pThread); } if ( pListener != NULL ) { (void)xrtNetListenerClose(pListener); while ( xrtNetListenerState(pListener) != XNET_LISTENER_CLOSED ) xrtThreadYield(); xrtNetListenerDestroy(pListener); } if ( pResolver != NULL ) (void)xrtNetResolverDestroy(pResolver); if ( pEngine != NULL ) (void)xrtNetEngineDestroy(pEngine); if ( !bOk ) { fputs("offline SMTP submission failed\n", stderr); return 1; } puts("offline SMTP submission: message accepted"); return 0; }
刚才发生了什么。 ① 程序先创建监听器、解析器与网络引擎,再启动本地服务器线程。解析器把演示主机名绑定到回环地址,监听端口由系统分配,连接实际经过 TCP。② 客户端读取 banner、发送 EHLO,然后把结构化消息交给 xrtSmtpSubmit,由共享 MIME 组合层生成正文,并完成 envelope 与 DATA。服务器检查主题、正文和终止行,返回接受响应;客户端随后执行 QUIT。③ 成功输出同时要求客户端完成提交、本地服务器确认收到内容、服务器线程正常退出。任一阶段失败均返回失败并清理创建的对象。这个程序使用本地明文线路,没有 TLS 或账户认证;真实服务器的提交范例需要提供可信 CA 与运行时凭据,并把服务器接受提交和后续关闭诊断分别解释。
119.4契约
- 协议/客户端分层:原语不碰网络;客户端是原语的状态机组装——无第二实现。
- 注入防御:命令层拒控制字符/CR/LF、512B 上限;路径验证只管尖括号内部。
- 响应纪律:三位码一致、行数限、唯一终止行;增量经
MailLineRead→ReplyRead。 - EHLO 回退:仅明确
500/502/504按配置回退 HELO;其他失败不猜。 - 所有权:配置 Open 期借用;共享对象(Engine/Resolver/TLS/Verifier)借不销毁;单 Client 无并发命令。
- 阻塞纪律:绝对 deadline+可选 cancel;禁止所属 Worker 回调内调用。
- 三收尾:Quit 协议告别/Close 跳 QUIT 等关闭/Abort 立即中止(重复成功;FAILED 留响应)。
- DATA 流式:跨片段 dot transparency;零整报文副本;CRLF 归调用方。
- BDAT:声明字节直发;不扫描不转义不加 CRLF;每块读 250;长度不符可补足否则中止。
- BINARYMIME:能力位+通用 MAIL 参数——不复制 envelope API。
- TLS 两形态:隐式(banner 前)/STARTTLS(升级后重发 EHLO、不沿用旧快照);验证器必须。
常见错误
症状:收件人地址含 CRLF 的输入“成功”发出——实际夹带了额外命令(伪造/放大)。
原因:SMTP 注入的老家:命令是文本行协议,裸拼即注入。CommandWrite/路径验证是防线——绕过它们拼字符串就是绕过防线。
snprintf(Cmd, "RCPT TO:<%s>\r\n", UserInput); /* 注入直通 */
if ( !xrtSmtpPathValid(view(UserInput), false) ) { return reject("invalid address"); /* 验内容 */ } xrtSmtpCommandWrite(XRT_STR_LITERAL("RCPT TO"), AngleForm, Out, sizeof(Out), &n); /* 走注入检查的入口 */
症状:升级前缓存的 "支持 SIZE" 在升级后决策——被中间人剥掉能力的连接上发出超限邮件被拒(好情况)或错误启用 CHUNKING(坏情况)。
原因:明文阶段的 EHLO 响应不可信(中间人可改);升级后必须重发 EHLO 取真快照——客户端契约自动做,自己缓存能力就是绕过它。
caps_before_tls = parse_ehlo(); /* 明文时缓存 */ starttls(); use_caps(caps_before_tls); /* 不可信快照 */
starttls(); caps = client_renegotiated_ehlo(); /* 升级后客户端已重发——用新的 */
症状:服务器挂等剩余字节或报协议错——声明 1024 实发 1000 就调 End。
原因:BDAT 的契约是"声明=承诺";End 检查不符时拒读响应并给补足机会——补不了就中止(连接的块计数已错,无法继续)。
BdatBegin(1024); BdatWrite(实际 1000 字节); BdatEnd(); /* 少 24 字节硬结:服务器挂起 */
BdatBegin(1024); sent = BdatWrite(Buf, 1000); if ( sent < 1024 ) { if ( !BdatWrite(Buf + 1000, 24) ) { /* 补足 */ xrtSmtpClientAbort(pClient); /* 补不了:中止 */ } } BdatEnd();
编程练习
构造单行/多行/不一致码/无终止行的四组响应字节流,走 LineRead→ReplyRead 验证。验收标准:合法组解析正确;非法组结构化错误可区分。
对 CommandWrite 与 PathValid 投喂含 CRLF/控制字符/超长的输入——验证全部拒绝。验收标准:五类恶意输入(CRLF/裸 CR/控制字节/512B 超/尖括号)各自被正确入口拒绝。
DATA 路径:10 MB 附件分 8 KiB 块流式发送(dot transparency 跨界)——对照 BDAT 路径同附件。统计内存峰值与线上字节(DATA 因转义略多)。验收标准:两路收件一致;DATA 路径峰值恒定;跨界点(块边界恰在行首/点前)测试向量通过。
小结
| 知识点 | 速查 |
|---|---|
| 分层 | 协议原语(离线可测)→客户端(状态机组装)——无第二实现 |
| 注入防御 | 命令拒 CR/LF/控制/512B;路径验尖括号内部 |
| 会话状态 | READY→MAIL→RECIPIENT→DATA→READY;EHLO 仅明确拒才回退 |
| 所有权 | 共享对象借不销毁;单 Client 无并发;Worker 回调禁调 |
| 阻塞形态 | 绝对 deadline+可选 cancel |
| 三收尾 | Quit/Close/Abort(立即、重复成功、FAILED 留诊断) |
| DATA | 跨片段 dot transparency;零整报文副本;CRLF 归调用方 |
| BDAT | 声明字节直发不转义;每块读 250;不符可补足否则中止 |
| BINARYMIME | 能力位+通用 MAIL 参数——不复制 envelope API |
| TLS | 隐式/STARTTLS 两形态;升级后重 EHLO;验证器必须 |
📖 本章涉及的 API 参考手册:xsmtp-smtp(9 个条目)xsmtp-smtp_client(29 个条目)xmail-mail(3 个条目)
点击查看完整签名、参数约束、返回值与错误说明