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