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

Заключительная глава xmail. Предыдущие четыре дали слой данных (115) и три протокола (116–118); эта глава сливает их: mail_compose (высокоуровневая композиция — xmailmessage/xmailattachment заимствуют все данные вызывающего; xrtMailCompose одной строкой даёт полное сообщение: трёхслойный автоматический выбор multipart (mixed/alternative/related комбинируются по содержимому, границы взаимно различны), автоматическое кодирование QP/Base64, Bcc идёт только слою отправки, в сообщение не попадает); smtp_submit (высшая отправка — одна структурная отправка, конверт выводится из сообщения; соединение держит вызывающий); слой синтаксиса адресов (mail_address: валидация addr-spec, ASCII по умолчанию с явным SMTPUTF8); замкнутый цикл (Compose → статическая проверка Valid → сессия Submit → вложения кодируются потоково, ноль копий целого сообщения на всём пути). Карта слоёв: от xrtSmtpSubmit (одна строка) до CommandWrite (байты) — каждый уровень свободен для спуска.

122.1引入

«Отправить HTML-письмо с вложением» в эпоху ручного MIME — двадцать строк клея: выбрать структуру multipart (mixed оборачивает related оборачивает alternative?), сгенерировать не сталкивающиеся границы, QP-кодировать тело, Base64-кодировать вложения с переносами, обработать кодированные слова китайского Subject, посчитать Content-Type… глава 116 дала примитивы для всего — но неужели распространённые комбинации каждый раз склеивать руками? Ответ mail_compose — структурированный ввод + автоматический выбор технологии: вы объявляете «вот тело, вот HTML-версия, вот две встроенные картинки, вот три вложения» — структура выводится из содержимого автоматически (трёхслойная вложенность собирается сама, границы генерируются взаимно различными), кодирование автоматично по типу (текст QP / вложения Base64 кусками), а Date/Message-ID/boundary вы вставляете, только когда нужен детерминированный вывод (тесты/архивы).

Обработка Bcc — образец проектирования безопасности: адрес скрытой копии входит только в получателей конверта SMTP-слоя отправки (в RCPT TO он есть), в RFC-сообщение — никогда (в заголовках To/Cc его нет) — протокольно корректная реализация «скрытой копии»; пропустить этот слой (вписать Bcc в заголовки) — превратить её в открытую копию, приватную аварию, где получатели видят друг друга.

122.2概念

Compose: описание, валидация и два выхода

