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