Ориентация главы
Слой данных (глава 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概念
Протокольные примитивы: ответы, возможности и команды
Валидация пути: 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 — офлайн-цикл протокольных примитивов:
#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; }
Что только что произошло. (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.
#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; }
Что только что произошло. (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 после апгрейда, старый снимок не наследуется); верификатор обязателен.
Типичные ошибки
Симптом: ввод с 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 фазы открытого текста недостоверен (MITM может его изменить); после апгрейда обязателен повторный EHLO за истинным снимком — контракт клиента делает это автоматически; собственный кеш возможностей его обходит.
caps_before_tls = parse_ehlo(); /* кеш при открытом тексте */ starttls(); use_caps(caps_before_tls); /* недостоверный снимок */
starttls(); caps = client_renegotiated_ehlo(); /* клиент повторил 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 МБ потоково кусками по 8 КиБ (dot transparency через границы) — сравните с путём BDAT на том же вложении. Посчитайте пиковую память и байты канала (DATA чуть больше из-за экранирования). Критерии приёмки: оба пути доставляют одинаково; пик пути DATA постоянен; пограничные случаи (граница куска ровно на начале строки / перед точкой) проходят тест-векторы.
Итоги
| Тема | Кратко |
|---|---|
| Расслоение | протокольные примитивы (офлайн-тестируемы) → клиент (сборка машины состояний) — без второй реализации |
| Защита от инъекций | команды отвергают CR/LF/управляющие/512B; путь проверяется внутри угловых скобок |
| Состояние сессии | READY→MAIL→RECIPIENT→DATA→READY; EHLO откатывается только при явном отказе |
| Владение | разделяемые объекты заимствуются, не уничтожаются; один клиент без конкурентности; из обратных вызовов Worker нельзя |
| Форма блокировки | абсолютный deadline + опциональный cancel |
| Три финала | Quit/Close/Abort (немедленный, идемпотентный, FAILED хранит диагностику) |
| DATA | dot transparency между кусками; ноль копий целого сообщения; CRLF — на вызывающем |
| BDAT | объявленные байты как есть, без экранирования; каждый блок читает 250; несовпадение — дополнить или прервать |
| BINARYMIME | бит возможности + родовой параметр MAIL — без дублирования API конверта |
| TLS | неявная/STARTTLS две формы; повторный EHLO после апгрейда; верификатор обязателен |
📖 Справочники API в этой главе:xsmtp-smtp (9 записей)xsmtp-smtp_client (29 записей)xmail-mail (3 записей)
Полные сигнатуры, ограничения параметров, возвращаемые значения и ошибки