Chapter Orientation
The data layer (Chapter 116) defines what a mail message is; SMTP defines how to send. xsmtp has two layers: protocol primitives (smtp: reply-line parsing, EHLO capability merging, command building — no network dependency; the official client and custom state machines share one implementation); the client (smtp_client: a synchronous session state machine — the READY → MAIL → RECIPIENT → DATA → READY envelope transaction; no hidden Engine/DNS thread/fixed buffers, and it does not build MIME). Four main threads: injection defense (the command layer rejects CR/LF and control characters — SMTP is the worst header-injection zone); streaming DATA (dot transparency as an incremental state machine — line-start state preserved across chunks, no whole-message copy); CHUNKING (BDAT sends raw by declared byte count — no scanning, no escaping); TLS in two forms (implicit TLS and STARTTLS upgrade — the post-upgrade capability snapshot is not carried over).
119.1引入
SMTP is a 1982 conversational protocol: the client sends commands (EHLO/MAIL FROM/RCPT TO/DATA), the server answers three-digit replies (250 OK/550 拒绝 (rejected)). Beneath the surface simplicity sit three modern engineering problems. First: injection — early implementations spliced user input straight into command lines; input containing CRLF can "smuggle" extra commands (the classic sender-forgery path); xrtSmtpCommandWrite rejects CR/LF and control characters at the entrance, with a 512-byte cap. Second: dot transparency — in DATA mode, a . at a body line's start must be escaped (.→..), and termination is a lone . line; streaming sends (without accumulating the whole message) require the escape state to persist across chunks — the previous chunk ended at a line start, the next begins with .; that boundary dot must be escaped. Third: binary — dot transparency and CRLF requirements make DATA inherently textual; BDAT (the CHUNKING extension) sends raw chunks by declared byte count, so binary attachments pass unescaped.
119.2概念
Protocol primitives: replies, capabilities, and commands
Path validation: xrtSmtpPathValid validates reverse/forward-path content (no angle brackets/whitespace/control separators) — it validates only the inside of the angle brackets and builds no command (responsibility split: content validation belongs to the protocol layer, command assembly to the command layer).
The client: session state and ownership
xrtSmtpClientOpen: validate the 220 banner → send EHLO → (only on an explicit 500/502/504 does it fall back to HELO per configuration — other EHLO failure codes do not fall back). The state machine READY → MAIL → RECIPIENT → READY (one envelope); with CHUNKING it enters CHUNK after the first chunk, and LAST success returns to READY. Ownership: the configuration is borrowed only during Open; the Client holds the transport and last reply, and borrows the Engine/Resolver/TLS Context/Verifier (destroying it touches no shared objects). Threading: all blocking operations take an absolute int64 + optional xcancel; they must not be called from the owning Engine's Worker callback (Chapter 98's same deadlock guard); a single Client supports no concurrent commands. Three finishes: Quit (the protocol farewell) / Close (skip QUIT, await transport close) / Abort (immediately abort from any non-empty state, idempotent success, FAILED keeps the last reply for diagnosis).
The DATA fast path: streaming dot transparency
DataBegin/DataWrite/DataEnd accept arbitrary chunking: the internal incremental state machine preserves CRLF and line-start state across chunks, sending dot-transparent fragments directly — no whole-message copy is created (the sending side of big-attachment streaming). xrtSmtpClientData is the contiguous-input convenience entrance: it fully validates CRLF first, then delegates to the incremental machine. The caller still owns the MIME fields, transfer encodings, and the server's SIZE limit — the client manages the wire, not the content (content belongs to Chapter 116's modules).
CHUNKING: the BDAT fast path
Once the server declares CHUNKING: BdatBegin/BdatWrite/BdatEnd send raw fragments by declared byte count — no content scanning, no dot transparency, no appended CRLF, zero whole-chunk copies. Each chunk synchronously reads 250; after 4xx/5xx further chunks are forbidden (you must RSET/close/abort). Declared length mismatch: End refuses to read the reply and the caller may top up the bytes; if it cannot, it must abort. The BINARYMIME combination: the capability bit declares support only, and the message type passes through Mail(..., "BODY=BINARYMIME", ...)'s generic parameter — no envelope API is duplicated for it (the ability to combine arbitrary MAIL parameters is retained).
TLS: implicit and upgrade forms
The base client is plaintext; the smtp_client_tls layer adds two forms: XMAIL_SECURITY_TLS (implicit — the handshake completes before the banner, the port-465 shape); XMAIL_SECURITY_STARTTLS (upgrade — EHLO declares STARTTLS, validate the 220 switch, take over TLS on the original stream, re-send EHLO after the handshake). Two hard rules: the upgrade does not carry over the pre-upgrade capability snapshot (a man in the middle may strip capabilities — the renegotiation is the truth); a verifier must be provided — certificate validation is never silently skipped (Chapter 85's "not verifying is not an option", mail edition).
119.3示例
Run these commands from the repository root. The builder selects modules, public headers and platform libraries from the manifest, and also prints build and dependency-test logs. Terminal blocks show the example output. The second program starts a loopback server within the process and uses fixed demonstration data. See the client library README for real-service host, port, CA and runtime credential configuration.
First complete program: capability parsing and command building
The program below is from examples/smtp/protocol — an offline loop of the protocol primitives:
#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; }
What just happened. (1) SmtpCapabilityParse("SIZE 10485760") parses a single capability (name + borrowed parameter view) → CapabilityAdd merges it into the built-in bits — SIZE's 64-bit cap (10 MiB) is recorded: later, before DATA, you can check against it (don't send past the limit in vain). (2) SmtpCommandWrite("EHLO", "client.example", ...) produces EHLO client.example\r\n — the arguments pass the injection checks (control characters/CR/LF rejected), 512-byte cap. (3) Offline testability is this sample's point: the protocol layer touches no network — the reply/capability/command primitive families are all unit-testable (the official client is precisely an assembly of these primitives, one behavior, no second implementation).
Second complete program: local submission
The second program is from 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; }
What just happened. (1) The program creates a listener, resolver and network engine, then starts a local server thread. The resolver maps the demonstration hostname to loopback, the OS selects the port, and the connection uses TCP. (2) The client reads the banner, sends EHLO and passes a structured message to xrtSmtpSubmit. Shared MIME composition produces the body; the client completes the envelope and DATA. The server checks the subject, body and terminator, accepts the message, and the client sends QUIT. (3) Success requires completed submission, verified server content and a normal server-thread exit. Every failed stage returns failure and cleans up created objects. This local plaintext example has neither TLS nor account authentication. The real-service submission example requires a trusted CA and runtime credentials, and server acceptance must be interpreted separately from later shutdown diagnostics.
119.4契约
- Protocol/client layering: the primitives touch no network; the client is a state-machine assembly of the primitives — no second implementation.
- Injection defense: the command layer rejects control characters/CR/LF, 512B cap; path validation covers only the inside of the angle brackets.
- Reply discipline: three-digit codes consistent, line-count cap, unique terminating line; the incremental path
MailLineRead→ReplyRead. - EHLO fallback: only an explicit
500/502/504falls back to HELO per configuration; other failures are not guessed at. - Ownership: configuration borrowed during Open; shared objects (Engine/Resolver/TLS/Verifier) borrowed, not destroyed; a single Client runs no concurrent commands.
- Blocking discipline: absolute deadline + optional cancel; calling from the owning Worker's callback forbidden.
- Three finishes: Quit protocol farewell / Close skips QUIT and awaits close / Abort aborts immediately (idempotent; FAILED keeps the reply).
- Streaming DATA: dot transparency across chunks; zero whole-message copies; CRLF belongs to the caller.
- BDAT: declared bytes sent raw; no scanning, no escaping, no CRLF; each chunk reads 250; a length mismatch may be topped up or the session aborted.
- BINARYMIME: capability bit + generic MAIL parameter — no envelope API duplication.
- TLS in two forms: implicit (before the banner) / STARTTLS (re-EHLO after upgrade, no old snapshot carried); a verifier is mandatory.
Common Mistakes
Symptom: input with CRLF in the recipient address "successfully" sends — actually smuggling extra commands (forgery/amplification).
Cause: SMTP injection's home turf: the command is a text-line protocol; bare splicing is injection. CommandWrite/path validation are the defense line — splicing strings around them bypasses the defense.
snprintf(Cmd, "RCPT TO:<%s>\r\n", UserInput); /* injection straight through */
if ( !xrtSmtpPathValid(view(UserInput), false) ) { return reject("invalid address"); /* validate the content */ } xrtSmtpCommandWrite(XRT_STR_LITERAL("RCPT TO"), AngleForm, Out, sizeof(Out), &n); /* the entrance with injection checks */
Symptom: a "supports SIZE" cached before the upgrade is used to decide after it — on a connection whose capabilities a man in the middle stripped, an oversized mail gets rejected (good case) or CHUNKING is wrongly enabled (bad case).
Cause: the plaintext-phase EHLO response is untrustworthy (a MITM can alter it); after upgrading you must re-send EHLO for the true snapshot — the client contract does this automatically; caching capabilities yourself bypasses it.
caps_before_tls = parse_ehlo(); /* cached during plaintext */ starttls(); use_caps(caps_before_tls); /* untrustworthy snapshot */
starttls(); caps = client_renegotiated_ehlo(); /* the client re-sent EHLO after upgrade - use the new one */
Symptom: the server hangs waiting for the remaining bytes or reports a protocol error — you declared 1024, actually sent 1000, then called End.
Cause: BDAT's contract is "declaration = promise"; when End detects a mismatch it refuses to read the reply and offers a top-up chance — if you can't top up, abort (the connection's chunk counting is already wrong; there is no continuing).
BdatBegin(1024); BdatWrite(实际 1000 字节); BdatEnd(); /* 24 bytes short, hard-ended: the server hangs */
BdatBegin(1024); sent = BdatWrite(Buf, 1000); if ( sent < 1024 ) { if ( !BdatWrite(Buf + 1000, 24) ) { /* top up */ xrtSmtpClientAbort(pClient); /* can't: abort */ } } BdatEnd();
Programming Exercises
Construct four reply byte streams — single-line/multiline/inconsistent codes/no terminating line — through LineRead→ReplyRead. Acceptance criteria: the legal groups parse correctly; the illegal groups yield distinguishable structured errors.
Feed CommandWrite and PathValid inputs containing CRLF/control characters/excessive length — verify all rejected. Acceptance criteria: five hostile input classes (CRLF/bare CR/control bytes/over 512B/angle brackets) each rejected by the correct entrance.
DATA path: a 10 MB attachment streamed in 8 KiB chunks (dot transparency across boundaries) — compare with the BDAT path on the same attachment. Measure peak memory and wire bytes (DATA slightly more due to escaping). Acceptance criteria: both paths deliver the same content; the DATA path's peak constant; the boundary cases (chunk boundary exactly at a line start / before a dot) pass their test vectors.
Summary
| Topic | Quick reference |
|---|---|
| Layering | protocol primitives (offline-testable) → client (state-machine assembly) — no second implementation |
| Injection defense | commands reject CR/LF/control/512B; paths validate inside the angle brackets |
| Session state | READY→MAIL→RECIPIENT→DATA→READY; EHLO falls back only on explicit rejection |
| Ownership | shared objects borrowed, not destroyed; a single Client has no concurrency; Worker callbacks forbidden |
| Blocking shape | absolute deadline + optional cancel |
| Three finishes | Quit/Close/Abort (immediate, idempotent, FAILED keeps diagnostics) |
| DATA | dot transparency across chunks; zero whole-message copies; CRLF is the caller's |
| BDAT | declared bytes sent raw, unescaped; each chunk reads 250; mismatch topped up or aborted |
| BINARYMIME | capability bit + generic MAIL parameter — no envelope API duplication |
| TLS | implicit/STARTTLS two forms; re-EHLO after upgrade; a verifier is mandatory |
📖 API references in this chapter:xsmtp-smtp (9 entries)xsmtp-smtp_client (29 entries)xmail-mail (3 entries)
Click for full signatures, parameter constraints, return values and error notes