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