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 sasl = @import("sasl");
24const Reply = protocol.Reply;
25
26reader: *Io.Reader,
27writer: *Io.Writer,
28/// Backing storage for reply text; `last_reply.text` points into it.
29reply_buffer: []u8,
30/// Scratch for the AUTH exchange, needed only by `authenticate` — a client
31/// that never authenticates may leave it empty.
32///
33/// It is the caller's for the same reason `reply_buffer` is: how much room a
34/// mechanism needs is the caller's to know, and the difference is large. The
35/// classic mechanisms want a few hundred bytes; an OAuth bearer token can be
36/// several kilobytes on its own. `sasl_buffer_suggested` is a size that fits
37/// everything short of an unusually fat token.
38///
39/// It is split four-to-three between base64 and plaintext, which is the
40/// ratio base64 expands by — so the usable message is about three sevenths
41/// of what is given.
42sasl_buffer: []u8 = &.{},
43/// The most recent reply read from the server. Useful for reporting the
44/// server's actual response after an `error.UnexpectedReply`.
45last_reply: ?Reply = null,
46/// Whether the transport is encrypted. This library cannot tell on its own
47/// — it is handed a reader and a writer and has no idea what is under them
48/// — so it assumes the worst and the caller says otherwise.
49///
50/// `setTransport` takes the answer as an argument, which covers a STARTTLS
51/// upgrade. A session that speaks TLS from the first byte (port 465) hands
52/// `init` an already-encrypted transport, and sets this itself.
53security: Security = .plaintext,
54/// Whether the server advertised PIPELINING
55/// ([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)), which
56/// `envelope` uses to send a whole envelope in one round trip. Set by
57/// `hello` from the EHLO response, and cleared by a HELO fallback, since
58/// RFC 2920 §3.1 lets a client pipeline only against a server that said it
59/// could take it.
60pipelining: bool = false,
61/// Which protocol to speak. Set before `hello`; see `Protocol`. (Spelled
62/// `mode` rather than `protocol` only because this file's `protocol`
63/// module import already holds that name in this scope; the server's
64/// equivalent is `Server.Options.protocol`.)
65mode: Protocol = .smtp,
66/// Recipients the server has accepted since the last MAIL, which in LMTP
67/// is how many replies the end of the message will draw.
68accepted_recipients: usize = 0,
69/// Whether the current transaction was opened with `BODY=BINARYMIME`, in
70/// which case its content can only go out by BDAT.
71binary: bool = false,
72/// Permits `authenticate`, `authPlain` and `authLogin` to send credentials
73/// over a `.plaintext` transport, which they otherwise refuse with
74/// `error.InsecureTransport`.
75///
76/// The honest use is a connection protected by something outside this
77/// library's view — a unix socket, an SSH tunnel, a loopback test — where
78/// setting `security` to `.encrypted` would be a lie. Anything else is
79/// handing the password to the network.
80allow_cleartext_auth: bool = false,
81
82/// Whether the transport encrypts what is written to it.
83pub const Security = enum { plaintext, encrypted };
84
85/// Which protocol this session speaks. `.lmtp` sends `LHLO` in place of
86/// `EHLO` and expects one reply per accepted recipient at the end of a
87/// message instead of one for the message
88/// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)); everything
89/// else is the same. Set it before `hello`.
90pub const Protocol = enum { smtp, lmtp };
91
92pub const Error = error{
93 WriteFailed,
94 ReadFailed,
95 EndOfStream,
96 LineTooLong,
97 InvalidReply,
98 ReplyTooLong,
99 /// The server answered with an unexpected code; see `last_reply`.
100 UnexpectedReply,
101 /// The transaction was opened with `BODY=BINARYMIME`, whose content
102 /// can only be sent with `bdat`. A server would answer DATA with 503
103 /// (RFC 3030 §3); this is the same refusal, made before the round trip.
104 BinaryRequiresChunking,
105 /// LMTP only: at least one recipient's verdict at the end of the
106 /// message was not a 2xx.
107 ///
108 /// It is a separate error from `UnexpectedReply` because `last_reply`
109 /// cannot answer "which one": the replies arrive one after another into
110 /// a single buffer, so reading the next overwrites the previous, and by
111 /// the time the last has been read the failing one's text is gone. Use
112 /// `DataWriter.endResults` to see each verdict as it arrives.
113 RecipientRejected,
114};
115
116pub const ArgumentError = error{
117 /// The transport is not encrypted and what was asked for needs it —
118 /// a mechanism that would put a reusable credential on the wire, or a
119 /// REQUIRETLS guarantee that would mean nothing without one.
120 ///
121 /// Upgrade the session with `starttls`, or for the credential case set
122 /// `allow_cleartext_auth` if the connection is protected by something
123 /// this library cannot see.
124 InsecureTransport,
125 /// An argument contained CR, LF or NUL and was not sent. See
126 /// `protocol.isSafeArgument` for why those three bytes and no others.
127 UnsafeArgument,
128 /// An ESMTP parameter value exceeded the length its RFC allows —
129 /// `ENVID` past 100 characters or `ORCPT` past 500, measured on the
130 /// xtext-encoded form that would go on the wire.
131 ArgumentTooLong,
132};
133
134/// Extensions advertised in the server's EHLO response.
135pub const Extensions = struct {
136 pipelining: bool = false,
137 eight_bit_mime: bool = false,
138 starttls: bool = false,
139 smtputf8: bool = false,
140 chunking: bool = false,
141 /// The server takes `BODY=BINARYMIME`
142 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). Always
143 /// accompanied by `chunking`, since binary content can only be sent
144 /// with BDAT — a server advertising one without the other is broken,
145 /// and sending binary to it anyway is what RFC 3030 forbids outright.
146 binary_mime: bool = false,
147 enhanced_status_codes: bool = false,
148 /// The server offers REQUIRETLS
149 /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)). Only
150 /// ever seen on a TLS-protected session, since that is the only kind it
151 /// may be advertised on.
152 requiretls: bool = false,
153 /// The server accepts the DSN parameters of
154 /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and
155 /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT.
156 dsn: bool = false,
157 /// The mechanism names from the server's `AUTH` keyword, space-separated
158 /// exactly as it sent them, for `sasl.Client.selectFromList`.
159 ///
160 /// A slice into the client's reply buffer, so it is valid until the next
161 /// reply is read — which for the usual `hello` then `authenticate`
162 /// sequence is long enough, since nothing is read in between.
163 auth: []const u8 = "",
164 /// Value of the SIZE extension, if advertised with a value.
165 max_size: ?u64 = null,
166
167 fn parse(reply: Reply) Extensions {
168 var ext: Extensions = .{};
169 var it = reply.lines();
170 _ = it.next(); // The first line is the server's greeting, not a keyword.
171 while (it.next()) |line| {
172 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
173 const kw = line[0..kw_end];
174 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else "";
175 if (ieql(kw, "PIPELINING")) {
176 ext.pipelining = true;
177 } else if (ieql(kw, "8BITMIME")) {
178 ext.eight_bit_mime = true;
179 } else if (ieql(kw, "STARTTLS")) {
180 ext.starttls = true;
181 } else if (ieql(kw, "SMTPUTF8")) {
182 ext.smtputf8 = true;
183 } else if (ieql(kw, "CHUNKING")) {
184 ext.chunking = true;
185 } else if (ieql(kw, "BINARYMIME")) {
186 ext.binary_mime = true;
187 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) {
188 ext.enhanced_status_codes = true;
189 } else if (ieql(kw, "DSN")) {
190 ext.dsn = true;
191 } else if (ieql(kw, "REQUIRETLS")) {
192 ext.requiretls = true;
193 } else if (ieql(kw, "AUTH")) {
194 ext.auth = arg;
195 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) {
196 // Some servers old enough to predate RFC 4954 advertise
197 // "AUTH=PLAIN LOGIN", with the first name jammed onto the
198 // keyword. Taking the line from the '=' recovers the whole
199 // list, which is why this points into the reply rather than
200 // rebuilding it somewhere that would not outlive the call.
201 ext.auth = line[kw_end - (kw.len - 5) ..];
202 } else if (ieql(kw, "SIZE")) {
203 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null;
204 }
205 }
206 return ext;
207 }
208
209 fn ieql(a: []const u8, b: []const u8) bool {
210 return std.ascii.eqlIgnoreCase(a, b);
211 }
212};
213
214/// `reply_buffer` must be large enough for the largest expected reply text
215/// (the EHLO response is usually the largest); 512 bytes is plenty in
216/// practice.
217pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client {
218 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer };
219}
220
221/// Reads the server's 220 greeting. Call once, right after connecting.
222pub fn greet(c: *Client) Error!Reply {
223 return c.expect(220);
224}
225
226/// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1))
227/// and returns the extensions the server advertised, falling back
228/// to plain HELO for servers that do not speak ESMTP.
229pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions {
230 if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument;
231 c.accepted_recipients = 0;
232 c.binary = false;
233 c.pipelining = false;
234 if (c.mode == .lmtp) {
235 // LHLO has EHLO's semantics, and there is no older greeting to fall
236 // back to: an LMTP server that will not take LHLO is not one.
237 try c.send("LHLO {s}", .{client_name});
238 const extensions = Extensions.parse(try c.expectClass(2));
239 c.pipelining = extensions.pipelining;
240 return extensions;
241 }
242 try c.send("EHLO {s}", .{client_name});
243 const reply = try c.readReply();
244 if (reply.isPositiveCompletion()) {
245 const extensions = Extensions.parse(reply);
246 c.pipelining = extensions.pipelining;
247 return extensions;
248 }
249 if (reply.code == 500 or reply.code == 502) {
250 // A server old enough to refuse EHLO has no extensions at all.
251 try c.send("HELO {s}", .{client_name});
252 _ = try c.expectClass(2);
253 return .{};
254 }
255 return error.UnexpectedReply;
256}
257
258/// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and
259/// reads the server's 220 go-ahead. On
260/// success, perform a TLS handshake over the underlying stream (see `Tls`),
261/// switch to the encrypted transport with `setTransport`, and then call
262/// `hello` again — the server discards everything it learned before the
263/// handshake, including the EHLO state.
264pub fn starttls(c: *Client) Error!void {
265 try c.send("STARTTLS", .{});
266 _ = try c.expect(220);
267}
268
269/// Replaces the session's transport, typically with a TLS reader/writer
270/// after `starttls`, and records whether the new one is encrypted. Pass
271/// `.encrypted` for a TLS transport; that is what lets `authenticate` use a
272/// mechanism that sends the password.
273pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer, security: Security) void {
274 c.reader = reader;
275 c.writer = writer;
276 c.security = security;
277}
278
279pub const AuthError = Error || ArgumentError || sasl.Client.Error || error{
280 /// The server rejected the credentials; see `last_reply`.
281 AuthenticationFailed,
282 /// The server's challenge was not valid base64, or was longer than the
283 /// buffer given to it.
284 InvalidChallenge,
285 /// `sasl_buffer` was empty or smaller than `sasl_buffer_min`. It is not
286 /// allocated here for the same reason `reply_buffer` is not: how much a
287 /// mechanism needs is the caller's to know.
288 SaslBufferTooSmall,
289 /// The server accepted the exchange but the mechanism had not finished
290 /// proving what it set out to prove.
291 ///
292 /// For a one-way mechanism this cannot happen. For SCRAM it means the
293 /// server reported success without ever producing its own signature —
294 /// which is what something in the middle, holding no verifier, would do.
295 /// The credentials are not compromised by it, but the peer is not the
296 /// server, and the session should be abandoned rather than used.
297 ServerNotAuthenticated,
298};
299
300/// The smallest `sasl_buffer` worth offering: enough plaintext for PLAIN,
301/// LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS, DIGEST-MD5 and SCRAM, none of which
302/// send more than a few hundred bytes.
303pub const sasl_buffer_min = 896;
304
305/// A `sasl_buffer` size that fits everything, including an OAuth token of a
306/// couple of kilobytes.
307///
308/// [RFC 4954 §4](https://datatracker.ietf.org/doc/html/rfc4954#section-4)
309/// says a client "MUST be able to handle the maximum encoded size of
310/// challenges and responses generated by their supported authentication
311/// mechanisms" and offers 12288 octets as a sufficient line length. Seven
312/// thousand here is a plaintext message of three thousand, which encodes to
313/// four — comfortably inside that.
314pub const sasl_buffer_suggested = 7168;
315
316/// Runs a SASL exchange with `mechanism`
317/// ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)).
318///
319/// The mechanisms themselves live in
320/// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.Plain`,
321/// `sasl.CramMd5`, `sasl.XOAuth2` and the rest, with SCRAM in zig-scram —
322/// because they are shared with every other protocol that speaks SASL and
323/// nothing about them is specific to SMTP. What is specific to SMTP is this
324/// function: `AUTH`, the 334 challenges, the `*` that cancels, and 235.
325///
326/// ```zig
327/// var plain: sasl.Plain = .init("alice", "secret");
328/// const extensions = try client.hello("my-host.example.com");
329/// const mechanism = sasl.Client.selectFromList(
330/// &.{ plain.client() },
331/// extensions.auth,
332/// client.security == .encrypted,
333/// ) orelse return error.NoSupportedMechanism;
334/// try client.authenticate(mechanism);
335/// ```
336///
337/// A mechanism that would put a reusable credential on an unencrypted
338/// transport is refused before anything is sent, as it was when the
339/// mechanisms lived here. When the mechanism itself fails mid-exchange the
340/// session is cancelled with `*` rather than abandoned, so the connection is
341/// left usable and the server's 501 is read rather than waiting in the
342/// stream for whatever comes next.
343pub fn authenticate(c: *Client, mechanism: sasl.Client) AuthError!void {
344 if (mechanism.cleartext()) try c.requireConfidentiality();
345 const scratch = try splitSaslBuffer(c.sasl_buffer);
346
347 var message: Io.Writer = .fixed(scratch.plain);
348
349 switch (try c.mechanismStep(mechanism.initial(&message))) {
350 .none => try c.send("AUTH {s}", .{mechanism.name()}),
351 .written => {
352 const encoded = std.base64.standard.Encoder.encode(scratch.coded, message.buffered());
353 // RFC 4954 §4: a zero-length initial response is a single `=`,
354 // because an empty argument would be indistinguishable from
355 // sending none at all.
356 try c.send("AUTH {s} {s}", .{ mechanism.name(), if (encoded.len == 0) "=" else encoded });
357 },
358 }
359
360 while (true) {
361 const reply = try c.readReply();
362 if (reply.code == 235) break;
363 if (reply.code != 334) return error.AuthenticationFailed;
364
365 // The challenge decodes into the coded half, which is free: whatever
366 // was encoded there has already gone out.
367 const challenge = decodeChallenge(scratch.coded, reply.text) orelse {
368 try c.cancelAuth();
369 return error.InvalidChallenge;
370 };
371
372 message = .fixed(scratch.plain);
373 try c.mechanismStep(mechanism.respond(challenge, &message));
374 // ...and the response encodes back over it, the challenge having
375 // been consumed by `respond`.
376 try c.send("{s}", .{std.base64.standard.Encoder.encode(scratch.coded, message.buffered())});
377 }
378
379 // The server says yes. Whether that means anything is the mechanism's to
380 // say: see `ServerNotAuthenticated`.
381 if (!mechanism.satisfied()) return error.ServerNotAuthenticated;
382}
383
384/// Cancels the exchange on a mechanism error and turns it into ours.
385///
386/// A mechanism that has failed will not produce another message, so the
387/// server is left waiting for a line that is never coming. RFC 4954 §4 gives
388/// `*` for exactly this, and answers it with 501, which is read here so the
389/// session is clean for whatever the caller does next.
390fn mechanismStep(c: *Client, result: anytype) AuthError!@typeInfo(@TypeOf(result)).error_union.payload {
391 return result catch |err| {
392 c.cancelAuth() catch {};
393 return err;
394 };
395}
396
397fn cancelAuth(c: *Client) Error!void {
398 try c.send("*", .{});
399 _ = c.readReply() catch {};
400}
401
402/// Decodes a challenge, which may legitimately be empty: RFC 4954 §4 spells a
403/// zero-length challenge `334 ` — the code, a space, and nothing after it.
404fn decodeChallenge(buffer: []u8, text: []const u8) ?[]const u8 {
405 if (text.len == 0) return buffer[0..0];
406 const len = std.base64.standard.Decoder.calcSizeForSlice(text) catch return null;
407 if (len > buffer.len) return null;
408 std.base64.standard.Decoder.decode(buffer[0..len], text) catch return null;
409 return buffer[0..len];
410}
411
412/// Refuses a mechanism that would transmit a reusable credential unprotected.
413fn requireConfidentiality(c: *Client) AuthError!void {
414 if (c.security == .encrypted or c.allow_cleartext_auth) return;
415 return error.InsecureTransport;
416}
417
418/// The two halves of `sasl_buffer`.
419///
420/// `coded` is four sevenths and `plain` three, which is base64's expansion
421/// exactly — so `coded` always holds the encoding of a full `plain`. They
422/// never hold anything at the same time: a challenge decodes into `coded`,
423/// is consumed by the mechanism writing into `plain`, and the answer encodes
424/// back over it.
425const SaslScratch = struct { coded: []u8, plain: []u8 };
426
427fn splitSaslBuffer(buffer: []u8) AuthError!SaslScratch {
428 if (buffer.len < sasl_buffer_min) return error.SaslBufferTooSmall;
429 const unit = buffer.len / 7;
430 return .{ .coded = buffer[0 .. unit * 4], .plain = buffer[unit * 4 ..][0 .. unit * 3] };
431}
432
433/// Parameters for the MAIL command. Send only what the server advertised:
434/// an unrecognized parameter is a 555 from a conforming server, so check
435/// `Extensions` first.
436pub const MailOptions = struct {
437 /// Requests the SMTPUTF8 extension
438 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)), which
439 /// lets the envelope and headers carry UTF-8. Needs `Extensions.smtputf8`.
440 smtputf8: bool = false,
441 /// `BODY=`: what kind of content the message carries.
442 /// `.eight_bit_mime` needs `Extensions.eight_bit_mime`;
443 /// `.binary_mime` needs `Extensions.binary_mime`, and commits the
444 /// transaction to BDAT — `data` will refuse to open a DATA phase for
445 /// it, as RFC 3030 §3 requires.
446 body: ?protocol.Body = null,
447 /// `REQUIRETLS`
448 /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)): do not
449 /// let this message travel onward in the clear — bounce it instead.
450 /// Needs `Extensions.requiretls`.
451 ///
452 /// Refused on a session this client does not believe is encrypted, with
453 /// `error.InsecureTransport`, because asking for a guarantee over a
454 /// channel that has none is asking for nothing. That is the part this
455 /// library can check. The rest of RFC 8689 §4.1's preconditions are the
456 /// caller's and cannot be checked from here: the server's certificate
457 /// must have been validated by a trust chain or DANE — so not with
458 /// `Tls.Options.ca = .insecure` — and the MX itself must have been
459 /// vouched for by DNSSEC or MTA-STS, which this library does not resolve.
460 require_tls: bool = false,
461 /// `AUTH=`
462 /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)):
463 /// who originally submitted this message, for a relay carrying it on
464 /// behalf of somebody else. Needs the server to advertise AUTH.
465 ///
466 /// `.unknown` sends `<>`, which is what a relay should send when it
467 /// cannot vouch for the submitter — RFC 4954 asks for that rather than
468 /// leaving the parameter off, because a server receiving no parameter
469 /// learns nothing while one receiving `<>` learns that the peer
470 /// considered the question. The mailbox form is xtext-encoded, so any
471 /// bytes are safe to pass, and is rejected with `error.ArgumentTooLong`
472 /// past the 500 characters RFC 4954 makes room for.
473 ///
474 /// Being believed is another matter: the receiving server disregards
475 /// this unless this client has authenticated to it.
476 auth: ?protocol.Submitter = null,
477 /// DSN `RET=`: how much of the message a failure report should carry
478 /// back. Needs `Extensions.dsn`.
479 ret: ?protocol.Ret = null,
480 /// DSN `ENVID=`: an identifier quoted back in any report about this
481 /// message. Sent xtext-encoded, so any bytes are safe to pass, and
482 /// rejected with `error.ArgumentTooLong` if the encoded form exceeds the
483 /// 100 characters RFC 3461 allows. Needs `Extensions.dsn`.
484 envid: ?[]const u8 = null,
485};
486
487/// Parameters for the RCPT command, which in this library means the DSN
488/// ones. Needs `Extensions.dsn`; see `MailOptions`.
489pub const RcptOptions = struct {
490 /// DSN `NOTIFY=`: when the sender wants to hear about this recipient.
491 /// Leave null to let the receiver apply its default.
492 notify: ?protocol.Notify = null,
493 /// DSN `ORCPT=`: the address the message was originally addressed to,
494 /// carried through aliasing so a report can name what the sender wrote.
495 /// The address is sent xtext-encoded; the `addr_type` is not, so it is
496 /// checked instead, and the whole parameter is capped at the 500
497 /// characters RFC 3461 allows.
498 orcpt: ?protocol.Orcpt = null,
499};
500
501/// Starts a mail transaction. An empty `from` sends the null reverse-path
502/// (`MAIL FROM:<>`), used for bounces.
503///
504/// Returns `error.UnsafeArgument` for an address that would break out of
505/// the command line; see `protocol.isSafeArgument`.
506pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void {
507 return c.mail(from, .{});
508}
509
510/// `mailFrom` with ESMTP parameters.
511pub fn mail(c: *Client, from: []const u8, options: MailOptions) (Error || ArgumentError)!void {
512 try c.checkMail(from, options);
513 try c.writeMail(from, options);
514 try c.writer.flush();
515 _ = try c.expectClass(2);
516 c.accepted_recipients = 0;
517 c.binary = options.body == .binary_mime;
518}
519
520/// Everything about a MAIL command that can be refused before it is
521/// written. Split out so that a pipelined group can be validated in full
522/// before any of it goes on the wire.
523fn checkMail(c: *Client, from: []const u8, options: MailOptions) ArgumentError!void {
524 if (!protocol.isSafeArgument(from)) return error.UnsafeArgument;
525 // A guarantee about a channel with no protection is not a guarantee.
526 if (options.require_tls and c.security != .encrypted) return error.InsecureTransport;
527 if (options.envid) |envid| {
528 if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len)
529 return error.ArgumentTooLong;
530 }
531 if (options.auth) |auth| switch (auth) {
532 .unknown => {},
533 .mailbox => |mailbox| {
534 if (mailbox.len == 0) return error.UnsafeArgument;
535 if (protocol.xtextEncodedLen(mailbox) > protocol.Submitter.max_len)
536 return error.ArgumentTooLong;
537 },
538 };
539}
540
541/// Writes MAIL without flushing or reading its reply.
542fn writeMail(c: *Client, from: []const u8, options: MailOptions) Error!void {
543 try c.writer.print("MAIL FROM:<{s}>", .{from});
544 if (options.require_tls) try c.writer.writeAll(" REQUIRETLS");
545 if (options.auth) |auth| try c.writer.print(" AUTH={f}", .{auth});
546 if (options.body) |body| try c.writer.print(" BODY={f}", .{body});
547 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8");
548 if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret});
549 if (options.envid) |envid| {
550 try c.writer.writeAll(" ENVID=");
551 try protocol.writeXtext(c.writer, envid);
552 }
553 try c.writer.writeAll(protocol.crlf);
554}
555
556/// Adds a recipient to the current transaction. Returns
557/// `error.UnsafeArgument` for an address that would break out of the
558/// command line; see `protocol.isSafeArgument`.
559pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void {
560 return c.rcpt(to, .{});
561}
562
563/// `rcptTo` with ESMTP parameters.
564pub fn rcpt(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!void {
565 const code = try c.rcptCode(to, options);
566 if (code / 100 != 2) return error.UnexpectedReply;
567}
568
569/// `rcpt`, but a refusal is the returned code rather than an error. The
570/// reply is in `last_reply` either way.
571fn rcptCode(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!u16 {
572 try c.checkRcpt(to, options);
573 try c.writeRcpt(to, options);
574 try c.writer.flush();
575 const reply = try c.readReply();
576 if (reply.isPositiveCompletion()) c.accepted_recipients += 1;
577 return reply.code;
578}
579
580/// Everything about a RCPT command that can be refused before it is
581/// written; see `checkMail`.
582fn checkRcpt(c: *Client, to: []const u8, options: RcptOptions) ArgumentError!void {
583 _ = c;
584 if (!protocol.isSafeArgument(to)) return error.UnsafeArgument;
585 if (options.orcpt) |orcpt| {
586 if (orcpt.addr_type.len == 0 or !protocol.isSafeArgument(orcpt.addr_type) or
587 std.mem.findScalar(u8, orcpt.addr_type, ';') != null)
588 return error.UnsafeArgument;
589 if (orcpt.addr_type.len + 1 + protocol.xtextEncodedLen(orcpt.address) > protocol.Orcpt.max_len)
590 return error.ArgumentTooLong;
591 }
592}
593
594/// Writes RCPT without flushing or reading its reply.
595fn writeRcpt(c: *Client, to: []const u8, options: RcptOptions) Error!void {
596 try c.writer.print("RCPT TO:<{s}>", .{to});
597 if (options.notify) |notify| try c.writer.print(" NOTIFY={f}", .{notify});
598 if (options.orcpt) |orcpt| try c.writer.print(" ORCPT={f}", .{orcpt});
599 try c.writer.writeAll(protocol.crlf);
600}
601
602pub const EnvelopeOptions = struct {
603 /// Parameters for the MAIL command.
604 mail: MailOptions = .{},
605 /// Parameters applied to every RCPT command. Per-recipient parameters
606 /// need `rcpt` called individually.
607 rcpt: RcptOptions = .{},
608};
609
610/// Sends MAIL FROM and one RCPT TO per recipient, then reads every reply,
611/// and returns how many recipients the server accepted.
612///
613/// When the server advertised PIPELINING the commands go out as a single
614/// group and their replies are read together, which turns an envelope of
615/// *n* recipients from *n*+1 round trips into one. Otherwise each command
616/// waits for its own reply, and the result is the same either way.
617///
618/// DATA is deliberately not part of the group, though RFC 2920 §3.1 allows
619/// it as the last command of one. Once a server has answered DATA with 354
620/// the transaction is committed, and a caller that wanted all-or-nothing
621/// delivery has no way back: the only ways out of the data phase are to
622/// send the message or to send an empty one to whichever recipients *were*
623/// accepted. Stopping the group before DATA keeps that decision with the
624/// caller, and costs one round trip out of the *n*+1 saved.
625///
626/// `codes`, when given, must have room for `recipients.len` entries and
627/// receives each RCPT reply code in order. Codes rather than replies
628/// because the replies share one buffer: by the time the group has been
629/// read, only the last one's text still exists.
630///
631/// A refused MAIL FROM is `error.UnexpectedReply`, with the reply in
632/// `last_reply` and the rest of the group drained. Refused *recipients*
633/// are not an error — with several of them the caller is the one who can
634/// say whether what remains is worth sending — so compare the returned
635/// count against `recipients.len`.
636pub fn envelope(
637 c: *Client,
638 from: []const u8,
639 recipients: []const []const u8,
640 codes: ?[]u16,
641 options: EnvelopeOptions,
642) (Error || ArgumentError)!usize {
643 if (codes) |slice| std.debug.assert(slice.len >= recipients.len);
644 if (!c.pipelining) {
645 try c.mail(from, options.mail);
646 var accepted: usize = 0;
647 for (recipients, 0..) |recipient, index| {
648 const code = try c.rcptCode(recipient, options.rcpt);
649 if (codes) |slice| slice[index] = code;
650 if (code / 100 == 2) accepted += 1;
651 }
652 return accepted;
653 }
654
655 // Everything is validated before anything is written: a group that
656 // turned out to be unsendable halfway through would leave the session
657 // holding a partial command.
658 try c.checkMail(from, options.mail);
659 for (recipients) |recipient| try c.checkRcpt(recipient, options.rcpt);
660
661 try c.writeMail(from, options.mail);
662 for (recipients) |recipient| try c.writeRcpt(recipient, options.rcpt);
663 try c.writer.flush();
664
665 // RFC 2920 §3.1: every status in the group must be checked, and all of
666 // them must be read whatever the first one said, or the replies still
667 // queued would be mistaken for the answers to whatever comes next.
668 const mail_reply = try c.readReply();
669 const mail_ok = mail_reply.isPositiveCompletion();
670 if (mail_ok) {
671 c.accepted_recipients = 0;
672 c.binary = options.mail.body == .binary_mime;
673 }
674
675 var accepted: usize = 0;
676 for (0..recipients.len) |index| {
677 if (!mail_ok) {
678 // The MAIL reply is the one worth keeping, so the rest of the
679 // group is drained without disturbing it.
680 try c.discardReply();
681 if (codes) |slice| slice[index] = 0;
682 continue;
683 }
684 const reply = try c.readReply();
685 if (codes) |slice| slice[index] = reply.code;
686 if (reply.isPositiveCompletion()) {
687 accepted += 1;
688 c.accepted_recipients += 1;
689 }
690 }
691 if (!mail_ok) return error.UnexpectedReply;
692 return accepted;
693}
694
695/// Sends the message content for the current transaction (DATA). Line
696/// endings in `data` are normalized to CRLF and leading dots are stuffed.
697pub fn sendMessage(c: *Client, message_data: []const u8) Error!void {
698 var data_writer = try c.data();
699 try data_writer.interface.writeAll(message_data);
700 try data_writer.end();
701}
702
703/// Streams the message content for the current transaction from `message`
704/// until end of stream. Line endings are normalized to CRLF and leading
705/// dots stuffed; nothing is buffered beyond the transport writer, so lines
706/// and messages of any length work.
707pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void {
708 var data_writer = try c.data();
709 while (true) {
710 const chunk = message.peekGreedy(1) catch |err| switch (err) {
711 error.EndOfStream => break,
712 error.ReadFailed => return error.ReadFailed,
713 };
714 try data_writer.interface.writeAll(chunk);
715 message.toss(chunk.len);
716 }
717 try data_writer.end();
718}
719
720/// Starts the DATA phase for streaming a message body: write the content
721/// through the returned writer's `interface`, then call `end`. Line endings
722/// are normalized to CRLF and leading dots stuffed as the data flows.
723pub fn data(c: *Client) Error!DataWriter {
724 if (c.binary) return error.BinaryRequiresChunking;
725 try c.send("DATA", .{});
726 _ = try c.expect(354);
727 return .{
728 .client = c,
729 .interface = .{
730 .buffer = &.{},
731 .vtable = &.{ .drain = DataWriter.drain },
732 },
733 };
734}
735
736/// Streaming writer for a message body; obtained from `data`. The dot
737/// stuffing and CRLF normalization state lives here, so chunks may split
738/// lines (and even CRLF pairs) at any byte boundary.
739pub const DataWriter = struct {
740 client: *Client,
741 interface: Io.Writer,
742 at_line_start: bool = true,
743 /// A '\r' was seen but not yet emitted; whether it is a line ending
744 /// depends on the next byte.
745 pending_cr: bool = false,
746
747 /// Terminates the message (adding a final CRLF if the content did not
748 /// end with one, then ".\r\n") and reads the server's verdict.
749 ///
750 /// In LMTP that is one verdict per accepted recipient rather than one
751 /// for the message. All of them are read — leaving any unread would
752 /// desynchronize the session — and a non-2xx among them becomes
753 /// `error.RecipientRejected`, with that first refusal left in
754 /// `last_reply`: once one has been read, the rest of the group is
755 /// drained without disturbing it. Which *recipient* it belonged to is
756 /// only available from `endResults`, which is the whole reason for
757 /// speaking LMTP and the way to see every verdict.
758 pub fn end(dw: *DataWriter) Error!void {
759 var verdicts = try dw.endResults();
760 const per_recipient = verdicts.remaining > 1;
761 while (try verdicts.next()) |reply| {
762 if (reply.isPositiveCompletion()) continue;
763 while (verdicts.remaining > 0) : (verdicts.remaining -= 1)
764 try dw.client.discardReply();
765 // With one reply there was never any ambiguity to begin with.
766 return if (per_recipient) error.RecipientRejected else error.UnexpectedReply;
767 }
768 }
769
770 /// Terminates the message and returns the verdicts to read: one in
771 /// SMTP, one per accepted recipient in LMTP, in the order the RCPT
772 /// commands were issued. Every one of them must be read before the
773 /// session is used again.
774 pub fn endResults(dw: *DataWriter) Error!Results {
775 try dw.interface.flush();
776 const c = dw.client;
777 if (dw.pending_cr) {
778 // A trailing bare CR counts as a line ending, matching
779 // `protocol.writeStuffed`.
780 dw.pending_cr = false;
781 dw.at_line_start = true;
782 try c.writer.writeAll(protocol.crlf);
783 }
784 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf);
785 try c.writer.writeAll("." ++ protocol.crlf);
786 try c.writer.flush();
787 return c.results();
788 }
789
790 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize {
791 const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w));
792 try dw.writeChunk(w.buffered());
793 w.end = 0;
794 if (chunks.len == 0) return 0;
795 var n: usize = 0;
796 for (chunks[0 .. chunks.len - 1]) |bytes| {
797 try dw.writeChunk(bytes);
798 n += bytes.len;
799 }
800 const pattern = chunks[chunks.len - 1];
801 for (0..splat) |_| {
802 try dw.writeChunk(pattern);
803 n += pattern.len;
804 }
805 return n;
806 }
807
808 test end {
809 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
810 var out_buf: [64]u8 = undefined;
811 var writer: Io.Writer = .fixed(&out_buf);
812 var reply_buf: [64]u8 = undefined;
813 var client: Client = .init(&reader, &writer, &reply_buf);
814
815 var data_writer = try client.data();
816 try data_writer.interface.writeAll("no trailing newline");
817 try data_writer.end(); // adds the final CRLF, sends ".", reads 250
818 try std.testing.expectEqualStrings(
819 "DATA\r\nno trailing newline\r\n.\r\n",
820 writer.buffered(),
821 );
822 }
823
824 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void {
825 const out = dw.client.writer;
826 var rest = bytes;
827 while (rest.len > 0) {
828 if (dw.pending_cr) {
829 dw.pending_cr = false;
830 if (rest[0] == '\n') {
831 try out.writeAll(protocol.crlf);
832 dw.at_line_start = true;
833 rest = rest[1..];
834 continue;
835 }
836 // A bare CR mid-line passes through untouched.
837 try out.writeByte('\r');
838 dw.at_line_start = false;
839 }
840 if (dw.at_line_start and rest[0] == '.') {
841 try out.writeAll("..");
842 dw.at_line_start = false;
843 rest = rest[1..];
844 continue;
845 }
846 const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse {
847 try out.writeAll(rest);
848 dw.at_line_start = false;
849 break;
850 };
851 if (special > 0) {
852 try out.writeAll(rest[0..special]);
853 dw.at_line_start = false;
854 }
855 switch (rest[special]) {
856 '\r' => dw.pending_cr = true,
857 '\n' => {
858 try out.writeAll(protocol.crlf);
859 dw.at_line_start = true;
860 },
861 else => unreachable,
862 }
863 rest = rest[special + 1 ..];
864 }
865 }
866};
867
868/// The verdicts a server sends at the end of a message: one in SMTP, one
869/// per accepted recipient in LMTP. Each `next` overwrites the client's
870/// reply buffer, so a reply must be used before the following call.
871pub const Results = struct {
872 client: *Client,
873 remaining: usize,
874 /// The index into the recipients accepted since the last MAIL that the
875 /// next reply belongs to. Meaningful in LMTP, where replies come back
876 /// in the order the RCPT commands were issued.
877 index: usize = 0,
878
879 pub fn next(r: *Results) Error!?Reply {
880 if (r.remaining == 0) return null;
881 r.remaining -= 1;
882 r.index += 1;
883 return try r.client.readReply();
884 }
885};
886
887/// The verdicts still to be read after a message has been terminated. Use
888/// `DataWriter.endResults`, which sends the terminator first; this is the
889/// reading half on its own, for a caller that framed the message itself.
890pub fn results(c: *Client) Results {
891 return .{
892 .client = c,
893 .remaining = switch (c.mode) {
894 .smtp => 1,
895 .lmtp => c.accepted_recipients,
896 },
897 };
898}
899
900/// Like `mailFrom`, but requests the SMTPUTF8 extension
901/// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the
902/// envelope addresses and message headers may contain UTF-8. Use only when
903/// `Extensions.smtputf8` was advertised.
904pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void {
905 return c.mail(from, .{ .smtputf8 = true });
906}
907
908/// Sends one BDAT chunk (the CHUNKING extension,
909/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the
910/// server's reply. Use only when `Extensions.chunking` was advertised. The
911/// chunk is transmitted verbatim — no dot-stuffing and no line-ending
912/// normalization — so message content must already use CRLF line endings.
913/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator.
914pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void {
915 if (last) {
916 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len});
917 } else {
918 try c.writer.print("BDAT {d}\r\n", .{chunk.len});
919 }
920 try c.writer.writeAll(chunk);
921 try c.writer.flush();
922 if (!last) {
923 _ = try c.expectClass(2);
924 return;
925 }
926 // RFC 2033 gives the LAST chunk the same per-recipient answer that the
927 // final dot of DATA gets, so it is read the same way.
928 var chunk_results = c.results();
929 const per_recipient = chunk_results.remaining > 1;
930 while (try chunk_results.next()) |reply| {
931 if (reply.isPositiveCompletion()) continue;
932 while (chunk_results.remaining > 0) : (chunk_results.remaining -= 1)
933 try c.discardReply();
934 return if (per_recipient) error.RecipientRejected else error.UnexpectedReply;
935 }
936}
937
938/// Sends the message content for the current transaction as a single BDAT
939/// chunk. See `bdat` for the transmission caveats.
940pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void {
941 try c.bdat(message_data, true);
942}
943
944/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient,
945/// then DATA. Call after `greet` and `hello`.
946pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) (Error || ArgumentError)!void {
947 const accepted = try c.envelope(from, recipients, null, .{});
948 if (accepted != recipients.len) {
949 // All or nothing, so nothing: the envelope is abandoned before DATA
950 // rather than delivering to the subset that was accepted. A caller
951 // who wants the subset calls `envelope` and decides for itself.
952 c.rset() catch {};
953 return error.UnexpectedReply;
954 }
955 try c.sendMessage(message_data);
956}
957
958/// Aborts the current mail transaction.
959pub fn rset(c: *Client) Error!void {
960 try c.send("RSET", .{});
961 _ = try c.expectClass(2);
962 c.accepted_recipients = 0;
963 c.binary = false;
964}
965
966pub fn noop(c: *Client) Error!void {
967 try c.send("NOOP", .{});
968 _ = try c.expectClass(2);
969}
970
971/// Ends the session. The connection should be closed afterwards.
972pub fn quit(c: *Client) Error!void {
973 try c.send("QUIT", .{});
974 _ = try c.expect(221);
975}
976
977fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void {
978 try c.writer.print(fmt ++ protocol.crlf, args);
979 try c.writer.flush();
980}
981
982fn readReply(c: *Client) Error!Reply {
983 const reply = try Reply.read(c.reader, c.reply_buffer);
984 c.last_reply = reply;
985 return reply;
986}
987
988/// Reads one reply and throws it away, without touching `reply_buffer`.
989///
990/// That is the point of it: the replies to a pipelined group all arrive
991/// before any of them can be acted on, and each one read into
992/// `reply_buffer` overwrites the last. Draining the rest of a group this
993/// way leaves the failing reply that was already read intact in
994/// `last_reply`, so an error can still say what went wrong.
995fn discardReply(c: *Client) Error!void {
996 while (true) {
997 const line = protocol.readLine(c.reader) catch |err| switch (err) {
998 error.LineTooLong => return error.ReplyTooLong,
999 error.ReadFailed, error.EndOfStream => |e| return e,
1000 };
1001 if (line.len < 4) return if (line.len == 3) {} else error.InvalidReply;
1002 // A '-' in the fourth column continues the reply; a space ends it.
1003 switch (line[3]) {
1004 '-' => continue,
1005 ' ' => return,
1006 else => return error.InvalidReply,
1007 }
1008 }
1009}
1010
1011fn expect(c: *Client, code: u16) Error!Reply {
1012 const reply = try c.readReply();
1013 if (reply.code != code) return error.UnexpectedReply;
1014 return reply;
1015}
1016
1017fn expectClass(c: *Client, class: u16) Error!Reply {
1018 const reply = try c.readReply();
1019 if (reply.code / 100 != class) return error.UnexpectedReply;
1020 return reply;
1021}
1022
1023test sendMail {
1024 const responses = "220 mx.example.com ESMTP\r\n" ++
1025 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++
1026 "250 2.1.0 Ok\r\n" ++
1027 "250 2.1.5 Ok\r\n" ++
1028 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
1029 "250 2.0.0 Ok\r\n" ++
1030 "221 2.0.0 Bye\r\n";
1031 var reader: Io.Reader = .fixed(responses);
1032 var out_buf: [1024]u8 = undefined;
1033 var writer: Io.Writer = .fixed(&out_buf);
1034 var reply_buf: [512]u8 = undefined;
1035 var client: Client = .init(&reader, &writer, &reply_buf);
1036
1037 _ = try client.greet();
1038 const ext = try client.hello("client.example.org");
1039 try std.testing.expect(ext.pipelining);
1040 try std.testing.expect(ext.eight_bit_mime);
1041 try std.testing.expect(!ext.starttls);
1042 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size);
1043
1044 try client.sendMail(
1045 "alice@example.com",
1046 &.{"bob@example.net"},
1047 "Subject: hi\r\n\r\n.leading dot\r\n",
1048 );
1049 try client.quit();
1050
1051 try std.testing.expectEqualStrings(
1052 "EHLO client.example.org\r\n" ++
1053 "MAIL FROM:<alice@example.com>\r\n" ++
1054 "RCPT TO:<bob@example.net>\r\n" ++
1055 "DATA\r\n" ++
1056 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++
1057 "QUIT\r\n",
1058 writer.buffered(),
1059 );
1060}
1061
1062test "HELO fallback for non-ESMTP servers" {
1063 const responses = "220 old.example.com\r\n" ++
1064 "502 command not implemented\r\n" ++
1065 "250 old.example.com\r\n";
1066 var reader: Io.Reader = .fixed(responses);
1067 var out_buf: [256]u8 = undefined;
1068 var writer: Io.Writer = .fixed(&out_buf);
1069 var reply_buf: [256]u8 = undefined;
1070 var client: Client = .init(&reader, &writer, &reply_buf);
1071
1072 _ = try client.greet();
1073 const ext = try client.hello("client.example.org");
1074 try std.testing.expectEqual(Extensions{}, ext);
1075 try std.testing.expectEqualStrings(
1076 "EHLO client.example.org\r\nHELO client.example.org\r\n",
1077 writer.buffered(),
1078 );
1079}
1080
1081test "rejected recipient surfaces the reply" {
1082 const responses = "550 5.1.1 No such user\r\n";
1083 var reader: Io.Reader = .fixed(responses);
1084 var out_buf: [256]u8 = undefined;
1085 var writer: Io.Writer = .fixed(&out_buf);
1086 var reply_buf: [256]u8 = undefined;
1087 var client: Client = .init(&reader, &writer, &reply_buf);
1088
1089 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com"));
1090 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
1091 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text);
1092}
1093
1094test starttls {
1095 const plain_responses = "220 mx.example.com ESMTP\r\n" ++
1096 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++
1097 "220 2.0.0 Ready to start TLS\r\n";
1098 var reader: Io.Reader = .fixed(plain_responses);
1099 var out_buf: [256]u8 = undefined;
1100 var writer: Io.Writer = .fixed(&out_buf);
1101 var reply_buf: [256]u8 = undefined;
1102 var client: Client = .init(&reader, &writer, &reply_buf);
1103
1104 _ = try client.greet();
1105 const ext = try client.hello("client.example.org");
1106 try std.testing.expect(ext.starttls);
1107 try client.starttls();
1108
1109 // Simulate the post-handshake encrypted transport with fresh buffers;
1110 // the session must re-EHLO on it.
1111 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n";
1112 var tls_reader: Io.Reader = .fixed(tls_responses);
1113 var tls_out_buf: [256]u8 = undefined;
1114 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
1115 client.setTransport(&tls_reader, &tls_writer, .encrypted);
1116
1117 const tls_ext = try client.hello("client.example.org");
1118 try std.testing.expect(!tls_ext.starttls);
1119 try std.testing.expect(tls_ext.eight_bit_mime);
1120 try std.testing.expectEqualStrings(
1121 "EHLO client.example.org\r\nSTARTTLS\r\n",
1122 writer.buffered(),
1123 );
1124 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered());
1125}
1126
1127test "BODY=BINARYMIME commits the transaction to BDAT" {
1128 const responses = "250-mx.example.com\r\n250-CHUNKING\r\n250 BINARYMIME\r\n" ++
1129 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.0.0 Ok\r\n";
1130 var reader: Io.Reader = .fixed(responses);
1131 var out_buf: [512]u8 = undefined;
1132 var writer: Io.Writer = .fixed(&out_buf);
1133 var reply_buf: [256]u8 = undefined;
1134 var client: Client = .init(&reader, &writer, &reply_buf);
1135
1136 const ext = try client.hello("client.example.org");
1137 try std.testing.expect(ext.binary_mime and ext.chunking);
1138
1139 try client.mail("alice@example.com", .{ .body = .binary_mime });
1140 try client.rcptTo("bob@example.net");
1141 // The server would answer DATA with 503; the client will not get that
1142 // far, because the round trip has nothing to discover.
1143 try std.testing.expectError(error.BinaryRequiresChunking, client.data());
1144
1145 // BDAT is the way, and it sends the octets untouched: a bare CR, a NUL
1146 // and a lone dot all cross unchanged.
1147 try client.bdat("\x00\r.\r\n", true);
1148 try std.testing.expect(std.mem.endsWith(
1149 u8,
1150 writer.buffered(),
1151 "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++
1152 "RCPT TO:<bob@example.net>\r\n" ++
1153 "BDAT 5 LAST\r\n\x00\r.\r\n",
1154 ));
1155}
1156
1157test "the binary commitment is lifted by RSET and by the next transaction" {
1158 const responses = "250 2.1.0 Ok\r\n250 2.0.0 Ok\r\n250 2.1.0 Ok\r\n354 go\r\n250 ok\r\n";
1159 var reader: Io.Reader = .fixed(responses);
1160 var out_buf: [512]u8 = undefined;
1161 var writer: Io.Writer = .fixed(&out_buf);
1162 var reply_buf: [256]u8 = undefined;
1163 var client: Client = .init(&reader, &writer, &reply_buf);
1164
1165 try client.mail("alice@example.com", .{ .body = .binary_mime });
1166 try std.testing.expect(client.binary);
1167 try client.rset();
1168 try std.testing.expect(!client.binary);
1169 // And a plain MAIL leaves DATA available again.
1170 try client.mail("alice@example.com", .{});
1171 try client.sendMessage("hi\r\n");
1172}
1173
1174test "a pipelined envelope writes the whole group before reading a reply" {
1175 // The MAIL is refused. A client working one command at a time would
1176 // stop there; a pipelined one has already sent everything, and that is
1177 // what makes the difference observable from the wire alone.
1178 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++
1179 "550 5.1.8 Bad sender\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n";
1180 var reader: Io.Reader = .fixed(responses);
1181 var out_buf: [512]u8 = undefined;
1182 var writer: Io.Writer = .fixed(&out_buf);
1183 var reply_buf: [256]u8 = undefined;
1184 var client: Client = .init(&reader, &writer, &reply_buf);
1185
1186 _ = try client.hello("client.example.org");
1187 try std.testing.expect(client.pipelining);
1188
1189 const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" };
1190 try std.testing.expectError(
1191 error.UnexpectedReply,
1192 client.envelope("alice@example.com", recipients, null, .{}),
1193 );
1194 try std.testing.expectEqualStrings(
1195 "EHLO client.example.org\r\n" ++
1196 "MAIL FROM:<alice@example.com>\r\n" ++
1197 "RCPT TO:<bob@example.net>\r\n" ++
1198 "RCPT TO:<carol@example.net>\r\n",
1199 writer.buffered(),
1200 );
1201 // The MAIL reply is the one kept, even though two more were read after
1202 // it, and the group was drained so the stream is where it should be.
1203 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
1204 try std.testing.expectEqualStrings("5.1.8 Bad sender", client.last_reply.?.text);
1205 try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen());
1206}
1207
1208test "without PIPELINING the commands wait for each other" {
1209 // Same refusal, no PIPELINING advertised: the RCPTs are never sent.
1210 const responses = "250-mx.example.com\r\n250 8BITMIME\r\n550 5.1.8 Bad sender\r\n";
1211 var reader: Io.Reader = .fixed(responses);
1212 var out_buf: [512]u8 = undefined;
1213 var writer: Io.Writer = .fixed(&out_buf);
1214 var reply_buf: [256]u8 = undefined;
1215 var client: Client = .init(&reader, &writer, &reply_buf);
1216
1217 _ = try client.hello("client.example.org");
1218 try std.testing.expect(!client.pipelining);
1219
1220 const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" };
1221 try std.testing.expectError(
1222 error.UnexpectedReply,
1223 client.envelope("alice@example.com", recipients, null, .{}),
1224 );
1225 try std.testing.expectEqualStrings(
1226 "EHLO client.example.org\r\nMAIL FROM:<alice@example.com>\r\n",
1227 writer.buffered(),
1228 );
1229}
1230
1231test "envelope reports which recipients were refused" {
1232 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++
1233 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n250 2.1.5 Ok\r\n";
1234 var reader: Io.Reader = .fixed(responses);
1235 var out_buf: [512]u8 = undefined;
1236 var writer: Io.Writer = .fixed(&out_buf);
1237 var reply_buf: [256]u8 = undefined;
1238 var client: Client = .init(&reader, &writer, &reply_buf);
1239
1240 _ = try client.hello("client.example.org");
1241 const recipients: []const []const u8 = &.{
1242 "bob@example.net",
1243 "nobody@example.net",
1244 "carol@example.net",
1245 };
1246 var codes: [3]u16 = undefined;
1247 const accepted = try client.envelope("alice@example.com", recipients, &codes, .{});
1248
1249 try std.testing.expectEqual(@as(usize, 2), accepted);
1250 try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes);
1251 // A refused recipient is not an error here, so the transaction is still
1252 // open and the client knows how many it may deliver to.
1253 try std.testing.expectEqual(@as(usize, 2), client.accepted_recipients);
1254}
1255
1256test "the same envelope works the same way without pipelining" {
1257 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n250 2.1.5 Ok\r\n";
1258 var reader: Io.Reader = .fixed(responses);
1259 var out_buf: [512]u8 = undefined;
1260 var writer: Io.Writer = .fixed(&out_buf);
1261 var reply_buf: [256]u8 = undefined;
1262 var client: Client = .init(&reader, &writer, &reply_buf);
1263
1264 const recipients: []const []const u8 = &.{
1265 "bob@example.net",
1266 "nobody@example.net",
1267 "carol@example.net",
1268 };
1269 var codes: [3]u16 = undefined;
1270 const accepted = try client.envelope("alice@example.com", recipients, &codes, .{});
1271 try std.testing.expectEqual(@as(usize, 2), accepted);
1272 try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes);
1273}
1274
1275test "sendMail abandons the transaction rather than deliver to some" {
1276 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++
1277 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n" ++
1278 "250 2.0.0 Ok\r\n"; // the RSET
1279 var reader: Io.Reader = .fixed(responses);
1280 var out_buf: [512]u8 = undefined;
1281 var writer: Io.Writer = .fixed(&out_buf);
1282 var reply_buf: [256]u8 = undefined;
1283 var client: Client = .init(&reader, &writer, &reply_buf);
1284
1285 _ = try client.hello("client.example.org");
1286 const recipients: []const []const u8 = &.{ "bob@example.net", "nobody@example.net" };
1287 try std.testing.expectError(
1288 error.UnexpectedReply,
1289 client.sendMail("alice@example.com", recipients, "hi\r\n"),
1290 );
1291 // DATA was never sent, so nothing reached the recipient that was
1292 // accepted, and the session was left clean for the next transaction.
1293 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "DATA") == null);
1294 try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "RSET\r\n"));
1295}
1296
1297test "LMTP greets with LHLO and reads one verdict per recipient" {
1298 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ // LHLO
1299 "250 2.1.0 Ok\r\n" ++ // MAIL
1300 "250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ // two RCPTs
1301 "354 End data\r\n" ++
1302 "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; // one per recipient
1303 var reader: Io.Reader = .fixed(responses);
1304 var out_buf: [512]u8 = undefined;
1305 var writer: Io.Writer = .fixed(&out_buf);
1306 var reply_buf: [256]u8 = undefined;
1307 var client: Client = .init(&reader, &writer, &reply_buf);
1308 client.mode = .lmtp;
1309
1310 _ = try client.hello("client.example.org");
1311 try client.mailFrom("alice@example.com");
1312 try client.rcptTo("good@example.net");
1313 try client.rcptTo("bad@example.net");
1314
1315 var data_writer = try client.data();
1316 try data_writer.interface.writeAll("hi\r\n");
1317 var verdicts = try data_writer.endResults();
1318
1319 const first = (try verdicts.next()).?;
1320 try std.testing.expectEqual(@as(u16, 250), first.code);
1321 try std.testing.expectEqual(@as(usize, 1), verdicts.index);
1322 const second = (try verdicts.next()).?;
1323 try std.testing.expectEqual(@as(u16, 550), second.code);
1324 try std.testing.expectEqualStrings("5.2.1 Mailbox disabled", second.text);
1325 try std.testing.expectEqual(@as(?Reply, null), try verdicts.next());
1326
1327 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "LHLO client.example.org\r\n"));
1328}
1329
1330test "end reports an LMTP rejection distinctly from an SMTP one" {
1331 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n354 End data\r\n" ++
1332 "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n";
1333 var reader: Io.Reader = .fixed(responses);
1334 var out_buf: [512]u8 = undefined;
1335 var writer: Io.Writer = .fixed(&out_buf);
1336 var reply_buf: [256]u8 = undefined;
1337 var client: Client = .init(&reader, &writer, &reply_buf);
1338 client.mode = .lmtp;
1339
1340 try client.mailFrom("alice@example.com");
1341 try client.rcptTo("good@example.net");
1342 try client.rcptTo("bad@example.net");
1343 // Both verdicts are read even though the first already decided the
1344 // outcome, or the next command would be answered by a stale reply.
1345 try std.testing.expectError(error.RecipientRejected, client.sendMessage("hi\r\n"));
1346
1347 // The single-reply case keeps `error.UnexpectedReply`, where
1348 // `last_reply` can actually say what happened.
1349 var smtp_reader: Io.Reader = .fixed("354 End data\r\n550 5.7.1 Rejected\r\n");
1350 var smtp_out: [256]u8 = undefined;
1351 var smtp_writer: Io.Writer = .fixed(&smtp_out);
1352 var smtp_reply_buf: [256]u8 = undefined;
1353 var smtp: Client = .init(&smtp_reader, &smtp_writer, &smtp_reply_buf);
1354 try std.testing.expectError(error.UnexpectedReply, smtp.sendMessage("hi\r\n"));
1355 try std.testing.expectEqualStrings("5.7.1 Rejected", smtp.last_reply.?.text);
1356}
1357
1358test "the recipient count resets with each new transaction" {
1359 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n" ++ // MAIL, RCPT
1360 "250 2.0.0 Ok\r\n" ++ // RSET
1361 "250 2.1.0 Ok\r\n"; // MAIL again
1362 var reader: Io.Reader = .fixed(responses);
1363 var out_buf: [512]u8 = undefined;
1364 var writer: Io.Writer = .fixed(&out_buf);
1365 var reply_buf: [256]u8 = undefined;
1366 var client: Client = .init(&reader, &writer, &reply_buf);
1367 client.mode = .lmtp;
1368
1369 try client.mailFrom("alice@example.com");
1370 try client.rcptTo("bob@example.net");
1371 try std.testing.expectEqual(@as(usize, 1), client.results().remaining);
1372 try client.rset();
1373 try std.testing.expectEqual(@as(usize, 0), client.results().remaining);
1374 try client.mailFrom("alice@example.com");
1375 try std.testing.expectEqual(@as(usize, 0), client.results().remaining);
1376}
1377
1378test "mail and rcpt carry the DSN parameters" {
1379 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n";
1380 var reader: Io.Reader = .fixed(responses);
1381 var out_buf: [256]u8 = undefined;
1382 var writer: Io.Writer = .fixed(&out_buf);
1383 var reply_buf: [64]u8 = undefined;
1384 var client: Client = .init(&reader, &writer, &reply_buf);
1385
1386 try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" });
1387 try client.rcpt("bob@example.net", .{
1388 .notify = .{ .on = .{ .failure = true, .delay = true } },
1389 .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" },
1390 });
1391 try std.testing.expectEqualStrings(
1392 "MAIL FROM:<me@example.com> RET=HDRS ENVID=batch+207\r\n" ++
1393 "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n",
1394 writer.buffered(),
1395 );
1396}
1397
1398test "REQUIRETLS is refused on a session with nothing to guarantee" {
1399 var reader: Io.Reader = .fixed("");
1400 var out_buf: [256]u8 = undefined;
1401 var writer: Io.Writer = .fixed(&out_buf);
1402 var reply_buf: [64]u8 = undefined;
1403 var client: Client = .init(&reader, &writer, &reply_buf);
1404
1405 // Asking for a guarantee over a channel that has none is asking for
1406 // nothing, so it is refused here rather than sent and relied upon.
1407 try std.testing.expectError(
1408 error.InsecureTransport,
1409 client.mail("a@example.com", .{ .require_tls = true }),
1410 );
1411 try std.testing.expectEqualStrings("", writer.buffered());
1412}
1413
1414test "REQUIRETLS goes out once the session is encrypted" {
1415 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250 REQUIRETLS\r\n250 2.1.0 Ok\r\n");
1416 var out_buf: [512]u8 = undefined;
1417 var writer: Io.Writer = .fixed(&out_buf);
1418 var reply_buf: [256]u8 = undefined;
1419 var client: Client = .init(&reader, &writer, &reply_buf);
1420 client.security = .encrypted;
1421
1422 const extensions = try client.hello("client.example.org");
1423 try std.testing.expect(extensions.requiretls);
1424
1425 try client.mail("a@example.com", .{ .require_tls = true });
1426 try std.testing.expect(std.mem.endsWith(
1427 u8,
1428 writer.buffered(),
1429 "MAIL FROM:<a@example.com> REQUIRETLS\r\n",
1430 ));
1431}
1432
1433test "mail carries AUTH= for a relay speaking for somebody else" {
1434 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n250 2.1.0 Ok\r\n");
1435 var out_buf: [512]u8 = undefined;
1436 var writer: Io.Writer = .fixed(&out_buf);
1437 var reply_buf: [64]u8 = undefined;
1438 var client: Client = .init(&reader, &writer, &reply_buf);
1439
1440 try client.mail("relay@example.com", .{
1441 .auth = .{ .mailbox = "e=mc2@example.com" },
1442 });
1443 // The '=' is escaped, because an unescaped one would end the parameter.
1444 try std.testing.expectEqualStrings(
1445 "MAIL FROM:<relay@example.com> AUTH=e+3Dmc2@example.com\r\n",
1446 writer.buffered(),
1447 );
1448
1449 // `<>` is what a relay sends when it cannot vouch for anybody, and RFC
1450 // 4954 asks for that rather than leaving the parameter off.
1451 var second_buf: [256]u8 = undefined;
1452 var second: Io.Writer = .fixed(&second_buf);
1453 client.setTransport(&reader, &second, .plaintext);
1454 try client.mail("relay@example.com", .{ .auth = .unknown });
1455 try std.testing.expectEqualStrings(
1456 "MAIL FROM:<relay@example.com> AUTH=<>\r\n",
1457 second.buffered(),
1458 );
1459}
1460
1461test "an AUTH= mailbox that will not fit is refused before it is sent" {
1462 var reader: Io.Reader = .fixed("");
1463 var out_buf: [1024]u8 = undefined;
1464 var writer: Io.Writer = .fixed(&out_buf);
1465 var reply_buf: [64]u8 = undefined;
1466 var client: Client = .init(&reader, &writer, &reply_buf);
1467
1468 try std.testing.expectError(error.ArgumentTooLong, client.mail("a@b", .{
1469 .auth = .{ .mailbox = "x" ** (protocol.Submitter.max_len + 1) },
1470 }));
1471 // An empty mailbox is not `<>`; the caller meant one or the other.
1472 try std.testing.expectError(error.UnsafeArgument, client.mail("a@b", .{
1473 .auth = .{ .mailbox = "" },
1474 }));
1475 try std.testing.expectEqualStrings("", writer.buffered());
1476}
1477
1478test "NOTIFY=NEVER is written on its own" {
1479 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n");
1480 var out_buf: [128]u8 = undefined;
1481 var writer: Io.Writer = .fixed(&out_buf);
1482 var reply_buf: [64]u8 = undefined;
1483 var client: Client = .init(&reader, &writer, &reply_buf);
1484
1485 try client.rcpt("bob@example.net", .{ .notify = .never });
1486 try std.testing.expectEqualStrings(
1487 "RCPT TO:<bob@example.net> NOTIFY=NEVER\r\n",
1488 writer.buffered(),
1489 );
1490}
1491
1492test "DSN parameter values that exceed their limits are refused" {
1493 var reader: Io.Reader = .fixed("");
1494 var out_buf: [1024]u8 = undefined;
1495 var writer: Io.Writer = .fixed(&out_buf);
1496 var reply_buf: [64]u8 = undefined;
1497 var client: Client = .init(&reader, &writer, &reply_buf);
1498
1499 // 34 spaces encode to 102 characters, over the ENVID limit of 100,
1500 // though the value itself is well under it.
1501 const spaces = " " ** 34;
1502 try std.testing.expectError(
1503 error.ArgumentTooLong,
1504 client.mail("me@example.com", .{ .envid = spaces }),
1505 );
1506 try std.testing.expectError(error.ArgumentTooLong, client.rcpt("bob@example.net", .{
1507 .orcpt = .{ .addr_type = "rfc822", .address = "x" ** 500 },
1508 }));
1509 // An addr-type is written literally, so it is checked rather than encoded.
1510 try std.testing.expectError(error.UnsafeArgument, client.rcpt("bob@example.net", .{
1511 .orcpt = .{ .addr_type = "rfc822;evil", .address = "x@example.net" },
1512 }));
1513 try std.testing.expectEqualStrings("", writer.buffered());
1514}
1515
1516test "hello reports DSN support" {
1517 const responses = "250-mx.example.com\r\n250-DSN\r\n250 8BITMIME\r\n";
1518 var reader: Io.Reader = .fixed(responses);
1519 var out_buf: [128]u8 = undefined;
1520 var writer: Io.Writer = .fixed(&out_buf);
1521 var reply_buf: [256]u8 = undefined;
1522 var client: Client = .init(&reader, &writer, &reply_buf);
1523 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1524 client.sasl_buffer = &sasl_buf;
1525
1526 const ext = try client.hello("client.example.org");
1527 try std.testing.expect(ext.dsn);
1528}
1529
1530test authenticate {
1531 const responses = "250-mx.example.com\r\n250 AUTH PLAIN LOGIN\r\n" ++
1532 "235 2.7.0 Accepted\r\n";
1533 var reader: Io.Reader = .fixed(responses);
1534 var out_buf: [256]u8 = undefined;
1535 var writer: Io.Writer = .fixed(&out_buf);
1536 var reply_buf: [256]u8 = undefined;
1537 var client: Client = .init(&reader, &writer, &reply_buf);
1538 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1539 client.sasl_buffer = &sasl_buf;
1540 client.security = .encrypted;
1541
1542 const extensions = try client.hello("client.example.org");
1543 var plain: sasl.Plain = .init("alice", "secret");
1544 const mechanism = sasl.Client.selectFromList(
1545 &.{plain.client()},
1546 extensions.auth,
1547 true,
1548 ).?;
1549 try client.authenticate(mechanism);
1550
1551 // base64("\x00alice\x00secret"), sent as the initial response in one
1552 // round trip rather than waiting to be asked.
1553 try std.testing.expect(std.mem.endsWith(
1554 u8,
1555 writer.buffered(),
1556 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n",
1557 ));
1558}
1559
1560test "a challenge-response mechanism runs through the 334s" {
1561 const responses = "250-mx.example.com\r\n250 AUTH CRAM-MD5\r\n" ++
1562 // base64 of RFC 2195's challenge
1563 "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++
1564 "235 2.7.0 Accepted\r\n";
1565 var reader: Io.Reader = .fixed(responses);
1566 var out_buf: [512]u8 = undefined;
1567 var writer: Io.Writer = .fixed(&out_buf);
1568 var reply_buf: [256]u8 = undefined;
1569 var client: Client = .init(&reader, &writer, &reply_buf);
1570 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1571 client.sasl_buffer = &sasl_buf;
1572
1573 const extensions = try client.hello("client.example.org");
1574 var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf");
1575 // CRAM-MD5 is not cleartext, so it is usable on this plaintext session.
1576 const mechanism = sasl.Client.selectFromList(&.{cram.client()}, extensions.auth, false).?;
1577 try client.authenticate(mechanism);
1578
1579 // No initial response, then the digest RFC 2195 publishes, base64'd.
1580 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "AUTH CRAM-MD5\r\n") != null);
1581 try std.testing.expect(std.mem.endsWith(
1582 u8,
1583 writer.buffered(),
1584 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n",
1585 ));
1586}
1587
1588test "a mechanism that sends a credential in the clear is refused first" {
1589 var reader: Io.Reader = .fixed("");
1590 var out_buf: [256]u8 = undefined;
1591 var writer: Io.Writer = .fixed(&out_buf);
1592 var reply_buf: [64]u8 = undefined;
1593 var client: Client = .init(&reader, &writer, &reply_buf);
1594 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1595 client.sasl_buffer = &sasl_buf;
1596
1597 var plain: sasl.Plain = .init("alice", "secret");
1598 try std.testing.expectError(
1599 error.InsecureTransport,
1600 client.authenticate(plain.client()),
1601 );
1602 // Nothing reached the wire, which is the point: the refusal happens
1603 // before the credential is written, not after the server rejects it.
1604 try std.testing.expectEqualStrings("", writer.buffered());
1605
1606 client.allow_cleartext_auth = true;
1607 var accepting: Io.Reader = .fixed("235 2.7.0 Accepted\r\n");
1608 client.setTransport(&accepting, &writer, .plaintext);
1609 try client.authenticate(plain.client());
1610}
1611
1612test "a server accepting without finishing the exchange is not authenticated" {
1613 // A mechanism that has not proved what it set out to prove, which is
1614 // SCRAM's shape: `satisfied` stays false until the server's own proof
1615 // has been verified.
1616 const Unfinished = struct {
1617 fn name(_: *anyopaque) []const u8 {
1618 return "MUTUAL-TEST";
1619 }
1620 fn initial(_: *anyopaque, out: *Io.Writer) sasl.Client.Error!sasl.Client.Initial {
1621 try out.writeAll("hello");
1622 return .written;
1623 }
1624 fn respond(_: *anyopaque, _: []const u8, _: *Io.Writer) sasl.Client.Error!void {}
1625 fn satisfied(_: *anyopaque) bool {
1626 return false;
1627 }
1628 fn cleartext(_: *anyopaque) bool {
1629 return false;
1630 }
1631 const vtable: sasl.Client.VTable = .{
1632 .name = name,
1633 .initial = initial,
1634 .respond = respond,
1635 .satisfied = satisfied,
1636 .cleartext = cleartext,
1637 };
1638 };
1639 var nothing: u8 = 0;
1640 const mechanism: sasl.Client = .{ .context = ¬hing, .vtable = &Unfinished.vtable };
1641
1642 var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n");
1643 var out_buf: [256]u8 = undefined;
1644 var writer: Io.Writer = .fixed(&out_buf);
1645 var reply_buf: [64]u8 = undefined;
1646 var client: Client = .init(&reader, &writer, &reply_buf);
1647 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1648 client.sasl_buffer = &sasl_buf;
1649
1650 // The server said yes. The mechanism disagrees, and it is the one that
1651 // knows — this is the case nothing in this library could express before
1652 // the mechanisms moved out of it.
1653 try std.testing.expectError(
1654 error.ServerNotAuthenticated,
1655 client.authenticate(mechanism),
1656 );
1657}
1658
1659test "a mechanism that fails mid-exchange cancels rather than stranding the session" {
1660 // PLAIN is never challenged, so a 334 makes it return BadChallenge.
1661 const responses = "334 c29tZXRoaW5n\r\n501 5.5.2 Cancelled\r\n";
1662 var reader: Io.Reader = .fixed(responses);
1663 var out_buf: [256]u8 = undefined;
1664 var writer: Io.Writer = .fixed(&out_buf);
1665 var reply_buf: [64]u8 = undefined;
1666 var client: Client = .init(&reader, &writer, &reply_buf);
1667 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1668 client.sasl_buffer = &sasl_buf;
1669 client.security = .encrypted;
1670
1671 var plain: sasl.Plain = .init("alice", "secret");
1672 try std.testing.expectError(error.BadChallenge, client.authenticate(plain.client()));
1673 // RFC 4954 §4's cancellation went out, so the server is not left waiting
1674 // for a line that was never coming.
1675 try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "*\r\n"));
1676 try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen());
1677}
1678
1679test "authenticate needs a buffer, and says so rather than overrunning one" {
1680 var reader: Io.Reader = .fixed("");
1681 var out_buf: [256]u8 = undefined;
1682 var writer: Io.Writer = .fixed(&out_buf);
1683 var reply_buf: [64]u8 = undefined;
1684 var client: Client = .init(&reader, &writer, &reply_buf);
1685 client.security = .encrypted;
1686
1687 var plain: sasl.Plain = .init("alice", "secret");
1688 // No buffer at all: this is the default, and it is an error rather than
1689 // a hidden allocation or a stack array the caller cannot see.
1690 try std.testing.expectError(
1691 error.SaslBufferTooSmall,
1692 client.authenticate(plain.client()),
1693 );
1694
1695 var tiny: [sasl_buffer_min - 1]u8 = undefined;
1696 client.sasl_buffer = &tiny;
1697 try std.testing.expectError(
1698 error.SaslBufferTooSmall,
1699 client.authenticate(plain.client()),
1700 );
1701 try std.testing.expectEqualStrings("", writer.buffered());
1702}
1703
1704test "the two halves of the buffer take turns rather than coexist" {
1705 // A challenge decodes into the coded half, the mechanism's answer is
1706 // written into the plain half, and the answer encodes back over the
1707 // challenge. The minimum buffer is enough to run a real exchange, which
1708 // is what this checks: at 896 bytes there are 512 coded and 384 plain.
1709 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++
1710 "235 2.7.0 Accepted\r\n";
1711 var reader: Io.Reader = .fixed(responses);
1712 var out_buf: [512]u8 = undefined;
1713 var writer: Io.Writer = .fixed(&out_buf);
1714 var reply_buf: [256]u8 = undefined;
1715 var client: Client = .init(&reader, &writer, &reply_buf);
1716 var scratch: [sasl_buffer_min]u8 = undefined;
1717 client.sasl_buffer = &scratch;
1718
1719 var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf");
1720 try client.authenticate(cram.client());
1721 try std.testing.expect(std.mem.endsWith(
1722 u8,
1723 writer.buffered(),
1724 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n",
1725 ));
1726}
1727
1728test "a rejection surfaces as AuthenticationFailed with the reply" {
1729 var reader: Io.Reader = .fixed("535 5.7.8 Authentication credentials invalid\r\n");
1730 var out_buf: [256]u8 = undefined;
1731 var writer: Io.Writer = .fixed(&out_buf);
1732 var reply_buf: [256]u8 = undefined;
1733 var client: Client = .init(&reader, &writer, &reply_buf);
1734 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1735 client.sasl_buffer = &sasl_buf;
1736 client.security = .encrypted;
1737
1738 var plain: sasl.Plain = .init("alice", "secret");
1739 try std.testing.expectError(
1740 error.AuthenticationFailed,
1741 client.authenticate(plain.client()),
1742 );
1743 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code);
1744}
1745
1746test "a rejection's enhanced status code says more than its reply code" {
1747 // Two different refusals behind the same 550: one about the address,
1748 // one about policy. The three-digit code cannot tell them apart and the
1749 // enhanced one can, which is the whole reason to read it.
1750 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n550 5.1.1 No such user\r\n");
1751 var out_buf: [256]u8 = undefined;
1752 var writer: Io.Writer = .fixed(&out_buf);
1753 var reply_buf: [256]u8 = undefined;
1754 var client: Client = .init(&reader, &writer, &reply_buf);
1755
1756 try client.mailFrom("alice@example.com");
1757 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.net"));
1758
1759 const reply = client.last_reply.?;
1760 const status = reply.enhanced().?;
1761 try std.testing.expect(status.agrees(reply.code));
1762 try std.testing.expectEqual(protocol.Enhanced.Subject.addressing, status.subjectClass());
1763 try std.testing.expectEqual(@as(u16, 1), status.detail);
1764 // And the part meant for a person, without the code in front of it.
1765 try std.testing.expectEqualStrings("No such user", reply.message());
1766}
1767
1768test "a server that contradicts itself is detectable" {
1769 // 250 carrying a 5.x.x code. Nothing in RFC 3463 says what to do about
1770 // it, but a caller can at least see it rather than trusting either half.
1771 var reader: Io.Reader = .fixed("250 5.1.1 Ok?\r\n");
1772 var out_buf: [128]u8 = undefined;
1773 var writer: Io.Writer = .fixed(&out_buf);
1774 var reply_buf: [128]u8 = undefined;
1775 var client: Client = .init(&reader, &writer, &reply_buf);
1776
1777 try client.mailFrom("alice@example.com"); // the 2xx is what `mail` checks
1778 const reply = client.last_reply.?;
1779 try std.testing.expect(!reply.enhanced().?.agrees(reply.code));
1780}
1781
1782test "a multiline reply repeats the code on every line" {
1783 const responses = "250-mx.example.com\r\n250 SIZE 1000000\r\n" ++
1784 "452-4.5.3 Too many recipients\r\n452 4.5.3 Try fewer\r\n";
1785 var reader: Io.Reader = .fixed(responses);
1786 var out_buf: [256]u8 = undefined;
1787 var writer: Io.Writer = .fixed(&out_buf);
1788 var reply_buf: [256]u8 = undefined;
1789 var client: Client = .init(&reader, &writer, &reply_buf);
1790
1791 _ = try client.hello("client.example.org");
1792 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("b@example.net"));
1793
1794 const reply = client.last_reply.?;
1795 // `message` strips the first line's code; the rest are reached through
1796 // `lines`, which is what the doc comment says to do.
1797 try std.testing.expectEqualStrings("Too many recipients\nTry fewer", blk: {
1798 var joined: [64]u8 = undefined;
1799 var out: Io.Writer = .fixed(&joined);
1800 var it = reply.lines();
1801 var first = true;
1802 while (it.next()) |line| {
1803 if (!first) try out.writeByte('\n');
1804 first = false;
1805 try out.writeAll(protocol.Enhanced.strip(line));
1806 }
1807 break :blk out.buffered();
1808 });
1809}
1810
1811test "an address carrying CRLF cannot inject a command" {
1812 // Without the check this would put a second RCPT on the wire.
1813 const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net";
1814 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
1815 var out_buf: [256]u8 = undefined;
1816 var writer: Io.Writer = .fixed(&out_buf);
1817 var reply_buf: [64]u8 = undefined;
1818 var client: Client = .init(&reader, &writer, &reply_buf);
1819
1820 try std.testing.expectError(error.UnsafeArgument, client.rcptTo(smuggled));
1821 try std.testing.expectError(error.UnsafeArgument, client.mailFrom(smuggled));
1822 try std.testing.expectError(error.UnsafeArgument, client.mailFromUtf8(smuggled));
1823 try std.testing.expectError(error.UnsafeArgument, client.hello("host\r\nQUIT"));
1824 // Nothing reached the wire, so the session is still where it was.
1825 try std.testing.expectEqualStrings("", writer.buffered());
1826}
1827
1828test hello {
1829 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n";
1830 var reader: Io.Reader = .fixed(responses);
1831 var out_buf: [256]u8 = undefined;
1832 var writer: Io.Writer = .fixed(&out_buf);
1833 var reply_buf: [256]u8 = undefined;
1834 var client: Client = .init(&reader, &writer, &reply_buf);
1835
1836 const ext = try client.hello("c.example");
1837 try std.testing.expectEqualStrings("PLAIN LOGIN CRAM-MD5", ext.auth);
1838}
1839
1840test init {
1841 var reader: Io.Reader = .fixed("");
1842 var out_buf: [16]u8 = undefined;
1843 var writer: Io.Writer = .fixed(&out_buf);
1844 var reply_buf: [128]u8 = undefined;
1845 const client: Client = .init(&reader, &writer, &reply_buf);
1846 try std.testing.expect(client.last_reply == null);
1847}
1848
1849test greet {
1850 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n");
1851 var out_buf: [16]u8 = undefined;
1852 var writer: Io.Writer = .fixed(&out_buf);
1853 var reply_buf: [128]u8 = undefined;
1854 var client: Client = .init(&reader, &writer, &reply_buf);
1855
1856 const reply = try client.greet();
1857 try std.testing.expectEqual(@as(u16, 220), reply.code);
1858 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text);
1859}
1860
1861test setTransport {
1862 var reader: Io.Reader = .fixed("");
1863 var out_buf: [16]u8 = undefined;
1864 var writer: Io.Writer = .fixed(&out_buf);
1865 var reply_buf: [64]u8 = undefined;
1866 var client: Client = .init(&reader, &writer, &reply_buf);
1867
1868 // After a TLS handshake, point the session at the encrypted streams.
1869 var tls_reader: Io.Reader = .fixed("");
1870 var tls_out_buf: [16]u8 = undefined;
1871 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
1872 client.setTransport(&tls_reader, &tls_writer, .encrypted);
1873 try std.testing.expectEqual(&tls_reader, client.reader);
1874 try std.testing.expectEqual(&tls_writer, client.writer);
1875 try std.testing.expectEqual(Security.encrypted, client.security);
1876}
1877
1878test mailFrom {
1879 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
1880 var out_buf: [64]u8 = undefined;
1881 var writer: Io.Writer = .fixed(&out_buf);
1882 var reply_buf: [64]u8 = undefined;
1883 var client: Client = .init(&reader, &writer, &reply_buf);
1884
1885 try client.mailFrom("alice@example.com");
1886 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered());
1887}
1888
1889test rcptTo {
1890 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n");
1891 var out_buf: [64]u8 = undefined;
1892 var writer: Io.Writer = .fixed(&out_buf);
1893 var reply_buf: [64]u8 = undefined;
1894 var client: Client = .init(&reader, &writer, &reply_buf);
1895
1896 try client.rcptTo("bob@example.net");
1897 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered());
1898}
1899
1900test sendMessage {
1901 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n");
1902 var out_buf: [128]u8 = undefined;
1903 var writer: Io.Writer = .fixed(&out_buf);
1904 var reply_buf: [64]u8 = undefined;
1905 var client: Client = .init(&reader, &writer, &reply_buf);
1906
1907 try client.sendMessage("Subject: hi\n\nhello\n");
1908 try std.testing.expectEqualStrings(
1909 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n",
1910 writer.buffered(),
1911 );
1912}
1913
1914test rset {
1915 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
1916 var out_buf: [16]u8 = undefined;
1917 var writer: Io.Writer = .fixed(&out_buf);
1918 var reply_buf: [64]u8 = undefined;
1919 var client: Client = .init(&reader, &writer, &reply_buf);
1920
1921 try client.rset();
1922 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered());
1923}
1924
1925test noop {
1926 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
1927 var out_buf: [16]u8 = undefined;
1928 var writer: Io.Writer = .fixed(&out_buf);
1929 var reply_buf: [64]u8 = undefined;
1930 var client: Client = .init(&reader, &writer, &reply_buf);
1931
1932 try client.noop();
1933 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered());
1934}
1935
1936test quit {
1937 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n");
1938 var out_buf: [16]u8 = undefined;
1939 var writer: Io.Writer = .fixed(&out_buf);
1940 var reply_buf: [64]u8 = undefined;
1941 var client: Client = .init(&reader, &writer, &reply_buf);
1942
1943 try client.quit();
1944 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered());
1945}
1946
1947test data {
1948 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
1949 var out_buf: [256]u8 = undefined;
1950 var writer: Io.Writer = .fixed(&out_buf);
1951 var reply_buf: [64]u8 = undefined;
1952 var client: Client = .init(&reader, &writer, &reply_buf);
1953
1954 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily.
1955 var data_writer = try client.data();
1956 try data_writer.interface.writeAll("Subject: chunked\n\nfirst");
1957 try data_writer.interface.writeAll(" second\r");
1958 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r");
1959 try data_writer.interface.writeAll("\n");
1960 try data_writer.interface.writeAll(".x\nend");
1961 try data_writer.end();
1962
1963 try std.testing.expectEqualStrings(
1964 "DATA\r\n" ++
1965 "Subject: chunked\r\n" ++
1966 "\r\n" ++
1967 "first second\r\n" ++
1968 "..needs stuffing\r\n" ++
1969 "split\r\n" ++
1970 "..x\r\n" ++
1971 "end\r\n" ++
1972 ".\r\n",
1973 writer.buffered(),
1974 );
1975}
1976
1977test sendMessageReader {
1978 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
1979 var out_buf: [128]u8 = undefined;
1980 var writer: Io.Writer = .fixed(&out_buf);
1981 var reply_buf: [64]u8 = undefined;
1982 var client: Client = .init(&reader, &writer, &reply_buf);
1983 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
1984 client.sasl_buffer = &sasl_buf;
1985
1986 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n");
1987 try client.sendMessageReader(&message);
1988 try std.testing.expectEqualStrings(
1989 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n",
1990 writer.buffered(),
1991 );
1992}
1993
1994test "fuzz client against arbitrary server replies" {
1995 try std.testing.fuzz({}, fuzzClientReplies, .{});
1996}
1997
1998fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void {
1999 _ = context;
2000 var input_buf: [1024]u8 = undefined;
2001 const input = input_buf[0..smith.value(u10)];
2002 smith.bytes(input);
2003
2004 var reader: Io.Reader = .fixed(input);
2005 var out_buf: [4096]u8 = undefined;
2006 var writer: Io.Writer = .fixed(&out_buf);
2007 var reply_buf: [256]u8 = undefined;
2008 var client: Client = .init(&reader, &writer, &reply_buf);
2009 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined;
2010 client.sasl_buffer = &sasl_buf;
2011
2012 // Whatever the "server" says, the client must fail cleanly, never crash.
2013 _ = client.greet() catch return;
2014 const extensions = client.hello("fuzz.example.org") catch return;
2015 var plain: sasl.Plain = .init("user", "password");
2016 client.allow_cleartext_auth = true;
2017 if (sasl.Client.selectFromList(&.{plain.client()}, extensions.auth, true)) |mechanism|
2018 client.authenticate(mechanism) catch {};
2019 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {};
2020 client.quit() catch {};
2021}
2022
2023test "fuzz DataWriter equivalence with writeStuffed" {
2024 try std.testing.fuzz({}, fuzzDataWriter, .{});
2025}
2026
2027fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void {
2028 _ = context;
2029 var message_buf: [1024]u8 = undefined;
2030 const message = message_buf[0..smith.value(u10)];
2031 smith.bytes(message);
2032
2033 // Reference implementation: slice-based stuffing.
2034 var expected_buf: [2100]u8 = undefined;
2035 var expected: Io.Writer = .fixed(&expected_buf);
2036 try protocol.writeStuffed(&expected, message);
2037
2038 // Streaming implementation, with fuzzer-chosen chunk boundaries.
2039 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n");
2040 var out_buf: [2200]u8 = undefined;
2041 var writer: Io.Writer = .fixed(&out_buf);
2042 var reply_buf: [64]u8 = undefined;
2043 var client: Client = .init(&responses, &writer, &reply_buf);
2044
2045 var data_writer = try client.data();
2046 var rest: []const u8 = message;
2047 while (rest.len > 0) {
2048 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len));
2049 try data_writer.interface.writeAll(rest[0..n]);
2050 rest = rest[n..];
2051 }
2052 try data_writer.end();
2053
2054 const written = writer.buffered();
2055 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n"));
2056 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n"));
2057 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len];
2058 try std.testing.expectEqualStrings(expected.buffered(), stuffed);
2059}
2060
2061test Extensions {
2062 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 };
2063 try std.testing.expect(extensions.pipelining);
2064 try std.testing.expect(!extensions.starttls);
2065 try std.testing.expectEqualStrings("", extensions.auth);
2066 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size);
2067}
2068
2069test bdat {
2070 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n");
2071 var out_buf: [128]u8 = undefined;
2072 var writer: Io.Writer = .fixed(&out_buf);
2073 var reply_buf: [64]u8 = undefined;
2074 var client: Client = .init(&reader, &writer, &reply_buf);
2075
2076 try client.bdat("Subject: hi\r\n\r\n", false);
2077 try client.bdat("body\r\n", true);
2078 try std.testing.expectEqualStrings(
2079 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n",
2080 writer.buffered(),
2081 );
2082}
2083
2084test sendMessageChunked {
2085 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
2086 var out_buf: [128]u8 = undefined;
2087 var writer: Io.Writer = .fixed(&out_buf);
2088 var reply_buf: [64]u8 = undefined;
2089 var client: Client = .init(&reader, &writer, &reply_buf);
2090
2091 // Raw transmission: the leading dot is not stuffed.
2092 try client.sendMessageChunked(".raw\r\n");
2093 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered());
2094}
2095
2096test mailFromUtf8 {
2097 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
2098 var out_buf: [64]u8 = undefined;
2099 var writer: Io.Writer = .fixed(&out_buf);
2100 var reply_buf: [64]u8 = undefined;
2101 var client: Client = .init(&reader, &writer, &reply_buf);
2102
2103 try client.mailFromUtf8("böb@example.com");
2104 try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered());
2105}