1Описаниеxmailmessage (From/Reply-To/To/Cc/Bcc/UTF-8 Subject/Text/Html/
2ВалидацияxrtMailMessageValid - проверяет только описание (не генерирует случайных значений, не выделяет, sink не зовёт)
3Выход первыйxrtMailComposeWrite(описание, sink, ...) - прямая запись в поток (вложения кодируются кусками в sink)
4Выход второйxrtMailCompose(описание, &длина) - полное сообщение (освобождается xrtFree)

Ключ детерминированного вывода: Date/Message-ID/три границы могут предоставляться вызывающим — пустыми остаются текущий UTC / криптостойкая случайность / криптостойкая случайность (домен Message-ID пуст — выводится из From). Тесты и архивы вставляют фиксированные значения → вывод воспроизводим байт-в-байт; продакшн оставляет пустыми → криптостойкая случайность. Свои поля не могут перекрыть структурные поля, управляемые Compose — владение структурными полями (Content-Type/boundary/объявления кодировок) принадлежит слою композиции; добавленные поля структуру не ломают.

Трёхслойный автоматический выбор multipart

Комбинация содержимогоСтруктура
обычные вложения естьmultipart/mixed
чистый текст + HTML сосуществуютmultipart/alternative
HTML со встроенными ресурсамиmultipart/related
все три вместетрёхслойная вложенность — границы обязаны различаться (гарантия слоя композиции)

Политика кодирования: текст QP (MIME-форма ширины строк главы 116); вложения Base64 фиксированными малыми кусками — «без кодированной копии, растущей с размером вложения» (пик кодирования вложения 10 МБ = кусок, а не 13 МБ). Это приземление философии потоковых тел главы 108 на слое кодирования.

Синтаксис адресов и граница интернационализации

xrtMailAddressValid валидирует addr-spec и отдаёт взаймы local-part/domain: по умолчанию принимает только ASCII local-part и DNS-стиль домена (поддерживаются цитируемые local-part и domain-literal); только с явно установленным XMAIL_ADDRESS_SMTPUTF8 принимаются интернационализированные local-part / UTF-8-домены — и SMTP-клиент всё равно решает отправку по возможностям сервера (синтаксис прошёл ≠ противоположная сторона примет). Граница слоёв: этот слой ведает только синтаксисом сообщения — длины пути, DNS, IDNA, лимиты конверта принадлежат верхним слоям (расслоение главы 89 в почтовой редакции: синтаксический слой без транспортной политики).

Submit: замкнутая отправка высшего уровня

xrtSmtpSubmit(客户端, 消息, 截止, 取消) (клиент, сообщение, дедлайн, отмена): внутри собирается MIME (через Compose) → выводится конверт (From→MAIL FROM; To+Cc+Bcc→RCPT TO) → отправка через DATA/BDAT — одной строкой от «структурированного описания» до «линии». Против трёх слоёв главы 117: CommandWrite (байты) ← smtp_client (сессия) ← submit (бизнес) — каждый уровень вниз — строка спуска, каждый вверх — поколение делегирования.

Панорама замкнутого цикла отправки-приёма

1Отправкаописание -> Valid (статическая проверка) -> Compose (структура + кодирование) -> Submit (конверт + сессия + потоковая отправка)
2Приёмпотоковый забор POP3/IMAP -> MailMessageParse (представления) -> курсор multipart -> декодирование кодированных слов -> бизнес
3Инженерия накопленногоархивировать при приёме (конвейер главы 118) + собирать при отправке (эта глава) - ноль резиденции целых сообщений на обоих концах

122.3示例

Выполняйте команды из корня репозитория. Сборщик выбирает модули, публичные заголовки и системные библиотеки по манифесту, а также печатает журнал сборки и тестов зависимостей. Терминальные блоки показывают вывод примера. Вторая программа запускает сервер loopback внутри процесса и использует фиксированные демонстрационные данные. Параметры реального сервиса, CA и учётных данных времени выполнения описаны в README клиентской библиотеки.

Первая полная программа: однострочный Compose

Программа ниже из examples/mail/compose — кратчайший путь текстового письма с UTF-8-темой:

extlibs/xmail/examples/mail/compose/main.c源码 ↗
#include <stdio.h>
#include <xmail.h>

#if defined(_WIN32)
#include <fcntl.h>
#include <io.h>
#endif



/* 使用一行式 Compose 构建带 UTF-8 主题的文本邮件。 */
int main(void)
{
	xmailmessage Message;
	xmailaddress To;
	str sOutput;
	size_t iSize;
	bool bOutputOk;

#if defined(_WIN32)
	if ( _setmode(_fileno(stdout), _O_BINARY) == -1 ) {
		fputs("MIME output mode failed\n", stderr);
		return 1;
	}
#endif

	xrtMailMessageInit(&Message);
	Message.From = (xmailaddress){
		XRT_STR_LITERAL("Sender"),
		XRT_STR_LITERAL("sender@example.com")
	};
	To = (xmailaddress){
		XRT_STR_LITERAL("Receiver"),
		XRT_STR_LITERAL("receiver@example.net")
	};
	Message.To = &To;
	Message.ToCount = 1u;
	Message.Subject = XRT_STR_LITERAL("示例邮件");
	Message.Text = XRT_STR_LITERAL("hello from xmail");

	sOutput = xrtMailCompose(&Message, &iSize);
	if ( sOutput == NULL ) {
		return 1;
	}
	bOutputOk = fwrite(sOutput, 1u, iSize, stdout) == iSize;
	if ( fflush(stdout) != 0 ) bOutputOk = false;
	xrtFree(sOutput);
	if ( !bOutputOk ) {
		fputs("MIME output failed\n", stderr);
		return 1;
	}
	return 0;
}
$ python tools/build.py --manifest extlibs/xmail/config/modules.json --suite mail_compose --no-single --jobs 4(输出完整 MIME 报文——含编码词主题与 QP 正文)

Что только что произошло. (1) MailMessageInit + пять присваиваний полей (отправитель/получатель/Subject 示例邮件 (пример письма)/тело) — всё заимствовано (строковые литералы входят в структуру напрямую, ноль копий, ноль деревьев). (2) xrtMailCompose одной строкой даёт полное сообщение: китайский Subject автоматически в кодированные слова (=?utf-8?B?...?=), чистый текст QP-кодируется, Date/Message-ID криптостойко-случайны — все решения двадцати строк ручного MIME исполняются здесь автоматически. (3) После fwrite вывода — xrtFree; этот выход есть форма «полного сообщения» (нужен поток — переключитесь на ComposeWrite+sink: вложения идут в sink кусками). (4) Разберите этот вывод обратно MailMessageParse главы 116 — отправка и приём пользуются одним словарём разбора, цикл самосогласован.

Вторая полная программа: локальный Submit

Вторая программа из 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) Офлайн-программа заполняет From, To, Subject и Text тем же интерфейсом структурированного письма, что в первом примере, затем передаёт описание в xrtSmtpSubmit. Compose формирует сообщение, SMTP-клиент ведёт envelope и DATA. Отправка относится к xsmtp, композиция MIME к xmail; каждый слой использует публичный API. (2) Внутрипроцессный сервер проверяет реально полученные тему, тело и завершающую строку DATA, возвращает успех; клиент отправляет QUIT. Принятие печатается только при успехе обеих сторон, образуя исполняемый цикл отправки. (3) Демонстрационные адреса и открытый трафик остаются внутри локального стенда, без отправки во внешние ящики. Реальные сервисы используют пример отправки и настройки CA, аккаунта и учётных данных из README. Успешная отправка означает принятие сервером, а не доставку во входящие; производственный вызывающий код отдельно фиксирует отправку, диагностику закрытия и независимое подтверждение получения.

