Ориентация главы

Слой данных (глава 116) определяет, что такое письмо; SMTP определяет, как отправлять. Два слоя в xsmtp: протокольные примитивы (smtp: разбор строк ответов, слияние возможностей EHLO, сборка команд — без зависимости от сети; официальный клиент и пользовательские машины состояний на одной реализации); клиент (smtp_client: синхронная машина состояний сессии — транзакция конверта READY → MAIL → RECIPIENT → DATA → READY; без скрытых 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 → отправка EHLO → (только при явных 500/502/504 откат на HELO по конфигурации — прочие коды отказа EHLO отката не дают). Машина состояний READY → MAIL → RECIPIENT → READY (один конверт); с CHUNKING после первого блока вход в CHUNK, успех LAST возвращает READY. Владение: конфигурация заимствуется только на время Open; клиент держит транспорт и последний ответ, заимствуя Engine/Resolver/TLS Context/Verifier (уничтожение клиента не трогает разделяемые объекты). Потоки: все блокирующие операции принимают абсолютный int64 + опциональный xcancel; вызывать из обратного вызова Worker-владельца Engine нельзя (тот же заслон взаимоблокировки, что в главе 97); один клиент не поддерживает конкурентных команд. Три финала: 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/закрытие/abort). Несовпадение объявленной длины: End отказывается читать ответ, вызывающий может дополнить байты; если невозможно — обязательно прервать. Комбинация BINARYMIME: бит возможностей лишь объявляет поддержку, тип сообщения проходит через родовой параметр Mail(..., "BODY=BINARYMIME", ...) — API конверта для него не дублируется (сохраняется способность комбинировать произвольные параметры MAIL).

TLS: неявная форма и апгрейд

Базовый клиент — открытый текст; слой smtp_client_tls добавляет две формы: XMAIL_SECURITY_TLS (неявная — рукопожатие завершается до баннера, форма порта 465); XMAIL_SECURITY_STARTTLS (апгрейд — EHLO объявляет STARTTLS, проверка переключения 220, перехват TLS на исходном потоке, повторный EHLO после рукопожатия). Два жёстких правила: апгрейд не наследует снимок возможностей (посредник может срезать возможности — истина в пересогласовании); верификатор обязателен — проверка сертификата никогда не пропускается молча («не проверять — не вариант» главы 85 в почтовой редакции).

119.3示例

Выполняйте команды из корня репозитория. Сборщик выбирает модули, публичные заголовки и системные библиотеки по манифесту, а также печатает журнал сборки и тестов зависимостей. Терминальные блоки показывают вывод примера. Вторая программа запускает сервер loopback внутри процесса и использует фиксированные демонстрационные данные. Параметры реального сервиса, 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

Что только что произошло. (1) SmtpCapabilityParse("SIZE 10485760") разбирает одну возможность (имя + заимствованное представление параметра) → CapabilityAdd вливает её во встроенные биты — 64-битный потолок SIZE (10 МиБ) записан: дальше перед DATA можно свериться (за лимит не слать впустую). (2) SmtpCommandWrite("EHLO", "client.example", ...) даёт EHLO client.example\r\n — аргументы проходят инъекционные проверки (управляющие символы/CR/LF отвергаются), потолок 512 байтов. (3) Офлайн-тестируемость — суть образца: протокольный слой сети не касается — семейства примитивов ответ/возможность/команда полностью юнит-тестируемы (официальный клиент — ровно сборка этих примитивов, одно поведение, второй реализации нет).

Вторая полная программа: локальная отправка

Вторая программа из 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

Что только что произошло. (1) Программа создаёт слушатель, резолвер и сетевой движок, затем запускает поток локального сервера. Резолвер направляет демонстрационное имя на loopback, ОС выбирает порт, соединение использует TCP. (2) Клиент читает приветствие, отправляет EHLO и передаёт структурированное письмо в xrtSmtpSubmit. Общий слой MIME формирует тело, клиент выполняет envelope и DATA. Сервер проверяет тему, тело и завершающую строку, принимает письмо; клиент отправляет QUIT. (3) Успех требует завершённой отправки, проверки содержимого сервером и нормального выхода серверного потока. Любой сбой возвращает ошибку и освобождает созданные объекты. Локальный пример использует открытый текст без TLS и аутентификации. Пример реальной отправки требует доверенного CA и учётных данных времени выполнения; принятие сервером рассматривается отдельно от диагностики последующего закрытия.

