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/// Sends one BDAT chunk (the CHUNKING extension,
418/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the
419/// server's reply. Use only when `Extensions.chunking` was advertised. The
420/// chunk is transmitted verbatim — no dot-stuffing and no line-ending
421/// normalization — so message content must already use CRLF line endings.
422/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator.
423pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void {
424 if (last) {
425 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len});
426 } else {
427 try c.writer.print("BDAT {d}\r\n", .{chunk.len});
428 }
429 try c.writer.writeAll(chunk);
430 try c.writer.flush();
431 _ = try c.expectClass(2);
432}
433
434/// Sends the message content for the current transaction as a single BDAT
435/// chunk. See `bdat` for the transmission caveats.
436pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void {
437 try c.bdat(message_data, true);
438}
439
440/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient,
441/// then DATA. Call after `greet` and `hello`.
442pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) Error!void {
443 try c.mailFrom(from);
444 for (recipients) |recipient| try c.rcptTo(recipient);
445 try c.sendMessage(message_data);
446}
447
448/// Aborts the current mail transaction.
449pub fn rset(c: *Client) Error!void {
450 try c.send("RSET", .{});
451 _ = try c.expectClass(2);
452}
453
454pub fn noop(c: *Client) Error!void {
455 try c.send("NOOP", .{});
456 _ = try c.expectClass(2);
457}
458
459/// Ends the session. The connection should be closed afterwards.
460pub fn quit(c: *Client) Error!void {
461 try c.send("QUIT", .{});
462 _ = try c.expect(221);
463}
464
465fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void {
466 try c.writer.print(fmt ++ protocol.crlf, args);
467 try c.writer.flush();
468}
469
470fn readReply(c: *Client) Error!Reply {
471 const reply = try Reply.read(c.reader, c.reply_buffer);
472 c.last_reply = reply;
473 return reply;
474}
475
476fn expect(c: *Client, code: u16) Error!Reply {
477 const reply = try c.readReply();
478 if (reply.code != code) return error.UnexpectedReply;
479 return reply;
480}
481
482fn expectClass(c: *Client, class: u16) Error!Reply {
483 const reply = try c.readReply();
484 if (reply.code / 100 != class) return error.UnexpectedReply;
485 return reply;
486}
487
488test sendMail {
489 const responses = "220 mx.example.com ESMTP\r\n" ++
490 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++
491 "250 2.1.0 Ok\r\n" ++
492 "250 2.1.5 Ok\r\n" ++
493 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
494 "250 2.0.0 Ok\r\n" ++
495 "221 2.0.0 Bye\r\n";
496 var reader: Io.Reader = .fixed(responses);
497 var out_buf: [1024]u8 = undefined;
498 var writer: Io.Writer = .fixed(&out_buf);
499 var reply_buf: [512]u8 = undefined;
500 var client: Client = .init(&reader, &writer, &reply_buf);
501
502 _ = try client.greet();
503 const ext = try client.hello("client.example.org");
504 try std.testing.expect(ext.pipelining);
505 try std.testing.expect(ext.eight_bit_mime);
506 try std.testing.expect(!ext.starttls);
507 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size);
508
509 try client.sendMail(
510 "alice@example.com",
511 &.{"bob@example.net"},
512 "Subject: hi\r\n\r\n.leading dot\r\n",
513 );
514 try client.quit();
515
516 try std.testing.expectEqualStrings(
517 "EHLO client.example.org\r\n" ++
518 "MAIL FROM:<alice@example.com>\r\n" ++
519 "RCPT TO:<bob@example.net>\r\n" ++
520 "DATA\r\n" ++
521 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++
522 "QUIT\r\n",
523 writer.buffered(),
524 );
525}
526
527test "HELO fallback for non-ESMTP servers" {
528 const responses = "220 old.example.com\r\n" ++
529 "502 command not implemented\r\n" ++
530 "250 old.example.com\r\n";
531 var reader: Io.Reader = .fixed(responses);
532 var out_buf: [256]u8 = undefined;
533 var writer: Io.Writer = .fixed(&out_buf);
534 var reply_buf: [256]u8 = undefined;
535 var client: Client = .init(&reader, &writer, &reply_buf);
536
537 _ = try client.greet();
538 const ext = try client.hello("client.example.org");
539 try std.testing.expectEqual(Extensions{}, ext);
540 try std.testing.expectEqualStrings(
541 "EHLO client.example.org\r\nHELO client.example.org\r\n",
542 writer.buffered(),
543 );
544}
545
546test "rejected recipient surfaces the reply" {
547 const responses = "550 5.1.1 No such user\r\n";
548 var reader: Io.Reader = .fixed(responses);
549 var out_buf: [256]u8 = undefined;
550 var writer: Io.Writer = .fixed(&out_buf);
551 var reply_buf: [256]u8 = undefined;
552 var client: Client = .init(&reader, &writer, &reply_buf);
553
554 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com"));
555 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
556 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text);
557}
558
559test starttls {
560 const plain_responses = "220 mx.example.com ESMTP\r\n" ++
561 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++
562 "220 2.0.0 Ready to start TLS\r\n";
563 var reader: Io.Reader = .fixed(plain_responses);
564 var out_buf: [256]u8 = undefined;
565 var writer: Io.Writer = .fixed(&out_buf);
566 var reply_buf: [256]u8 = undefined;
567 var client: Client = .init(&reader, &writer, &reply_buf);
568
569 _ = try client.greet();
570 const ext = try client.hello("client.example.org");
571 try std.testing.expect(ext.starttls);
572 try client.starttls();
573
574 // Simulate the post-handshake encrypted transport with fresh buffers;
575 // the session must re-EHLO on it.
576 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n";
577 var tls_reader: Io.Reader = .fixed(tls_responses);
578 var tls_out_buf: [256]u8 = undefined;
579 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
580 client.setTransport(&tls_reader, &tls_writer);
581
582 const tls_ext = try client.hello("client.example.org");
583 try std.testing.expect(!tls_ext.starttls);
584 try std.testing.expect(tls_ext.eight_bit_mime);
585 try std.testing.expectEqualStrings(
586 "EHLO client.example.org\r\nSTARTTLS\r\n",
587 writer.buffered(),
588 );
589 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered());
590}
591
592test authPlain {
593 const responses = "235 2.7.0 Accepted\r\n";
594 var reader: Io.Reader = .fixed(responses);
595 var out_buf: [256]u8 = undefined;
596 var writer: Io.Writer = .fixed(&out_buf);
597 var reply_buf: [256]u8 = undefined;
598 var client: Client = .init(&reader, &writer, &reply_buf);
599
600 try client.authPlain("", "user", "pass");
601 // base64("\x00user\x00pass")
602 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered());
603}
604
605test authLogin {
606 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n";
607 var reader: Io.Reader = .fixed(responses);
608 var out_buf: [256]u8 = undefined;
609 var writer: Io.Writer = .fixed(&out_buf);
610 var reply_buf: [256]u8 = undefined;
611 var client: Client = .init(&reader, &writer, &reply_buf);
612
613 try client.authLogin("user", "pass");
614 try std.testing.expectEqualStrings(
615 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n",
616 writer.buffered(),
617 );
618}
619
620test authCramMd5 {
621 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim",
622 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890.
623 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++
624 "235 2.7.0 Accepted\r\n";
625 var reader: Io.Reader = .fixed(responses);
626 var out_buf: [256]u8 = undefined;
627 var writer: Io.Writer = .fixed(&out_buf);
628 var reply_buf: [256]u8 = undefined;
629 var client: Client = .init(&reader, &writer, &reply_buf);
630
631 try client.authCramMd5("tim", "tanstaaftanstaaf");
632 try std.testing.expectEqualStrings(
633 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n",
634 writer.buffered(),
635 );
636}
637
638test authenticate {
639 var out_buf: [256]u8 = undefined;
640 var reply_buf: [256]u8 = undefined;
641 {
642 // Only CRAM-MD5 advertised.
643 const responses = "334 YWJj\r\n235 ok\r\n";
644 var reader: Io.Reader = .fixed(responses);
645 var writer: Io.Writer = .fixed(&out_buf);
646 var client: Client = .init(&reader, &writer, &reply_buf);
647 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p");
648 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n"));
649 }
650 {
651 // Nothing advertised.
652 var reader: Io.Reader = .fixed("");
653 var writer: Io.Writer = .fixed(&out_buf);
654 var client: Client = .init(&reader, &writer, &reply_buf);
655 try std.testing.expectError(
656 error.NoSupportedMechanism,
657 client.authenticate(.{}, "u", "p"),
658 );
659 }
660}
661
662test "rejected credentials surface AuthenticationFailed" {
663 const responses = "535 5.7.8 Authentication credentials invalid\r\n";
664 var reader: Io.Reader = .fixed(responses);
665 var out_buf: [256]u8 = undefined;
666 var writer: Io.Writer = .fixed(&out_buf);
667 var reply_buf: [256]u8 = undefined;
668 var client: Client = .init(&reader, &writer, &reply_buf);
669
670 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p"));
671 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code);
672}
673
674test hello {
675 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n";
676 var reader: Io.Reader = .fixed(responses);
677 var out_buf: [256]u8 = undefined;
678 var writer: Io.Writer = .fixed(&out_buf);
679 var reply_buf: [256]u8 = undefined;
680 var client: Client = .init(&reader, &writer, &reply_buf);
681
682 const ext = try client.hello("c.example");
683 try std.testing.expect(ext.auth.plain);
684 try std.testing.expect(ext.auth.login);
685 try std.testing.expect(ext.auth.cram_md5);
686 try std.testing.expect(ext.auth.any());
687}
688
689test init {
690 var reader: Io.Reader = .fixed("");
691 var out_buf: [16]u8 = undefined;
692 var writer: Io.Writer = .fixed(&out_buf);
693 var reply_buf: [128]u8 = undefined;
694 const client: Client = .init(&reader, &writer, &reply_buf);
695 try std.testing.expect(client.last_reply == null);
696}
697
698test greet {
699 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n");
700 var out_buf: [16]u8 = undefined;
701 var writer: Io.Writer = .fixed(&out_buf);
702 var reply_buf: [128]u8 = undefined;
703 var client: Client = .init(&reader, &writer, &reply_buf);
704
705 const reply = try client.greet();
706 try std.testing.expectEqual(@as(u16, 220), reply.code);
707 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text);
708}
709
710test setTransport {
711 var reader: Io.Reader = .fixed("");
712 var out_buf: [16]u8 = undefined;
713 var writer: Io.Writer = .fixed(&out_buf);
714 var reply_buf: [64]u8 = undefined;
715 var client: Client = .init(&reader, &writer, &reply_buf);
716
717 // After a TLS handshake, point the session at the encrypted streams.
718 var tls_reader: Io.Reader = .fixed("");
719 var tls_out_buf: [16]u8 = undefined;
720 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
721 client.setTransport(&tls_reader, &tls_writer);
722 try std.testing.expectEqual(&tls_reader, client.reader);
723 try std.testing.expectEqual(&tls_writer, client.writer);
724}
725
726test mailFrom {
727 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
728 var out_buf: [64]u8 = undefined;
729 var writer: Io.Writer = .fixed(&out_buf);
730 var reply_buf: [64]u8 = undefined;
731 var client: Client = .init(&reader, &writer, &reply_buf);
732
733 try client.mailFrom("alice@example.com");
734 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered());
735}
736
737test rcptTo {
738 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n");
739 var out_buf: [64]u8 = undefined;
740 var writer: Io.Writer = .fixed(&out_buf);
741 var reply_buf: [64]u8 = undefined;
742 var client: Client = .init(&reader, &writer, &reply_buf);
743
744 try client.rcptTo("bob@example.net");
745 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered());
746}
747
748test sendMessage {
749 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n");
750 var out_buf: [128]u8 = undefined;
751 var writer: Io.Writer = .fixed(&out_buf);
752 var reply_buf: [64]u8 = undefined;
753 var client: Client = .init(&reader, &writer, &reply_buf);
754
755 try client.sendMessage("Subject: hi\n\nhello\n");
756 try std.testing.expectEqualStrings(
757 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n",
758 writer.buffered(),
759 );
760}
761
762test rset {
763 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
764 var out_buf: [16]u8 = undefined;
765 var writer: Io.Writer = .fixed(&out_buf);
766 var reply_buf: [64]u8 = undefined;
767 var client: Client = .init(&reader, &writer, &reply_buf);
768
769 try client.rset();
770 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered());
771}
772
773test noop {
774 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
775 var out_buf: [16]u8 = undefined;
776 var writer: Io.Writer = .fixed(&out_buf);
777 var reply_buf: [64]u8 = undefined;
778 var client: Client = .init(&reader, &writer, &reply_buf);
779
780 try client.noop();
781 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered());
782}
783
784test quit {
785 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n");
786 var out_buf: [16]u8 = undefined;
787 var writer: Io.Writer = .fixed(&out_buf);
788 var reply_buf: [64]u8 = undefined;
789 var client: Client = .init(&reader, &writer, &reply_buf);
790
791 try client.quit();
792 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered());
793}
794
795test data {
796 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
797 var out_buf: [256]u8 = undefined;
798 var writer: Io.Writer = .fixed(&out_buf);
799 var reply_buf: [64]u8 = undefined;
800 var client: Client = .init(&reader, &writer, &reply_buf);
801
802 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily.
803 var data_writer = try client.data();
804 try data_writer.interface.writeAll("Subject: chunked\n\nfirst");
805 try data_writer.interface.writeAll(" second\r");
806 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r");
807 try data_writer.interface.writeAll("\n");
808 try data_writer.interface.writeAll(".x\nend");
809 try data_writer.end();
810
811 try std.testing.expectEqualStrings(
812 "DATA\r\n" ++
813 "Subject: chunked\r\n" ++
814 "\r\n" ++
815 "first second\r\n" ++
816 "..needs stuffing\r\n" ++
817 "split\r\n" ++
818 "..x\r\n" ++
819 "end\r\n" ++
820 ".\r\n",
821 writer.buffered(),
822 );
823}
824
825test sendMessageReader {
826 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
827 var out_buf: [128]u8 = undefined;
828 var writer: Io.Writer = .fixed(&out_buf);
829 var reply_buf: [64]u8 = undefined;
830 var client: Client = .init(&reader, &writer, &reply_buf);
831
832 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n");
833 try client.sendMessageReader(&message);
834 try std.testing.expectEqualStrings(
835 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n",
836 writer.buffered(),
837 );
838}
839
840test "fuzz client against arbitrary server replies" {
841 try std.testing.fuzz({}, fuzzClientReplies, .{});
842}
843
844fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void {
845 _ = context;
846 var input_buf: [1024]u8 = undefined;
847 const input = input_buf[0..smith.value(u10)];
848 smith.bytes(input);
849
850 var reader: Io.Reader = .fixed(input);
851 var out_buf: [4096]u8 = undefined;
852 var writer: Io.Writer = .fixed(&out_buf);
853 var reply_buf: [256]u8 = undefined;
854 var client: Client = .init(&reader, &writer, &reply_buf);
855
856 // Whatever the "server" says, the client must fail cleanly, never crash.
857 _ = client.greet() catch return;
858 const extensions = client.hello("fuzz.example.org") catch return;
859 client.authenticate(extensions, "user", "password") catch {};
860 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {};
861 client.quit() catch {};
862}
863
864test "fuzz DataWriter equivalence with writeStuffed" {
865 try std.testing.fuzz({}, fuzzDataWriter, .{});
866}
867
868fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void {
869 _ = context;
870 var message_buf: [1024]u8 = undefined;
871 const message = message_buf[0..smith.value(u10)];
872 smith.bytes(message);
873
874 // Reference implementation: slice-based stuffing.
875 var expected_buf: [2100]u8 = undefined;
876 var expected: Io.Writer = .fixed(&expected_buf);
877 try protocol.writeStuffed(&expected, message);
878
879 // Streaming implementation, with fuzzer-chosen chunk boundaries.
880 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n");
881 var out_buf: [2200]u8 = undefined;
882 var writer: Io.Writer = .fixed(&out_buf);
883 var reply_buf: [64]u8 = undefined;
884 var client: Client = .init(&responses, &writer, &reply_buf);
885
886 var data_writer = try client.data();
887 var rest: []const u8 = message;
888 while (rest.len > 0) {
889 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len));
890 try data_writer.interface.writeAll(rest[0..n]);
891 rest = rest[n..];
892 }
893 try data_writer.end();
894
895 const written = writer.buffered();
896 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n"));
897 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n"));
898 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len];
899 try std.testing.expectEqualStrings(expected.buffered(), stuffed);
900}
901
902test Extensions {
903 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 };
904 try std.testing.expect(extensions.pipelining);
905 try std.testing.expect(!extensions.starttls);
906 try std.testing.expect(!extensions.auth.any());
907 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size);
908}
909
910test bdat {
911 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n");
912 var out_buf: [128]u8 = undefined;
913 var writer: Io.Writer = .fixed(&out_buf);
914 var reply_buf: [64]u8 = undefined;
915 var client: Client = .init(&reader, &writer, &reply_buf);
916
917 try client.bdat("Subject: hi\r\n\r\n", false);
918 try client.bdat("body\r\n", true);
919 try std.testing.expectEqualStrings(
920 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n",
921 writer.buffered(),
922 );
923}
924
925test sendMessageChunked {
926 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
927 var out_buf: [128]u8 = undefined;
928 var writer: Io.Writer = .fixed(&out_buf);
929 var reply_buf: [64]u8 = undefined;
930 var client: Client = .init(&reader, &writer, &reply_buf);
931
932 // Raw transmission: the leading dot is not stuffed.
933 try client.sendMessageChunked(".raw\r\n");
934 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered());
935}