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