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