122.4契约

  • Заимствованное описание: message/attachment целиком заимствованы — ноль скрытых связей / ноль словарей / ноль рекурсивных owned-деревьев.
  • Valid — чистая проверка: случайных значений не генерирует, не выделяет, sink не зовёт — все статические ошибки до первой записи в сеть.
  • Два выхода: ComposeWrite пишет прямо в sink (вложения кодируются кусками) / Compose даёт полное сообщение (xrtFree).
  • Структура автоматична: три слоя mixed/alternative/related комбинируются по содержимому; взаимная различность границ гарантирует слой.
  • Ключи детерминизма: Date/Message-ID/границы вставляемы (пусто = криптостойкая случайность); домен Message-ID выводится из From.
  • Граница полей: свои поля не перекрывают структурные — владение структурой у слоя композиции.
  • Политика кодирования: текст QP; вложения Base64 фиксированными кусками — ноль кодированных копий, растущих с размером.
  • Семантика Bcc: только в конверт слоя отправки, никогда в RFC-сообщение — протокольно корректная скрытая копия.
  • Адреса ASCII по умолчанию: SMTPUTF8 явно; синтаксис прошёл ≠ доставляемо (суждение возможностей — протокольному слою).
  • Слой Submit: описание → сборка → конверт → сессия одной строкой; дедлайн/отмена насквозь.

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

Ловушка 1: получатель Bcc вписан в собственное поле:Ловушка 1: получатель Bcc вписан в собственное поле

Симптом: «скрытая копия» становится открытой — все получатели видят друг друга в заголовках; приватная авария.

Причина: семантика Bcc реализуется потреблением слоя отправки (получатели конверта) — его отсутствие в заголовках сообщения и есть свойство. Добавить адрес Bcc как своё поле = вписать открытую копию руками.

错误写法
message.Bcc = &Secret;
add_custom_field(&message, "Bcc", "secret@x.com");  /* в сообщение: открытая копия */
正确写法
message.Bcc = &Secret;   /* только структурное поле */
/* Submit выводит конверт: RCPT TO содержит secret; в заголовках Bcc нет - настоящая скрытая копия */
Ловушка 2: вложения через Compose (не Write) — и жалобы на память:Ловушка 2: вложения через Compose (не Write) — и жалобы на память

