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