119.4契约

  • Расслоение протокол/клиент: примитивы сети не касаются; клиент — сборка машины состояний из примитивов — без второй реализации.
  • Защита от инъекций: командный слой отвергает управляющие символы/CR/LF, потолок 512B; валидация пути — только внутренность угловых скобок.
  • Дисциплина ответов: трёхзначные коды согласованы, потолок строк, уникальная завершающая строка; инкрементальный путь MailLineRead→ReplyRead.
  • Откат EHLO: только явные 500/502/504 дают откат на HELO по конфигурации; прочие отказы не угадываются.
  • Владение: конфигурация заимствована на время Open; разделяемые объекты (Engine/Resolver/TLS/Verifier) заимствуются, не уничтожаются; один клиент без конкурентных команд.
  • Дисциплина блокировки: абсолютный deadline + опциональный cancel; вызов из обратного вызова Worker-владельца запрещён.
  • Три финала: Quit протокольное прощание / Close пропускает QUIT и ждёт закрытия / Abort немедленный стоп (идемпотент; FAILED хранит ответ).
  • Потоковая DATA: dot transparency между кусками; ноль копий целого сообщения; CRLF — ответственность вызывающего.
  • BDAT: объявленные байты идут как есть; без сканирования, без экранирования, без CRLF; каждый блок читает 250; несовпадение длины можно дополнить или прервать.
  • BINARYMIME: бит возможности + родовой параметр MAIL — без дублирования API конверта.
  • TLS в двух формах: неявная (до баннера) / STARTTLS (повторный EHLO после апгрейда, старый снимок не наследуется); верификатор обязателен.

Типичные ошибки

Ловушка 1: пользовательский ввод вклеен прямо в команду:Ловушка 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:Ловушка 2: старый снимок возможностей после STARTTLS

Симптом: закешированное до апгрейда «поддерживает SIZE» используется для решений после апгрейда — на соединении, где посредник срезал возможности, письмо сверх лимита отвергается (хороший случай) или ошибочно включается CHUNKING (плохой).

Причина: ответ EHLO фазы открытого текста недостоверен (MITM может его изменить); после апгрейда обязателен повторный EHLO за истинным снимком — контракт клиента делает это автоматически; собственный кеш возможностей его обходит.

错误写法
caps_before_tls = parse_ehlo();   /* кеш при открытом тексте */
starttls();
	use_caps(caps_before_tls);  /* недостоверный снимок */
正确写法
starttls();
caps = client_renegotiated_ehlo();   /* клиент повторил EHLO после апгрейда - берём новый */
Ловушка 3: жёсткий End при несовпадении длины BDAT:Ловушка 3: жёсткий End при несовпадении длины BDAT

Симптом: сервер виснет в ожидании недостающих байтов или сообщает протокольную ошибку — объявлено 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 МБ потоково кусками по 8 КиБ (dot transparency через границы) — сравните с путём BDAT на том же вложении. Посчитайте пиковую память и байты канала (DATA чуть больше из-за экранирования). Критерии приёмки: оба пути доставляют одинаково; пик пути DATA постоянен; пограничные случаи (граница куска ровно на начале строки / перед точкой) проходят тест-векторы.

Итоги

ТемаКратко
Расслоениепротокольные примитивы (офлайн-тестируемы) → клиент (сборка машины состояний) — без второй реализации
Защита от инъекцийкоманды отвергают CR/LF/управляющие/512B; путь проверяется внутри угловых скобок
Состояние сессииREADY→MAIL→RECIPIENT→DATA→READY; EHLO откатывается только при явном отказе
Владениеразделяемые объекты заимствуются, не уничтожаются; один клиент без конкурентности; из обратных вызовов Worker нельзя
Форма блокировкиабсолютный deadline + опциональный cancel
Три финалаQuit/Close/Abort (немедленный, идемпотентный, FAILED хранит диагностику)
DATAdot transparency между кусками; ноль копий целого сообщения; CRLF — на вызывающем
BDATобъявленные байты как есть, без экранирования; каждый блок читает 250; несовпадение — дополнить или прервать
BINARYMIMEбит возможности + родовой параметр MAIL — без дублирования API конверта
TLSнеявная/STARTTLS две формы; повторный EHLO после апгрейда; верификатор обязателен

📖 Справочники API в этой главе:xsmtp-smtp (9 записей)xsmtp-smtp_client (29 записей)xmail-mail (3 записей)

Полные сигнатуры, ограничения параметров, возвращаемые значения и ошибки