本章导读

数据层(第 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概念

协议原语:响应、能力与命令

1响应MailLineRead(取无 CRLF 行)→ SmtpReplyLineParse(三位码+多行分隔符)
2能力SmtpCapabilityParse(借用名称与参数)→ CapabilityAdd(合并常用扩展+
3命令SmtpCommandWrite(Verb [Args]\r\n——拒控制字符/CR/LF 注入/512B 超限)

路径验证: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——协议原语的离线闭环:

extlibs/xsmtp/examples/protocol/main.c源码 ↗
#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;
}
$ python tools/build.py --manifest extlibs/xsmtp/config/modules.json --suite smtp --no-single --jobs 4SIZE=10485760 command=EHLO client.example

刚才发生了什么。 ① 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.

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;
}
$ python tools/build.py --manifest extlibs/xsmtp/config/modules.json --suite smtp_offline_example --no-single --jobs 4offline SMTP submission: message accepted

刚才发生了什么。 ① 程序先创建监听器、解析器与网络引擎,再启动本地服务器线程。解析器把演示主机名绑定到回环地址,监听端口由系统分配,连接实际经过 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、不沿用旧快照);验证器必须。

常见错误

坑 1:把用户输入直接拼进命令

症状:收件人地址含 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);   /* 走注入检查的入口 */
坑 2:STARTTLS 后沿用旧能力快照

症状:升级前缓存的 "支持 SIZE" 在升级后决策——被中间人剥掉能力的连接上发出超限邮件被拒(好情况)或错误启用 CHUNKING(坏情况)。

原因:明文阶段的 EHLO 响应不可信(中间人可改);升级后必须重发 EHLO 取真快照——客户端契约自动做,自己缓存能力就是绕过它。

错误写法
caps_before_tls = parse_ehlo();   /* 明文时缓存 */
starttls();
	use_caps(caps_before_tls);  /* 不可信快照 */
正确写法
starttls();
caps = client_renegotiated_ehlo();   /* 升级后客户端已重发——用新的 */
坑 3:BDAT 声明长度与实际不符还硬 End

症状:服务器挂等剩余字节或报协议错——声明 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 个条目)

点击查看完整签名、参数约束、返回值与错误说明