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