Симптом: вложение 50 МБ через выход полного сообщения Compose — в памяти одновременно оригинал и полное сообщение.

Причина: у двух выходов разная семантика: Compose — «дай мне полное сообщение» (агрегация неизбежна); для потоковой передачи — ComposeWrite: вложения кодируются фиксированными малыми кусками прямо в sink (сетевой поток/файл), пик = кусок.

错误写法
str raw = xrtMailCompose(&Msg, &n);   /* 50МБ агрегировано: пик удвоен */
send_all(raw);
正确写法
/* ComposeWrite + потоковый sink: куски выходят напрямую */
xrtMailComposeWrite(&Msg, stream_sink, pCtx, ...);  /* малые куски прямо в сеть */
Ловушка 3: тестовые ассерты без вставки ключей детерминизма:Ловушка 3: тестовые ассерты без вставки ключей детерминизма

Симптом: сравнение двух выводов Compose проваливается — Date/Message-ID/границы каждый раз случайны.

Причина: пусто = криптостойкая случайность (правильное продакшн-умолчание); тестам для детерминизма нужно вставить фиксированные значения — это спроектированный ключ, а не дефект.

错误写法
raw1 = xrtMailCompose(&Msg, NULL);
raw2 = xrtMailCompose(&Msg, NULL);
assert(equal(raw1, raw2));   /* случайные Date/ID: никогда не равны */
正确写法
Msg.Date = FixedDate; Msg.MessageId = FixedId;
provide_boundaries(&Msg, "t1", "t2", "t3");
raw1 = Compose(&Msg); raw2 = Compose(&Msg);
assert(equal(raw1, raw2));   /* детерминизм: байт-в-байт */

Практические задания

Базовое: проверка трёхслойной структуры

Постройте три описания (чистый текст + HTML + вложения; + встроенная картинка) — вывод Compose разберите по слоям курсором главы 116, проверьте структуру и взаимную различность границ. Критерии приёмки: подбор структуры сходится с таблицей; порядок трёхслойной вложенности верен.

Продвинутое: эксперимент с замкнутым Bcc

Сообщение с Bcc отправить Submit на локальный тестовый сервер (или самосбор главы 104) — снять линию: RCPT TO содержит скрытую копию, в заголовках сообщения DATA поля Bcc нет. Критерии приёмки: оба доказательства на месте; адрес скрытой копии никогда не появляется и в пересылаемых получателями сообщениях.

Вызов: инструмент полной цепочки отправка-приём

Композиция: ComposeWrite потоково отправляет вложение 10 МБ (DATA-поток главы 117) → потоковый приём POP3 (глава 118) → инкрементальный разбор восстанавливает (глава 116) → вложение пишется на диск с проверкой хеша. Критерии приёмки: память постоянна по всей цепочке (уровня МБ); хеши вложения совпадают; тема с кодированными словами проходит круг без потерь.

Итоги

ТемаКратко
Позиционированиеслой удобств распространённых сценариев: структурированное описание → автоматический MIME — все примитивы достижимы в главе 115
Заимствование описанияmessage/attachment ноль копий; без скрытых связей/словарей/owned-деревьев
Validчистая статическая проверка — все ошибки до первой записи в сеть
Два выходаComposeWrite (потоковый sink, кодирование кусками) / Compose (полное сообщение)
Структура автоматичнаmixed/alternative/related три слоя по содержимому; различность границ гарантирует слой
Ключи детерминизмаDate/Message-ID/границы вставляемы; пусто = криптостойкая случайность
Кодированиетекст QP; вложения Base64 кусками — ноль копий, растущих с размером
Bccв конверте есть, в сообщении нет — протокольно корректная скрытая копия
АдресаASCII по умолчанию; SMTPUTF8 явно; синтаксис ≠ доставляемо
Submitоднострочный цикл; каждый слой между ним и CommandWrite доступен для спуска

📖 Справочники API в этой главе:xmail-mail_compose (4 записей)xsmtp-smtp_submit (2 записей)xmail-mail (3 записей)

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