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