An SMTP client and server library for Zig implementing RFC 5321.
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! An SMTP client session over any `Io.Reader`/`Io.Writer` pair, which keeps
5//! it transport-agnostic: wrap a TCP stream for real use, or fixed buffers
6//! for testing. TLS can be layered in the same way once the transport
7//! supports it.
8//!
9//! Typical use:
10//! ```
11//! var client: Client = .init(&stream_reader, &stream_writer, &reply_buf);
12//! _ = try client.greet();
13//! _ = try client.hello("my-host.example.com");
14//! try client.sendMail("me@example.com", &.{"you@example.net"}, message);
15//! try client.quit();
16//! ```
17
18const Client = @This();
19
20const std = @import("std");
21const Io = std.Io;
22const protocol = @import("protocol.zig");
23const Reply = protocol.Reply;
24
25reader: *Io.Reader,
26writer: *Io.Writer,
27/// Backing storage for reply text; `last_reply.text` points into it.
28reply_buffer: []u8,
29/// The most recent reply read from the server. Useful for reporting the
30/// server's actual response after an `error.UnexpectedReply`.
31last_reply: ?Reply = null,
32
33pub const Error = error{
34 WriteFailed,
35 ReadFailed,
36 EndOfStream,
37 LineTooLong,
38 InvalidReply,
39 ReplyTooLong,
40 /// The server answered with an unexpected code; see `last_reply`.
41 UnexpectedReply,
42};
43
44/// Extensions advertised in the server's EHLO response.
45pub const Extensions = struct {
46 pipelining: bool = false,
47 eight_bit_mime: bool = false,
48 starttls: bool = false,
49 smtputf8: bool = false,
50 chunking: bool = false,
51 enhanced_status_codes: bool = false,
52 /// AUTH mechanisms advertised by the server.
53 auth: Auth = .{},
54 /// Value of the SIZE extension, if advertised with a value.
55 max_size: ?u64 = null,
56
57 pub const Auth = struct {
58 plain: bool = false,
59 login: bool = false,
60 cram_md5: bool = false,
61
62 pub fn any(a: Auth) bool {
63 return a.plain or a.login or a.cram_md5;
64 }
65
66 test any {
67 try std.testing.expect((Auth{ .login = true }).any());
68 try std.testing.expect(!(Auth{}).any());
69 }
70
71 fn parse(arg: []const u8) Auth {
72 var auth: Auth = .{};
73 var it = std.mem.tokenizeScalar(u8, arg, ' ');
74 while (it.next()) |mechanism| {
75 if (ieql(mechanism, "PLAIN")) {
76 auth.plain = true;
77 } else if (ieql(mechanism, "LOGIN")) {
78 auth.login = true;
79 } else if (ieql(mechanism, "CRAM-MD5")) {
80 auth.cram_md5 = true;
81 }
82 }
83 return auth;
84 }
85 };
86
87 fn parse(reply: Reply) Extensions {
88 var ext: Extensions = .{};
89 var it = reply.lines();
90 _ = it.next(); // The first line is the server's greeting, not a keyword.
91 while (it.next()) |line| {
92 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
93 const kw = line[0..kw_end];
94 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else "";
95 if (ieql(kw, "PIPELINING")) {
96 ext.pipelining = true;
97 } else if (ieql(kw, "8BITMIME")) {
98 ext.eight_bit_mime = true;
99 } else if (ieql(kw, "STARTTLS")) {
100 ext.starttls = true;
101 } else if (ieql(kw, "SMTPUTF8")) {
102 ext.smtputf8 = true;
103 } else if (ieql(kw, "CHUNKING")) {
104 ext.chunking = true;
105 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) {
106 ext.enhanced_status_codes = true;
107 } else if (ieql(kw, "AUTH")) {
108 ext.auth = Auth.parse(arg);
109 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) {
110 // Some legacy servers advertise "AUTH=PLAIN LOGIN".
111 var legacy_arg_buf: [128]u8 = undefined;
112 const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..];
113 ext.auth = Auth.parse(joined);
114 } else if (ieql(kw, "SIZE")) {
115 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null;
116 }
117 }
118 return ext;
119 }
120
121 fn ieql(a: []const u8, b: []const u8) bool {
122 return std.ascii.eqlIgnoreCase(a, b);
123 }
124};
125
126/// `reply_buffer` must be large enough for the largest expected reply text
127/// (the EHLO response is usually the largest); 512 bytes is plenty in
128/// practice.
129pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client {
130 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer };
131}
132
133/// Reads the server's 220 greeting. Call once, right after connecting.
134pub fn greet(c: *Client) Error!Reply {
135 return c.expect(220);
136}
137
138/// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1))
139/// and returns the extensions the server advertised, falling back
140/// to plain HELO for servers that do not speak ESMTP.
141pub fn hello(c: *Client, client_name: []const u8) Error!Extensions {
142 try c.send("EHLO {s}", .{client_name});
143 const reply = try c.readReply();
144 if (reply.isPositiveCompletion()) return Extensions.parse(reply);
145 if (reply.code == 500 or reply.code == 502) {
146 try c.send("HELO {s}", .{client_name});
147 _ = try c.expectClass(2);
148 return .{};
149 }
150 return error.UnexpectedReply;
151}
152
153/// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and
154/// reads the server's 220 go-ahead. On
155/// success, perform a TLS handshake over the underlying stream (see `Tls`),
156/// switch to the encrypted transport with `setTransport`, and then call
157/// `hello` again — the server discards everything it learned before the
158/// handshake, including the EHLO state.
159pub fn starttls(c: *Client) Error!void {
160 try c.send("STARTTLS", .{});
161 _ = try c.expect(220);
162}
163
164/// Replaces the session's transport, typically with a TLS reader/writer
165/// after `starttls`.
166pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void {
167 c.reader = reader;
168 c.writer = writer;
169}
170
171pub const AuthError = Error || error{
172 CredentialsTooLong,
173 /// The server rejected the credentials; see `last_reply`.
174 AuthenticationFailed,
175 /// The server's CRAM-MD5 challenge was not valid base64.
176 InvalidChallenge,
177 /// The server advertised none of the supported mechanisms.
178 NoSupportedMechanism,
179};
180
181/// Authenticates with the best mechanism the server advertised (PLAIN,
182/// then LOGIN, then CRAM-MD5). Note that PLAIN and LOGIN send credentials
183/// unprotected: use TLS on real networks.
184pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void {
185 if (extensions.auth.plain) return c.authPlain("", username, password);
186 if (extensions.auth.login) return c.authLogin(username, password);
187 if (extensions.auth.cram_md5) return c.authCramMd5(username, password);
188 return error.NoSupportedMechanism;
189}
190
191/// Authenticates with AUTH PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)).
192/// Pass an empty `authzid` unless
193/// you need to act on behalf of another identity. Note that sending
194/// credentials over an unencrypted connection exposes them to the network.
195pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void {
196 var plain_buf: [512]u8 = undefined;
197 var plain: Io.Writer = .fixed(&plain_buf);
198 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch
199 return error.CredentialsTooLong;
200 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined;
201 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered());
202 try c.send("AUTH PLAIN {s}", .{b64});
203 try c.expectAuthSuccess();
204}
205
206/// Authenticates with AUTH LOGIN, the legacy two-step username/password
207/// exchange still required by some servers (no RFC; the de-facto
208/// [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00)
209/// mechanism).
210pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void {
211 try c.send("AUTH LOGIN", .{});
212 _ = try c.expect(334); // Username: prompt
213 try c.sendBase64(username);
214 _ = try c.expect(334); // Password: prompt
215 try c.sendBase64(password);
216 try c.expectAuthSuccess();
217}
218
219/// Authenticates with AUTH CRAM-MD5 ([RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195)):
220/// the password never crosses
221/// the wire, only an HMAC-MD5 of the server's challenge.
222pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void {
223 try c.send("AUTH CRAM-MD5", .{});
224 const reply = try c.expect(334);
225
226 var challenge_buf: [512]u8 = undefined;
227 const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch
228 return error.InvalidChallenge;
229 if (challenge_len > challenge_buf.len) return error.InvalidChallenge;
230 std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch
231 return error.InvalidChallenge;
232
233 var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined;
234 std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password);
235 const digest = std.fmt.bytesToHex(mac, .lower);
236
237 var response_buf: [384]u8 = undefined;
238 var response: Io.Writer = .fixed(&response_buf);
239 response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong;
240 try c.sendBase64(response.buffered());
241 try c.expectAuthSuccess();
242}
243
244/// Sends `bytes` base64-encoded as a bare continuation line.
245fn sendBase64(c: *Client, bytes: []const u8) AuthError!void {
246 var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined;
247 if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len)
248 return error.CredentialsTooLong;
249 const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes);
250 try c.send("{s}", .{b64});
251}
252
253fn expectAuthSuccess(c: *Client) AuthError!void {
254 const reply = try c.readReply();
255 if (reply.code != 235) return error.AuthenticationFailed;
256}
257
258/// Starts a mail transaction. An empty `from` sends the null reverse-path
259/// (`MAIL FROM:<>`), used for bounces.
260pub fn mailFrom(c: *Client, from: []const u8) Error!void {
261 try c.send("MAIL FROM:<{s}>", .{from});
262 _ = try c.expectClass(2);
263}
264
265pub fn rcptTo(c: *Client, to: []const u8) Error!void {
266 try c.send("RCPT TO:<{s}>", .{to});
267 _ = try c.expectClass(2);
268}
269
270/// Sends the message content for the current transaction (DATA). Line
271/// endings in `data` are normalized to CRLF and leading dots are stuffed.
272pub fn sendMessage(c: *Client, message_data: []const u8) Error!void {
273 var data_writer = try c.data();
274 try data_writer.interface.writeAll(message_data);
275 try data_writer.end();
276}
277
278/// Streams the message content for the current transaction from `message`
279/// until end of stream. Line endings are normalized to CRLF and leading
280/// dots stuffed; nothing is buffered beyond the transport writer, so lines
281/// and messages of any length work.
282pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void {
283 var data_writer = try c.data();
284 while (true) {
285 const chunk = message.peekGreedy(1) catch |err| switch (err) {
286 error.EndOfStream => break,
287 error.ReadFailed => return error.ReadFailed,
288 };
289 try data_writer.interface.writeAll(chunk);
290 message.toss(chunk.len);
291 }
292 try data_writer.end();
293}
294
295/// Starts the DATA phase for streaming a message body: write the content
296/// through the returned writer's `interface`, then call `end`. Line endings
297/// are normalized to CRLF and leading dots stuffed as the data flows.
298pub fn data(c: *Client) Error!DataWriter {
299 try c.send("DATA", .{});
300 _ = try c.expect(354);
301 return .{
302 .client = c,
303 .interface = .{
304 .buffer = &.{},
305 .vtable = &.{ .drain = DataWriter.drain },
306 },
307 };
308}
309
310/// Streaming writer for a message body; obtained from `data`. The dot
311/// stuffing and CRLF normalization state lives here, so chunks may split
312/// lines (and even CRLF pairs) at any byte boundary.
313pub const DataWriter = struct {
314 client: *Client,
315 interface: Io.Writer,
316 at_line_start: bool = true,
317 /// A '\r' was seen but not yet emitted; whether it is a line ending
318 /// depends on the next byte.
319 pending_cr: bool = false,
320
321 /// Terminates the message (adding a final CRLF if the content did not
322 /// end with one, then ".\r\n") and reads the server's verdict.
323 pub fn end(dw: *DataWriter) Error!void {
324 try dw.interface.flush();
325 const c = dw.client;
326 if (dw.pending_cr) {
327 // A trailing bare CR counts as a line ending, matching
328 // `protocol.writeStuffed`.
329 dw.pending_cr = false;
330 dw.at_line_start = true;
331 try c.writer.writeAll(protocol.crlf);
332 }
333 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf);
334 try c.writer.writeAll("." ++ protocol.crlf);
335 try c.writer.flush();
336 _ = try c.expectClass(2);
337 }
338
339 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize {
340 const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w));
341 try dw.writeChunk(w.buffered());
342 w.end = 0;
343 if (chunks.len == 0) return 0;
344 var n: usize = 0;
345 for (chunks[0 .. chunks.len - 1]) |bytes| {
346 try dw.writeChunk(bytes);
347 n += bytes.len;
348 }
349 const pattern = chunks[chunks.len - 1];
350 for (0..splat) |_| {
351 try dw.writeChunk(pattern);
352 n += pattern.len;
353 }
354 return n;
355 }
356
357 test end {
358 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
359 var out_buf: [64]u8 = undefined;
360 var writer: Io.Writer = .fixed(&out_buf);
361 var reply_buf: [64]u8 = undefined;
362 var client: Client = .init(&reader, &writer, &reply_buf);
363
364 var data_writer = try client.data();
365 try data_writer.interface.writeAll("no trailing newline");
366 try data_writer.end(); // adds the final CRLF, sends ".", reads 250
367 try std.testing.expectEqualStrings(
368 "DATA\r\nno trailing newline\r\n.\r\n",
369 writer.buffered(),
370 );
371 }
372
373 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void {
374 const out = dw.client.writer;
375 var rest = bytes;
376 while (rest.len > 0) {
377 if (dw.pending_cr) {
378 dw.pending_cr = false;
379 if (rest[0] == '\n') {
380 try out.writeAll(protocol.crlf);
381 dw.at_line_start = true;
382 rest = rest[1..];
383 continue;
384 }
385 // A bare CR mid-line passes through untouched.
386 try out.writeByte('\r');
387 dw.at_line_start = false;
388 }
389 if (dw.at_line_start and rest[0] == '.') {
390 try out.writeAll("..");
391 dw.at_line_start = false;
392 rest = rest[1..];
393 continue;
394 }
395 const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse {
396 try out.writeAll(rest);
397 dw.at_line_start = false;
398 break;
399 };
400 if (special > 0) {
401 try out.writeAll(rest[0..special]);
402 dw.at_line_start = false;
403 }
404 switch (rest[special]) {
405 '\r' => dw.pending_cr = true,
406 '\n' => {
407 try out.writeAll(protocol.crlf);
408 dw.at_line_start = true;
409 },
410 else => unreachable,
411 }
412 rest = rest[special + 1 ..];
413 }
414 }
415};
416
417/// Like `mailFrom`, but requests the SMTPUTF8 extension
418/// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the
419/// envelope addresses and message headers may contain UTF-8. Use only when
420/// `Extensions.smtputf8` was advertised.
421pub fn mailFromUtf8(c: *Client, from: []const u8) Error!void {
422 try c.send("MAIL FROM:<{s}> SMTPUTF8", .{from});
423 _ = try c.expectClass(2);
424}
425
426/// Sends one BDAT chunk (the CHUNKING extension,
427/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the
428/// server's reply. Use only when `Extensions.chunking` was advertised. The
429/// chunk is transmitted verbatim — no dot-stuffing and no line-ending
430/// normalization — so message content must already use CRLF line endings.
431/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator.
432pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void {
433 if (last) {
434 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len});
435 } else {
436 try c.writer.print("BDAT {d}\r\n", .{chunk.len});
437 }
438 try c.writer.writeAll(chunk);
439 try c.writer.flush();
440 _ = try c.expectClass(2);
441}
442
443/// Sends the message content for the current transaction as a single BDAT
444/// chunk. See `bdat` for the transmission caveats.
445pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void {
446 try c.bdat(message_data, true);
447}
448
449/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient,
450/// then DATA. Call after `greet` and `hello`.
451pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) Error!void {
452 try c.mailFrom(from);
453 for (recipients) |recipient| try c.rcptTo(recipient);
454 try c.sendMessage(message_data);
455}
456
457/// Aborts the current mail transaction.
458pub fn rset(c: *Client) Error!void {
459 try c.send("RSET", .{});
460 _ = try c.expectClass(2);
461}
462
463pub fn noop(c: *Client) Error!void {
464 try c.send("NOOP", .{});
465 _ = try c.expectClass(2);
466}
467
468/// Ends the session. The connection should be closed afterwards.
469pub fn quit(c: *Client) Error!void {
470 try c.send("QUIT", .{});
471 _ = try c.expect(221);
472}
473
474fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void {
475 try c.writer.print(fmt ++ protocol.crlf, args);
476 try c.writer.flush();
477}
478
479fn readReply(c: *Client) Error!Reply {
480 const reply = try Reply.read(c.reader, c.reply_buffer);
481 c.last_reply = reply;
482 return reply;
483}
484
485fn expect(c: *Client, code: u16) Error!Reply {
486 const reply = try c.readReply();
487 if (reply.code != code) return error.UnexpectedReply;
488 return reply;
489}
490
491fn expectClass(c: *Client, class: u16) Error!Reply {
492 const reply = try c.readReply();
493 if (reply.code / 100 != class) return error.UnexpectedReply;
494 return reply;
495}
496
497test sendMail {
498 const responses = "220 mx.example.com ESMTP\r\n" ++
499 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++
500 "250 2.1.0 Ok\r\n" ++
501 "250 2.1.5 Ok\r\n" ++
502 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
503 "250 2.0.0 Ok\r\n" ++
504 "221 2.0.0 Bye\r\n";
505 var reader: Io.Reader = .fixed(responses);
506 var out_buf: [1024]u8 = undefined;
507 var writer: Io.Writer = .fixed(&out_buf);
508 var reply_buf: [512]u8 = undefined;
509 var client: Client = .init(&reader, &writer, &reply_buf);
510
511 _ = try client.greet();
512 const ext = try client.hello("client.example.org");
513 try std.testing.expect(ext.pipelining);
514 try std.testing.expect(ext.eight_bit_mime);
515 try std.testing.expect(!ext.starttls);
516 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size);
517
518 try client.sendMail(
519 "alice@example.com",
520 &.{"bob@example.net"},
521 "Subject: hi\r\n\r\n.leading dot\r\n",
522 );
523 try client.quit();
524
525 try std.testing.expectEqualStrings(
526 "EHLO client.example.org\r\n" ++
527 "MAIL FROM:<alice@example.com>\r\n" ++
528 "RCPT TO:<bob@example.net>\r\n" ++
529 "DATA\r\n" ++
530 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++
531 "QUIT\r\n",
532 writer.buffered(),
533 );
534}
535
536test "HELO fallback for non-ESMTP servers" {
537 const responses = "220 old.example.com\r\n" ++
538 "502 command not implemented\r\n" ++
539 "250 old.example.com\r\n";
540 var reader: Io.Reader = .fixed(responses);
541 var out_buf: [256]u8 = undefined;
542 var writer: Io.Writer = .fixed(&out_buf);
543 var reply_buf: [256]u8 = undefined;
544 var client: Client = .init(&reader, &writer, &reply_buf);
545
546 _ = try client.greet();
547 const ext = try client.hello("client.example.org");
548 try std.testing.expectEqual(Extensions{}, ext);
549 try std.testing.expectEqualStrings(
550 "EHLO client.example.org\r\nHELO client.example.org\r\n",
551 writer.buffered(),
552 );
553}
554
555test "rejected recipient surfaces the reply" {
556 const responses = "550 5.1.1 No such user\r\n";
557 var reader: Io.Reader = .fixed(responses);
558 var out_buf: [256]u8 = undefined;
559 var writer: Io.Writer = .fixed(&out_buf);
560 var reply_buf: [256]u8 = undefined;
561 var client: Client = .init(&reader, &writer, &reply_buf);
562
563 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com"));
564 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
565 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text);
566}
567
568test starttls {
569 const plain_responses = "220 mx.example.com ESMTP\r\n" ++
570 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++
571 "220 2.0.0 Ready to start TLS\r\n";
572 var reader: Io.Reader = .fixed(plain_responses);
573 var out_buf: [256]u8 = undefined;
574 var writer: Io.Writer = .fixed(&out_buf);
575 var reply_buf: [256]u8 = undefined;
576 var client: Client = .init(&reader, &writer, &reply_buf);
577
578 _ = try client.greet();
579 const ext = try client.hello("client.example.org");
580 try std.testing.expect(ext.starttls);
581 try client.starttls();
582
583 // Simulate the post-handshake encrypted transport with fresh buffers;
584 // the session must re-EHLO on it.
585 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n";
586 var tls_reader: Io.Reader = .fixed(tls_responses);
587 var tls_out_buf: [256]u8 = undefined;
588 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
589 client.setTransport(&tls_reader, &tls_writer);
590
591 const tls_ext = try client.hello("client.example.org");
592 try std.testing.expect(!tls_ext.starttls);
593 try std.testing.expect(tls_ext.eight_bit_mime);
594 try std.testing.expectEqualStrings(
595 "EHLO client.example.org\r\nSTARTTLS\r\n",
596 writer.buffered(),
597 );
598 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered());
599}
600
601test authPlain {
602 const responses = "235 2.7.0 Accepted\r\n";
603 var reader: Io.Reader = .fixed(responses);
604 var out_buf: [256]u8 = undefined;
605 var writer: Io.Writer = .fixed(&out_buf);
606 var reply_buf: [256]u8 = undefined;
607 var client: Client = .init(&reader, &writer, &reply_buf);
608
609 try client.authPlain("", "user", "pass");
610 // base64("\x00user\x00pass")
611 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered());
612}
613
614test authLogin {
615 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n";
616 var reader: Io.Reader = .fixed(responses);
617 var out_buf: [256]u8 = undefined;
618 var writer: Io.Writer = .fixed(&out_buf);
619 var reply_buf: [256]u8 = undefined;
620 var client: Client = .init(&reader, &writer, &reply_buf);
621
622 try client.authLogin("user", "pass");
623 try std.testing.expectEqualStrings(
624 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n",
625 writer.buffered(),
626 );
627}
628
629test authCramMd5 {
630 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim",
631 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890.
632 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++
633 "235 2.7.0 Accepted\r\n";
634 var reader: Io.Reader = .fixed(responses);
635 var out_buf: [256]u8 = undefined;
636 var writer: Io.Writer = .fixed(&out_buf);
637 var reply_buf: [256]u8 = undefined;
638 var client: Client = .init(&reader, &writer, &reply_buf);
639
640 try client.authCramMd5("tim", "tanstaaftanstaaf");
641 try std.testing.expectEqualStrings(
642 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n",
643 writer.buffered(),
644 );
645}
646
647test authenticate {
648 var out_buf: [256]u8 = undefined;
649 var reply_buf: [256]u8 = undefined;
650 {
651 // Only CRAM-MD5 advertised.
652 const responses = "334 YWJj\r\n235 ok\r\n";
653 var reader: Io.Reader = .fixed(responses);
654 var writer: Io.Writer = .fixed(&out_buf);
655 var client: Client = .init(&reader, &writer, &reply_buf);
656 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p");
657 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n"));
658 }
659 {
660 // Nothing advertised.
661 var reader: Io.Reader = .fixed("");
662 var writer: Io.Writer = .fixed(&out_buf);
663 var client: Client = .init(&reader, &writer, &reply_buf);
664 try std.testing.expectError(
665 error.NoSupportedMechanism,
666 client.authenticate(.{}, "u", "p"),
667 );
668 }
669}
670
671test "rejected credentials surface AuthenticationFailed" {
672 const responses = "535 5.7.8 Authentication credentials invalid\r\n";
673 var reader: Io.Reader = .fixed(responses);
674 var out_buf: [256]u8 = undefined;
675 var writer: Io.Writer = .fixed(&out_buf);
676 var reply_buf: [256]u8 = undefined;
677 var client: Client = .init(&reader, &writer, &reply_buf);
678
679 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p"));
680 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code);
681}
682
683test hello {
684 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n";
685 var reader: Io.Reader = .fixed(responses);
686 var out_buf: [256]u8 = undefined;
687 var writer: Io.Writer = .fixed(&out_buf);
688 var reply_buf: [256]u8 = undefined;
689 var client: Client = .init(&reader, &writer, &reply_buf);
690
691 const ext = try client.hello("c.example");
692 try std.testing.expect(ext.auth.plain);
693 try std.testing.expect(ext.auth.login);
694 try std.testing.expect(ext.auth.cram_md5);
695 try std.testing.expect(ext.auth.any());
696}
697
698test init {
699 var reader: Io.Reader = .fixed("");
700 var out_buf: [16]u8 = undefined;
701 var writer: Io.Writer = .fixed(&out_buf);
702 var reply_buf: [128]u8 = undefined;
703 const client: Client = .init(&reader, &writer, &reply_buf);
704 try std.testing.expect(client.last_reply == null);
705}
706
707test greet {
708 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n");
709 var out_buf: [16]u8 = undefined;
710 var writer: Io.Writer = .fixed(&out_buf);
711 var reply_buf: [128]u8 = undefined;
712 var client: Client = .init(&reader, &writer, &reply_buf);
713
714 const reply = try client.greet();
715 try std.testing.expectEqual(@as(u16, 220), reply.code);
716 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text);
717}
718
719test setTransport {
720 var reader: Io.Reader = .fixed("");
721 var out_buf: [16]u8 = undefined;
722 var writer: Io.Writer = .fixed(&out_buf);
723 var reply_buf: [64]u8 = undefined;
724 var client: Client = .init(&reader, &writer, &reply_buf);
725
726 // After a TLS handshake, point the session at the encrypted streams.
727 var tls_reader: Io.Reader = .fixed("");
728 var tls_out_buf: [16]u8 = undefined;
729 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
730 client.setTransport(&tls_reader, &tls_writer);
731 try std.testing.expectEqual(&tls_reader, client.reader);
732 try std.testing.expectEqual(&tls_writer, client.writer);
733}
734
735test mailFrom {
736 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
737 var out_buf: [64]u8 = undefined;
738 var writer: Io.Writer = .fixed(&out_buf);
739 var reply_buf: [64]u8 = undefined;
740 var client: Client = .init(&reader, &writer, &reply_buf);
741
742 try client.mailFrom("alice@example.com");
743 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered());
744}
745
746test rcptTo {
747 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n");
748 var out_buf: [64]u8 = undefined;
749 var writer: Io.Writer = .fixed(&out_buf);
750 var reply_buf: [64]u8 = undefined;
751 var client: Client = .init(&reader, &writer, &reply_buf);
752
753 try client.rcptTo("bob@example.net");
754 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered());
755}
756
757test sendMessage {
758 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n");
759 var out_buf: [128]u8 = undefined;
760 var writer: Io.Writer = .fixed(&out_buf);
761 var reply_buf: [64]u8 = undefined;
762 var client: Client = .init(&reader, &writer, &reply_buf);
763
764 try client.sendMessage("Subject: hi\n\nhello\n");
765 try std.testing.expectEqualStrings(
766 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n",
767 writer.buffered(),
768 );
769}
770
771test rset {
772 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
773 var out_buf: [16]u8 = undefined;
774 var writer: Io.Writer = .fixed(&out_buf);
775 var reply_buf: [64]u8 = undefined;
776 var client: Client = .init(&reader, &writer, &reply_buf);
777
778 try client.rset();
779 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered());
780}
781
782test noop {
783 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
784 var out_buf: [16]u8 = undefined;
785 var writer: Io.Writer = .fixed(&out_buf);
786 var reply_buf: [64]u8 = undefined;
787 var client: Client = .init(&reader, &writer, &reply_buf);
788
789 try client.noop();
790 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered());
791}
792
793test quit {
794 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n");
795 var out_buf: [16]u8 = undefined;
796 var writer: Io.Writer = .fixed(&out_buf);
797 var reply_buf: [64]u8 = undefined;
798 var client: Client = .init(&reader, &writer, &reply_buf);
799
800 try client.quit();
801 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered());
802}
803
804test data {
805 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
806 var out_buf: [256]u8 = undefined;
807 var writer: Io.Writer = .fixed(&out_buf);
808 var reply_buf: [64]u8 = undefined;
809 var client: Client = .init(&reader, &writer, &reply_buf);
810
811 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily.
812 var data_writer = try client.data();
813 try data_writer.interface.writeAll("Subject: chunked\n\nfirst");
814 try data_writer.interface.writeAll(" second\r");
815 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r");
816 try data_writer.interface.writeAll("\n");
817 try data_writer.interface.writeAll(".x\nend");
818 try data_writer.end();
819
820 try std.testing.expectEqualStrings(
821 "DATA\r\n" ++
822 "Subject: chunked\r\n" ++
823 "\r\n" ++
824 "first second\r\n" ++
825 "..needs stuffing\r\n" ++
826 "split\r\n" ++
827 "..x\r\n" ++
828 "end\r\n" ++
829 ".\r\n",
830 writer.buffered(),
831 );
832}
833
834test sendMessageReader {
835 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
836 var out_buf: [128]u8 = undefined;
837 var writer: Io.Writer = .fixed(&out_buf);
838 var reply_buf: [64]u8 = undefined;
839 var client: Client = .init(&reader, &writer, &reply_buf);
840
841 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n");
842 try client.sendMessageReader(&message);
843 try std.testing.expectEqualStrings(
844 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n",
845 writer.buffered(),
846 );
847}
848
849test "fuzz client against arbitrary server replies" {
850 try std.testing.fuzz({}, fuzzClientReplies, .{});
851}
852
853fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void {
854 _ = context;
855 var input_buf: [1024]u8 = undefined;
856 const input = input_buf[0..smith.value(u10)];
857 smith.bytes(input);
858
859 var reader: Io.Reader = .fixed(input);
860 var out_buf: [4096]u8 = undefined;
861 var writer: Io.Writer = .fixed(&out_buf);
862 var reply_buf: [256]u8 = undefined;
863 var client: Client = .init(&reader, &writer, &reply_buf);
864
865 // Whatever the "server" says, the client must fail cleanly, never crash.
866 _ = client.greet() catch return;
867 const extensions = client.hello("fuzz.example.org") catch return;
868 client.authenticate(extensions, "user", "password") catch {};
869 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {};
870 client.quit() catch {};
871}
872
873test "fuzz DataWriter equivalence with writeStuffed" {
874 try std.testing.fuzz({}, fuzzDataWriter, .{});
875}
876
877fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void {
878 _ = context;
879 var message_buf: [1024]u8 = undefined;
880 const message = message_buf[0..smith.value(u10)];
881 smith.bytes(message);
882
883 // Reference implementation: slice-based stuffing.
884 var expected_buf: [2100]u8 = undefined;
885 var expected: Io.Writer = .fixed(&expected_buf);
886 try protocol.writeStuffed(&expected, message);
887
888 // Streaming implementation, with fuzzer-chosen chunk boundaries.
889 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n");
890 var out_buf: [2200]u8 = undefined;
891 var writer: Io.Writer = .fixed(&out_buf);
892 var reply_buf: [64]u8 = undefined;
893 var client: Client = .init(&responses, &writer, &reply_buf);
894
895 var data_writer = try client.data();
896 var rest: []const u8 = message;
897 while (rest.len > 0) {
898 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len));
899 try data_writer.interface.writeAll(rest[0..n]);
900 rest = rest[n..];
901 }
902 try data_writer.end();
903
904 const written = writer.buffered();
905 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n"));
906 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n"));
907 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len];
908 try std.testing.expectEqualStrings(expected.buffered(), stuffed);
909}
910
911test Extensions {
912 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 };
913 try std.testing.expect(extensions.pipelining);
914 try std.testing.expect(!extensions.starttls);
915 try std.testing.expect(!extensions.auth.any());
916 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size);
917}
918
919test bdat {
920 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n");
921 var out_buf: [128]u8 = undefined;
922 var writer: Io.Writer = .fixed(&out_buf);
923 var reply_buf: [64]u8 = undefined;
924 var client: Client = .init(&reader, &writer, &reply_buf);
925
926 try client.bdat("Subject: hi\r\n\r\n", false);
927 try client.bdat("body\r\n", true);
928 try std.testing.expectEqualStrings(
929 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n",
930 writer.buffered(),
931 );
932}
933
934test sendMessageChunked {
935 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
936 var out_buf: [128]u8 = undefined;
937 var writer: Io.Writer = .fixed(&out_buf);
938 var reply_buf: [64]u8 = undefined;
939 var client: Client = .init(&reader, &writer, &reply_buf);
940
941 // Raw transmission: the leading dot is not stuffed.
942 try client.sendMessageChunked(".raw\r\n");
943 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered());
944}
945
946test mailFromUtf8 {
947 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
948 var out_buf: [64]u8 = undefined;
949 var writer: Io.Writer = .fixed(&out_buf);
950 var reply_buf: [64]u8 = undefined;
951 var client: Client = .init(&reader, &writer, &reply_buf);
952
953 try client.mailFromUtf8("böb@example.com");
954 try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered());
955}