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/// Permits `authenticate`, `authPlain` and `authLogin` to send credentials
41/// over a `.plaintext` transport, which they otherwise refuse with
42/// `error.InsecureTransport`.
43///
44/// The honest use is a connection protected by something outside this
45/// library's view — a unix socket, an SSH tunnel, a loopback test — where
46/// setting `security` to `.encrypted` would be a lie. Anything else is
47/// handing the password to the network.
48allow_cleartext_auth: bool = false,
49
50/// Whether the transport encrypts what is written to it.
51pub const Security = enum { plaintext, encrypted };
52
53pub const Error = error{
54 WriteFailed,
55 ReadFailed,
56 EndOfStream,
57 LineTooLong,
58 InvalidReply,
59 ReplyTooLong,
60 /// The server answered with an unexpected code; see `last_reply`.
61 UnexpectedReply,
62};
63
64pub const ArgumentError = error{
65 /// An argument contained CR, LF or NUL and was not sent. See
66 /// `protocol.isSafeArgument` for why those three bytes and no others.
67 UnsafeArgument,
68 /// An ESMTP parameter value exceeded the length its RFC allows —
69 /// `ENVID` past 100 characters or `ORCPT` past 500, measured on the
70 /// xtext-encoded form that would go on the wire.
71 ArgumentTooLong,
72};
73
74/// Extensions advertised in the server's EHLO response.
75pub const Extensions = struct {
76 pipelining: bool = false,
77 eight_bit_mime: bool = false,
78 starttls: bool = false,
79 smtputf8: bool = false,
80 chunking: bool = false,
81 enhanced_status_codes: bool = false,
82 /// The server accepts the DSN parameters of
83 /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and
84 /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT.
85 dsn: bool = false,
86 /// AUTH mechanisms advertised by the server.
87 auth: Auth = .{},
88 /// Value of the SIZE extension, if advertised with a value.
89 max_size: ?u64 = null,
90
91 pub const Auth = struct {
92 plain: bool = false,
93 login: bool = false,
94 cram_md5: bool = false,
95
96 pub fn any(a: Auth) bool {
97 return a.plain or a.login or a.cram_md5;
98 }
99
100 test any {
101 try std.testing.expect((Auth{ .login = true }).any());
102 try std.testing.expect(!(Auth{}).any());
103 }
104
105 fn parse(arg: []const u8) Auth {
106 var auth: Auth = .{};
107 var it = std.mem.tokenizeScalar(u8, arg, ' ');
108 while (it.next()) |mechanism| {
109 if (ieql(mechanism, "PLAIN")) {
110 auth.plain = true;
111 } else if (ieql(mechanism, "LOGIN")) {
112 auth.login = true;
113 } else if (ieql(mechanism, "CRAM-MD5")) {
114 auth.cram_md5 = true;
115 }
116 }
117 return auth;
118 }
119 };
120
121 fn parse(reply: Reply) Extensions {
122 var ext: Extensions = .{};
123 var it = reply.lines();
124 _ = it.next(); // The first line is the server's greeting, not a keyword.
125 while (it.next()) |line| {
126 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
127 const kw = line[0..kw_end];
128 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else "";
129 if (ieql(kw, "PIPELINING")) {
130 ext.pipelining = true;
131 } else if (ieql(kw, "8BITMIME")) {
132 ext.eight_bit_mime = true;
133 } else if (ieql(kw, "STARTTLS")) {
134 ext.starttls = true;
135 } else if (ieql(kw, "SMTPUTF8")) {
136 ext.smtputf8 = true;
137 } else if (ieql(kw, "CHUNKING")) {
138 ext.chunking = true;
139 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) {
140 ext.enhanced_status_codes = true;
141 } else if (ieql(kw, "DSN")) {
142 ext.dsn = true;
143 } else if (ieql(kw, "AUTH")) {
144 ext.auth = Auth.parse(arg);
145 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) {
146 // Some legacy servers advertise "AUTH=PLAIN LOGIN".
147 var legacy_arg_buf: [128]u8 = undefined;
148 const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..];
149 ext.auth = Auth.parse(joined);
150 } else if (ieql(kw, "SIZE")) {
151 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null;
152 }
153 }
154 return ext;
155 }
156
157 fn ieql(a: []const u8, b: []const u8) bool {
158 return std.ascii.eqlIgnoreCase(a, b);
159 }
160};
161
162/// `reply_buffer` must be large enough for the largest expected reply text
163/// (the EHLO response is usually the largest); 512 bytes is plenty in
164/// practice.
165pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client {
166 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer };
167}
168
169/// Reads the server's 220 greeting. Call once, right after connecting.
170pub fn greet(c: *Client) Error!Reply {
171 return c.expect(220);
172}
173
174/// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1))
175/// and returns the extensions the server advertised, falling back
176/// to plain HELO for servers that do not speak ESMTP.
177pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions {
178 if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument;
179 try c.send("EHLO {s}", .{client_name});
180 const reply = try c.readReply();
181 if (reply.isPositiveCompletion()) return Extensions.parse(reply);
182 if (reply.code == 500 or reply.code == 502) {
183 try c.send("HELO {s}", .{client_name});
184 _ = try c.expectClass(2);
185 return .{};
186 }
187 return error.UnexpectedReply;
188}
189
190/// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and
191/// reads the server's 220 go-ahead. On
192/// success, perform a TLS handshake over the underlying stream (see `Tls`),
193/// switch to the encrypted transport with `setTransport`, and then call
194/// `hello` again — the server discards everything it learned before the
195/// handshake, including the EHLO state.
196pub fn starttls(c: *Client) Error!void {
197 try c.send("STARTTLS", .{});
198 _ = try c.expect(220);
199}
200
201/// Replaces the session's transport, typically with a TLS reader/writer
202/// after `starttls`, and records whether the new one is encrypted. Pass
203/// `.encrypted` for a TLS transport; that is what lets `authenticate` use a
204/// mechanism that sends the password.
205pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer, security: Security) void {
206 c.reader = reader;
207 c.writer = writer;
208 c.security = security;
209}
210
211pub const AuthError = Error || ArgumentError || error{
212 CredentialsTooLong,
213 /// The transport is not encrypted and the mechanism would have put the
214 /// password on the wire in the clear. Upgrade the session with
215 /// `starttls`, or set `allow_cleartext_auth` if the connection is
216 /// protected by something this library cannot see.
217 InsecureTransport,
218 /// The server rejected the credentials; see `last_reply`.
219 AuthenticationFailed,
220 /// The server's CRAM-MD5 challenge was not valid base64.
221 InvalidChallenge,
222 /// The server advertised none of the supported mechanisms.
223 NoSupportedMechanism,
224};
225
226/// Authenticates with the best mechanism the server advertised, which
227/// depends on `security`.
228///
229/// Over an encrypted transport that is PLAIN, then LOGIN, then CRAM-MD5:
230/// the network cannot read any of them, so the order is by how reliably
231/// servers implement them. Over a plaintext one the order inverts to
232/// CRAM-MD5 first, because it is the only one of the three that does not
233/// put the password on the wire; if the server does not offer it, the
234/// remaining mechanisms are refused with `error.InsecureTransport` rather
235/// than used, unless `allow_cleartext_auth` says otherwise.
236pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void {
237 if (c.security == .plaintext and extensions.auth.cram_md5)
238 return c.authCramMd5(username, password);
239 if (extensions.auth.plain) return c.authPlain("", username, password);
240 if (extensions.auth.login) return c.authLogin(username, password);
241 if (extensions.auth.cram_md5) return c.authCramMd5(username, password);
242 return error.NoSupportedMechanism;
243}
244
245/// Refuses a mechanism that would transmit the password unprotected.
246fn requireConfidentiality(c: *Client) AuthError!void {
247 if (c.security == .encrypted or c.allow_cleartext_auth) return;
248 return error.InsecureTransport;
249}
250
251/// Authenticates with AUTH PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)).
252/// Pass an empty `authzid` unless
253/// you need to act on behalf of another identity.
254///
255/// The credentials cross the wire in the clear (base64 is not encryption),
256/// so this returns `error.InsecureTransport` unless `security` is
257/// `.encrypted` or `allow_cleartext_auth` is set.
258pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void {
259 try c.requireConfidentiality();
260 // NUL separates the three fields, so one hidden in a field would move
261 // the boundaries and authenticate as somebody else.
262 if (!protocol.isSafeArgument(authzid) or !protocol.isSafeArgument(username) or
263 !protocol.isSafeArgument(password)) return error.UnsafeArgument;
264 var plain_buf: [512]u8 = undefined;
265 var plain: Io.Writer = .fixed(&plain_buf);
266 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch
267 return error.CredentialsTooLong;
268 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined;
269 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered());
270 try c.send("AUTH PLAIN {s}", .{b64});
271 try c.expectAuthSuccess();
272}
273
274/// Authenticates with AUTH LOGIN, the legacy two-step username/password
275/// exchange still required by some servers (no RFC; the de-facto
276/// [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00)
277/// mechanism). Like AUTH PLAIN it sends the credentials in the clear, so
278/// it returns `error.InsecureTransport` unless `security` is `.encrypted`
279/// or `allow_cleartext_auth` is set.
280pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void {
281 try c.requireConfidentiality();
282 try c.send("AUTH LOGIN", .{});
283 _ = try c.expect(334); // Username: prompt
284 try c.sendBase64(username);
285 _ = try c.expect(334); // Password: prompt
286 try c.sendBase64(password);
287 try c.expectAuthSuccess();
288}
289
290/// Authenticates with AUTH CRAM-MD5 ([RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195)):
291/// the password never crosses
292/// the wire, only an HMAC-MD5 of the server's challenge — which is why this
293/// one is allowed over a plaintext transport, and why `authenticate`
294/// prefers it there. The challenge is still replayable and MD5 is long
295/// past retirement, so it is a way to avoid handing over the password, not
296/// a substitute for TLS.
297pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void {
298 if (!protocol.isSafeArgument(username)) return error.UnsafeArgument;
299 try c.send("AUTH CRAM-MD5", .{});
300 const reply = try c.expect(334);
301
302 var challenge_buf: [512]u8 = undefined;
303 const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch
304 return error.InvalidChallenge;
305 if (challenge_len > challenge_buf.len) return error.InvalidChallenge;
306 std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch
307 return error.InvalidChallenge;
308
309 var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined;
310 std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password);
311 const digest = std.fmt.bytesToHex(mac, .lower);
312
313 var response_buf: [384]u8 = undefined;
314 var response: Io.Writer = .fixed(&response_buf);
315 response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong;
316 try c.sendBase64(response.buffered());
317 try c.expectAuthSuccess();
318}
319
320/// Sends `bytes` base64-encoded as a bare continuation line.
321fn sendBase64(c: *Client, bytes: []const u8) AuthError!void {
322 var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined;
323 if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len)
324 return error.CredentialsTooLong;
325 const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes);
326 try c.send("{s}", .{b64});
327}
328
329fn expectAuthSuccess(c: *Client) AuthError!void {
330 const reply = try c.readReply();
331 if (reply.code != 235) return error.AuthenticationFailed;
332}
333
334/// Parameters for the MAIL command. Send only what the server advertised:
335/// an unrecognized parameter is a 555 from a conforming server, so check
336/// `Extensions` first.
337pub const MailOptions = struct {
338 /// Requests the SMTPUTF8 extension
339 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)), which
340 /// lets the envelope and headers carry UTF-8. Needs `Extensions.smtputf8`.
341 smtputf8: bool = false,
342 /// DSN `RET=`: how much of the message a failure report should carry
343 /// back. Needs `Extensions.dsn`.
344 ret: ?protocol.Ret = null,
345 /// DSN `ENVID=`: an identifier quoted back in any report about this
346 /// message. Sent xtext-encoded, so any bytes are safe to pass, and
347 /// rejected with `error.ArgumentTooLong` if the encoded form exceeds the
348 /// 100 characters RFC 3461 allows. Needs `Extensions.dsn`.
349 envid: ?[]const u8 = null,
350};
351
352/// Parameters for the RCPT command, which in this library means the DSN
353/// ones. Needs `Extensions.dsn`; see `MailOptions`.
354pub const RcptOptions = struct {
355 /// DSN `NOTIFY=`: when the sender wants to hear about this recipient.
356 /// Leave null to let the receiver apply its default.
357 notify: ?protocol.Notify = null,
358 /// DSN `ORCPT=`: the address the message was originally addressed to,
359 /// carried through aliasing so a report can name what the sender wrote.
360 /// The address is sent xtext-encoded; the `addr_type` is not, so it is
361 /// checked instead, and the whole parameter is capped at the 500
362 /// characters RFC 3461 allows.
363 orcpt: ?protocol.Orcpt = null,
364};
365
366/// Starts a mail transaction. An empty `from` sends the null reverse-path
367/// (`MAIL FROM:<>`), used for bounces.
368///
369/// Returns `error.UnsafeArgument` for an address that would break out of
370/// the command line; see `protocol.isSafeArgument`.
371pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void {
372 return c.mail(from, .{});
373}
374
375/// `mailFrom` with ESMTP parameters.
376pub fn mail(c: *Client, from: []const u8, options: MailOptions) (Error || ArgumentError)!void {
377 if (!protocol.isSafeArgument(from)) return error.UnsafeArgument;
378 if (options.envid) |envid| {
379 if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len)
380 return error.ArgumentTooLong;
381 }
382 try c.writer.print("MAIL FROM:<{s}>", .{from});
383 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8");
384 if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret});
385 if (options.envid) |envid| {
386 try c.writer.writeAll(" ENVID=");
387 try protocol.writeXtext(c.writer, envid);
388 }
389 try c.writer.writeAll(protocol.crlf);
390 try c.writer.flush();
391 _ = try c.expectClass(2);
392}
393
394/// Adds a recipient to the current transaction. Returns
395/// `error.UnsafeArgument` for an address that would break out of the
396/// command line; see `protocol.isSafeArgument`.
397pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void {
398 return c.rcpt(to, .{});
399}
400
401/// `rcptTo` with ESMTP parameters.
402pub fn rcpt(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!void {
403 if (!protocol.isSafeArgument(to)) return error.UnsafeArgument;
404 if (options.orcpt) |orcpt| {
405 if (orcpt.addr_type.len == 0 or !protocol.isSafeArgument(orcpt.addr_type) or
406 std.mem.findScalar(u8, orcpt.addr_type, ';') != null)
407 return error.UnsafeArgument;
408 if (orcpt.addr_type.len + 1 + protocol.xtextEncodedLen(orcpt.address) > protocol.Orcpt.max_len)
409 return error.ArgumentTooLong;
410 }
411 try c.writer.print("RCPT TO:<{s}>", .{to});
412 if (options.notify) |notify| try c.writer.print(" NOTIFY={f}", .{notify});
413 if (options.orcpt) |orcpt| try c.writer.print(" ORCPT={f}", .{orcpt});
414 try c.writer.writeAll(protocol.crlf);
415 try c.writer.flush();
416 _ = try c.expectClass(2);
417}
418
419/// Sends the message content for the current transaction (DATA). Line
420/// endings in `data` are normalized to CRLF and leading dots are stuffed.
421pub fn sendMessage(c: *Client, message_data: []const u8) Error!void {
422 var data_writer = try c.data();
423 try data_writer.interface.writeAll(message_data);
424 try data_writer.end();
425}
426
427/// Streams the message content for the current transaction from `message`
428/// until end of stream. Line endings are normalized to CRLF and leading
429/// dots stuffed; nothing is buffered beyond the transport writer, so lines
430/// and messages of any length work.
431pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void {
432 var data_writer = try c.data();
433 while (true) {
434 const chunk = message.peekGreedy(1) catch |err| switch (err) {
435 error.EndOfStream => break,
436 error.ReadFailed => return error.ReadFailed,
437 };
438 try data_writer.interface.writeAll(chunk);
439 message.toss(chunk.len);
440 }
441 try data_writer.end();
442}
443
444/// Starts the DATA phase for streaming a message body: write the content
445/// through the returned writer's `interface`, then call `end`. Line endings
446/// are normalized to CRLF and leading dots stuffed as the data flows.
447pub fn data(c: *Client) Error!DataWriter {
448 try c.send("DATA", .{});
449 _ = try c.expect(354);
450 return .{
451 .client = c,
452 .interface = .{
453 .buffer = &.{},
454 .vtable = &.{ .drain = DataWriter.drain },
455 },
456 };
457}
458
459/// Streaming writer for a message body; obtained from `data`. The dot
460/// stuffing and CRLF normalization state lives here, so chunks may split
461/// lines (and even CRLF pairs) at any byte boundary.
462pub const DataWriter = struct {
463 client: *Client,
464 interface: Io.Writer,
465 at_line_start: bool = true,
466 /// A '\r' was seen but not yet emitted; whether it is a line ending
467 /// depends on the next byte.
468 pending_cr: bool = false,
469
470 /// Terminates the message (adding a final CRLF if the content did not
471 /// end with one, then ".\r\n") and reads the server's verdict.
472 pub fn end(dw: *DataWriter) Error!void {
473 try dw.interface.flush();
474 const c = dw.client;
475 if (dw.pending_cr) {
476 // A trailing bare CR counts as a line ending, matching
477 // `protocol.writeStuffed`.
478 dw.pending_cr = false;
479 dw.at_line_start = true;
480 try c.writer.writeAll(protocol.crlf);
481 }
482 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf);
483 try c.writer.writeAll("." ++ protocol.crlf);
484 try c.writer.flush();
485 _ = try c.expectClass(2);
486 }
487
488 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize {
489 const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w));
490 try dw.writeChunk(w.buffered());
491 w.end = 0;
492 if (chunks.len == 0) return 0;
493 var n: usize = 0;
494 for (chunks[0 .. chunks.len - 1]) |bytes| {
495 try dw.writeChunk(bytes);
496 n += bytes.len;
497 }
498 const pattern = chunks[chunks.len - 1];
499 for (0..splat) |_| {
500 try dw.writeChunk(pattern);
501 n += pattern.len;
502 }
503 return n;
504 }
505
506 test end {
507 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
508 var out_buf: [64]u8 = undefined;
509 var writer: Io.Writer = .fixed(&out_buf);
510 var reply_buf: [64]u8 = undefined;
511 var client: Client = .init(&reader, &writer, &reply_buf);
512
513 var data_writer = try client.data();
514 try data_writer.interface.writeAll("no trailing newline");
515 try data_writer.end(); // adds the final CRLF, sends ".", reads 250
516 try std.testing.expectEqualStrings(
517 "DATA\r\nno trailing newline\r\n.\r\n",
518 writer.buffered(),
519 );
520 }
521
522 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void {
523 const out = dw.client.writer;
524 var rest = bytes;
525 while (rest.len > 0) {
526 if (dw.pending_cr) {
527 dw.pending_cr = false;
528 if (rest[0] == '\n') {
529 try out.writeAll(protocol.crlf);
530 dw.at_line_start = true;
531 rest = rest[1..];
532 continue;
533 }
534 // A bare CR mid-line passes through untouched.
535 try out.writeByte('\r');
536 dw.at_line_start = false;
537 }
538 if (dw.at_line_start and rest[0] == '.') {
539 try out.writeAll("..");
540 dw.at_line_start = false;
541 rest = rest[1..];
542 continue;
543 }
544 const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse {
545 try out.writeAll(rest);
546 dw.at_line_start = false;
547 break;
548 };
549 if (special > 0) {
550 try out.writeAll(rest[0..special]);
551 dw.at_line_start = false;
552 }
553 switch (rest[special]) {
554 '\r' => dw.pending_cr = true,
555 '\n' => {
556 try out.writeAll(protocol.crlf);
557 dw.at_line_start = true;
558 },
559 else => unreachable,
560 }
561 rest = rest[special + 1 ..];
562 }
563 }
564};
565
566/// Like `mailFrom`, but requests the SMTPUTF8 extension
567/// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the
568/// envelope addresses and message headers may contain UTF-8. Use only when
569/// `Extensions.smtputf8` was advertised.
570pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void {
571 return c.mail(from, .{ .smtputf8 = true });
572}
573
574/// Sends one BDAT chunk (the CHUNKING extension,
575/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the
576/// server's reply. Use only when `Extensions.chunking` was advertised. The
577/// chunk is transmitted verbatim — no dot-stuffing and no line-ending
578/// normalization — so message content must already use CRLF line endings.
579/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator.
580pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void {
581 if (last) {
582 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len});
583 } else {
584 try c.writer.print("BDAT {d}\r\n", .{chunk.len});
585 }
586 try c.writer.writeAll(chunk);
587 try c.writer.flush();
588 _ = try c.expectClass(2);
589}
590
591/// Sends the message content for the current transaction as a single BDAT
592/// chunk. See `bdat` for the transmission caveats.
593pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void {
594 try c.bdat(message_data, true);
595}
596
597/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient,
598/// then DATA. Call after `greet` and `hello`.
599pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) (Error || ArgumentError)!void {
600 try c.mailFrom(from);
601 for (recipients) |recipient| try c.rcptTo(recipient);
602 try c.sendMessage(message_data);
603}
604
605/// Aborts the current mail transaction.
606pub fn rset(c: *Client) Error!void {
607 try c.send("RSET", .{});
608 _ = try c.expectClass(2);
609}
610
611pub fn noop(c: *Client) Error!void {
612 try c.send("NOOP", .{});
613 _ = try c.expectClass(2);
614}
615
616/// Ends the session. The connection should be closed afterwards.
617pub fn quit(c: *Client) Error!void {
618 try c.send("QUIT", .{});
619 _ = try c.expect(221);
620}
621
622fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void {
623 try c.writer.print(fmt ++ protocol.crlf, args);
624 try c.writer.flush();
625}
626
627fn readReply(c: *Client) Error!Reply {
628 const reply = try Reply.read(c.reader, c.reply_buffer);
629 c.last_reply = reply;
630 return reply;
631}
632
633fn expect(c: *Client, code: u16) Error!Reply {
634 const reply = try c.readReply();
635 if (reply.code != code) return error.UnexpectedReply;
636 return reply;
637}
638
639fn expectClass(c: *Client, class: u16) Error!Reply {
640 const reply = try c.readReply();
641 if (reply.code / 100 != class) return error.UnexpectedReply;
642 return reply;
643}
644
645test sendMail {
646 const responses = "220 mx.example.com ESMTP\r\n" ++
647 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++
648 "250 2.1.0 Ok\r\n" ++
649 "250 2.1.5 Ok\r\n" ++
650 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
651 "250 2.0.0 Ok\r\n" ++
652 "221 2.0.0 Bye\r\n";
653 var reader: Io.Reader = .fixed(responses);
654 var out_buf: [1024]u8 = undefined;
655 var writer: Io.Writer = .fixed(&out_buf);
656 var reply_buf: [512]u8 = undefined;
657 var client: Client = .init(&reader, &writer, &reply_buf);
658
659 _ = try client.greet();
660 const ext = try client.hello("client.example.org");
661 try std.testing.expect(ext.pipelining);
662 try std.testing.expect(ext.eight_bit_mime);
663 try std.testing.expect(!ext.starttls);
664 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size);
665
666 try client.sendMail(
667 "alice@example.com",
668 &.{"bob@example.net"},
669 "Subject: hi\r\n\r\n.leading dot\r\n",
670 );
671 try client.quit();
672
673 try std.testing.expectEqualStrings(
674 "EHLO client.example.org\r\n" ++
675 "MAIL FROM:<alice@example.com>\r\n" ++
676 "RCPT TO:<bob@example.net>\r\n" ++
677 "DATA\r\n" ++
678 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++
679 "QUIT\r\n",
680 writer.buffered(),
681 );
682}
683
684test "HELO fallback for non-ESMTP servers" {
685 const responses = "220 old.example.com\r\n" ++
686 "502 command not implemented\r\n" ++
687 "250 old.example.com\r\n";
688 var reader: Io.Reader = .fixed(responses);
689 var out_buf: [256]u8 = undefined;
690 var writer: Io.Writer = .fixed(&out_buf);
691 var reply_buf: [256]u8 = undefined;
692 var client: Client = .init(&reader, &writer, &reply_buf);
693
694 _ = try client.greet();
695 const ext = try client.hello("client.example.org");
696 try std.testing.expectEqual(Extensions{}, ext);
697 try std.testing.expectEqualStrings(
698 "EHLO client.example.org\r\nHELO client.example.org\r\n",
699 writer.buffered(),
700 );
701}
702
703test "rejected recipient surfaces the reply" {
704 const responses = "550 5.1.1 No such user\r\n";
705 var reader: Io.Reader = .fixed(responses);
706 var out_buf: [256]u8 = undefined;
707 var writer: Io.Writer = .fixed(&out_buf);
708 var reply_buf: [256]u8 = undefined;
709 var client: Client = .init(&reader, &writer, &reply_buf);
710
711 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com"));
712 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
713 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text);
714}
715
716test starttls {
717 const plain_responses = "220 mx.example.com ESMTP\r\n" ++
718 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++
719 "220 2.0.0 Ready to start TLS\r\n";
720 var reader: Io.Reader = .fixed(plain_responses);
721 var out_buf: [256]u8 = undefined;
722 var writer: Io.Writer = .fixed(&out_buf);
723 var reply_buf: [256]u8 = undefined;
724 var client: Client = .init(&reader, &writer, &reply_buf);
725
726 _ = try client.greet();
727 const ext = try client.hello("client.example.org");
728 try std.testing.expect(ext.starttls);
729 try client.starttls();
730
731 // Simulate the post-handshake encrypted transport with fresh buffers;
732 // the session must re-EHLO on it.
733 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n";
734 var tls_reader: Io.Reader = .fixed(tls_responses);
735 var tls_out_buf: [256]u8 = undefined;
736 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
737 client.setTransport(&tls_reader, &tls_writer, .encrypted);
738
739 const tls_ext = try client.hello("client.example.org");
740 try std.testing.expect(!tls_ext.starttls);
741 try std.testing.expect(tls_ext.eight_bit_mime);
742 try std.testing.expectEqualStrings(
743 "EHLO client.example.org\r\nSTARTTLS\r\n",
744 writer.buffered(),
745 );
746 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered());
747}
748
749test authPlain {
750 const responses = "235 2.7.0 Accepted\r\n";
751 var reader: Io.Reader = .fixed(responses);
752 var out_buf: [256]u8 = undefined;
753 var writer: Io.Writer = .fixed(&out_buf);
754 var reply_buf: [256]u8 = undefined;
755 var client: Client = .init(&reader, &writer, &reply_buf);
756 client.security = .encrypted; // PLAIN is refused in the clear.
757
758 try client.authPlain("", "user", "pass");
759 // base64("\x00user\x00pass")
760 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered());
761}
762
763test authLogin {
764 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n";
765 var reader: Io.Reader = .fixed(responses);
766 var out_buf: [256]u8 = undefined;
767 var writer: Io.Writer = .fixed(&out_buf);
768 var reply_buf: [256]u8 = undefined;
769 var client: Client = .init(&reader, &writer, &reply_buf);
770 client.security = .encrypted; // LOGIN is refused in the clear.
771
772 try client.authLogin("user", "pass");
773 try std.testing.expectEqualStrings(
774 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n",
775 writer.buffered(),
776 );
777}
778
779test authCramMd5 {
780 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim",
781 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890.
782 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++
783 "235 2.7.0 Accepted\r\n";
784 var reader: Io.Reader = .fixed(responses);
785 var out_buf: [256]u8 = undefined;
786 var writer: Io.Writer = .fixed(&out_buf);
787 var reply_buf: [256]u8 = undefined;
788 var client: Client = .init(&reader, &writer, &reply_buf);
789
790 try client.authCramMd5("tim", "tanstaaftanstaaf");
791 try std.testing.expectEqualStrings(
792 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n",
793 writer.buffered(),
794 );
795}
796
797test authenticate {
798 var out_buf: [256]u8 = undefined;
799 var reply_buf: [256]u8 = undefined;
800 {
801 // Only CRAM-MD5 advertised.
802 const responses = "334 YWJj\r\n235 ok\r\n";
803 var reader: Io.Reader = .fixed(responses);
804 var writer: Io.Writer = .fixed(&out_buf);
805 var client: Client = .init(&reader, &writer, &reply_buf);
806 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p");
807 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n"));
808 }
809 {
810 // Nothing advertised.
811 var reader: Io.Reader = .fixed("");
812 var writer: Io.Writer = .fixed(&out_buf);
813 var client: Client = .init(&reader, &writer, &reply_buf);
814 try std.testing.expectError(
815 error.NoSupportedMechanism,
816 client.authenticate(.{}, "u", "p"),
817 );
818 }
819}
820
821test "mail and rcpt carry the DSN parameters" {
822 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n";
823 var reader: Io.Reader = .fixed(responses);
824 var out_buf: [256]u8 = undefined;
825 var writer: Io.Writer = .fixed(&out_buf);
826 var reply_buf: [64]u8 = undefined;
827 var client: Client = .init(&reader, &writer, &reply_buf);
828
829 try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" });
830 try client.rcpt("bob@example.net", .{
831 .notify = .{ .on = .{ .failure = true, .delay = true } },
832 .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" },
833 });
834 try std.testing.expectEqualStrings(
835 "MAIL FROM:<me@example.com> RET=HDRS ENVID=batch+207\r\n" ++
836 "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n",
837 writer.buffered(),
838 );
839}
840
841test "NOTIFY=NEVER is written on its own" {
842 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n");
843 var out_buf: [128]u8 = undefined;
844 var writer: Io.Writer = .fixed(&out_buf);
845 var reply_buf: [64]u8 = undefined;
846 var client: Client = .init(&reader, &writer, &reply_buf);
847
848 try client.rcpt("bob@example.net", .{ .notify = .never });
849 try std.testing.expectEqualStrings(
850 "RCPT TO:<bob@example.net> NOTIFY=NEVER\r\n",
851 writer.buffered(),
852 );
853}
854
855test "DSN parameter values that exceed their limits are refused" {
856 var reader: Io.Reader = .fixed("");
857 var out_buf: [1024]u8 = undefined;
858 var writer: Io.Writer = .fixed(&out_buf);
859 var reply_buf: [64]u8 = undefined;
860 var client: Client = .init(&reader, &writer, &reply_buf);
861
862 // 34 spaces encode to 102 characters, over the ENVID limit of 100,
863 // though the value itself is well under it.
864 const spaces = " " ** 34;
865 try std.testing.expectError(
866 error.ArgumentTooLong,
867 client.mail("me@example.com", .{ .envid = spaces }),
868 );
869 try std.testing.expectError(error.ArgumentTooLong, client.rcpt("bob@example.net", .{
870 .orcpt = .{ .addr_type = "rfc822", .address = "x" ** 500 },
871 }));
872 // An addr-type is written literally, so it is checked rather than encoded.
873 try std.testing.expectError(error.UnsafeArgument, client.rcpt("bob@example.net", .{
874 .orcpt = .{ .addr_type = "rfc822;evil", .address = "x@example.net" },
875 }));
876 try std.testing.expectEqualStrings("", writer.buffered());
877}
878
879test "hello reports DSN support" {
880 const responses = "250-mx.example.com\r\n250-DSN\r\n250 8BITMIME\r\n";
881 var reader: Io.Reader = .fixed(responses);
882 var out_buf: [128]u8 = undefined;
883 var writer: Io.Writer = .fixed(&out_buf);
884 var reply_buf: [256]u8 = undefined;
885 var client: Client = .init(&reader, &writer, &reply_buf);
886
887 const ext = try client.hello("client.example.org");
888 try std.testing.expect(ext.dsn);
889}
890
891test "an address carrying CRLF cannot inject a command" {
892 // Without the check this would put a second RCPT on the wire.
893 const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net";
894 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
895 var out_buf: [256]u8 = undefined;
896 var writer: Io.Writer = .fixed(&out_buf);
897 var reply_buf: [64]u8 = undefined;
898 var client: Client = .init(&reader, &writer, &reply_buf);
899
900 try std.testing.expectError(error.UnsafeArgument, client.rcptTo(smuggled));
901 try std.testing.expectError(error.UnsafeArgument, client.mailFrom(smuggled));
902 try std.testing.expectError(error.UnsafeArgument, client.mailFromUtf8(smuggled));
903 try std.testing.expectError(error.UnsafeArgument, client.hello("host\r\nQUIT"));
904 // Nothing reached the wire, so the session is still where it was.
905 try std.testing.expectEqualStrings("", writer.buffered());
906}
907
908test "a NUL in a PLAIN field cannot shift the credential boundaries" {
909 var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n");
910 var out_buf: [256]u8 = undefined;
911 var writer: Io.Writer = .fixed(&out_buf);
912 var reply_buf: [64]u8 = undefined;
913 var client: Client = .init(&reader, &writer, &reply_buf);
914 client.security = .encrypted;
915
916 // Decoded by the server as authzid "", username "admin", password "x".
917 try std.testing.expectError(
918 error.UnsafeArgument,
919 client.authPlain("", "user\x00admin\x00x", "pass"),
920 );
921 try std.testing.expectEqualStrings("", writer.buffered());
922}
923
924test "cleartext mechanisms are refused on an unencrypted transport" {
925 var reader: Io.Reader = .fixed("");
926 var out_buf: [256]u8 = undefined;
927 var writer: Io.Writer = .fixed(&out_buf);
928 var reply_buf: [64]u8 = undefined;
929 var client: Client = .init(&reader, &writer, &reply_buf);
930
931 try std.testing.expectError(error.InsecureTransport, client.authPlain("", "u", "p"));
932 try std.testing.expectError(error.InsecureTransport, client.authLogin("u", "p"));
933 // A server offering only those two leaves `authenticate` nothing to use.
934 const cleartext_only: Extensions = .{ .auth = .{ .plain = true, .login = true } };
935 try std.testing.expectError(
936 error.InsecureTransport,
937 client.authenticate(cleartext_only, "u", "p"),
938 );
939 try std.testing.expectEqualStrings("", writer.buffered());
940}
941
942test "authenticate prefers CRAM-MD5 in the clear and PLAIN once encrypted" {
943 const challenge = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++
944 "235 2.7.0 Accepted\r\n";
945 const advertised: Extensions = .{
946 .auth = .{ .plain = true, .login = true, .cram_md5 = true },
947 };
948
949 var reader: Io.Reader = .fixed(challenge);
950 var out_buf: [256]u8 = undefined;
951 var writer: Io.Writer = .fixed(&out_buf);
952 var reply_buf: [256]u8 = undefined;
953 var client: Client = .init(&reader, &writer, &reply_buf);
954
955 // In the clear: the one mechanism that keeps the password off the wire.
956 try client.authenticate(advertised, "tim", "tanstaaftanstaaf");
957 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n"));
958
959 var tls_reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n");
960 var tls_out_buf: [256]u8 = undefined;
961 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
962 client.setTransport(&tls_reader, &tls_writer, .encrypted);
963
964 try client.authenticate(advertised, "user", "pass");
965 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", tls_writer.buffered());
966}
967
968test "allow_cleartext_auth is the way past the refusal" {
969 var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n");
970 var out_buf: [256]u8 = undefined;
971 var writer: Io.Writer = .fixed(&out_buf);
972 var reply_buf: [64]u8 = undefined;
973 var client: Client = .init(&reader, &writer, &reply_buf);
974 client.allow_cleartext_auth = true;
975
976 try client.authPlain("", "user", "pass");
977 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered());
978}
979
980test "rejected credentials surface AuthenticationFailed" {
981 const responses = "535 5.7.8 Authentication credentials invalid\r\n";
982 var reader: Io.Reader = .fixed(responses);
983 var out_buf: [256]u8 = undefined;
984 var writer: Io.Writer = .fixed(&out_buf);
985 var reply_buf: [256]u8 = undefined;
986 var client: Client = .init(&reader, &writer, &reply_buf);
987 client.security = .encrypted;
988
989 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p"));
990 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code);
991}
992
993test hello {
994 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n";
995 var reader: Io.Reader = .fixed(responses);
996 var out_buf: [256]u8 = undefined;
997 var writer: Io.Writer = .fixed(&out_buf);
998 var reply_buf: [256]u8 = undefined;
999 var client: Client = .init(&reader, &writer, &reply_buf);
1000
1001 const ext = try client.hello("c.example");
1002 try std.testing.expect(ext.auth.plain);
1003 try std.testing.expect(ext.auth.login);
1004 try std.testing.expect(ext.auth.cram_md5);
1005 try std.testing.expect(ext.auth.any());
1006}
1007
1008test init {
1009 var reader: Io.Reader = .fixed("");
1010 var out_buf: [16]u8 = undefined;
1011 var writer: Io.Writer = .fixed(&out_buf);
1012 var reply_buf: [128]u8 = undefined;
1013 const client: Client = .init(&reader, &writer, &reply_buf);
1014 try std.testing.expect(client.last_reply == null);
1015}
1016
1017test greet {
1018 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n");
1019 var out_buf: [16]u8 = undefined;
1020 var writer: Io.Writer = .fixed(&out_buf);
1021 var reply_buf: [128]u8 = undefined;
1022 var client: Client = .init(&reader, &writer, &reply_buf);
1023
1024 const reply = try client.greet();
1025 try std.testing.expectEqual(@as(u16, 220), reply.code);
1026 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text);
1027}
1028
1029test setTransport {
1030 var reader: Io.Reader = .fixed("");
1031 var out_buf: [16]u8 = undefined;
1032 var writer: Io.Writer = .fixed(&out_buf);
1033 var reply_buf: [64]u8 = undefined;
1034 var client: Client = .init(&reader, &writer, &reply_buf);
1035
1036 // After a TLS handshake, point the session at the encrypted streams.
1037 var tls_reader: Io.Reader = .fixed("");
1038 var tls_out_buf: [16]u8 = undefined;
1039 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
1040 client.setTransport(&tls_reader, &tls_writer, .encrypted);
1041 try std.testing.expectEqual(&tls_reader, client.reader);
1042 try std.testing.expectEqual(&tls_writer, client.writer);
1043 try std.testing.expectEqual(Security.encrypted, client.security);
1044}
1045
1046test mailFrom {
1047 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
1048 var out_buf: [64]u8 = undefined;
1049 var writer: Io.Writer = .fixed(&out_buf);
1050 var reply_buf: [64]u8 = undefined;
1051 var client: Client = .init(&reader, &writer, &reply_buf);
1052
1053 try client.mailFrom("alice@example.com");
1054 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered());
1055}
1056
1057test rcptTo {
1058 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n");
1059 var out_buf: [64]u8 = undefined;
1060 var writer: Io.Writer = .fixed(&out_buf);
1061 var reply_buf: [64]u8 = undefined;
1062 var client: Client = .init(&reader, &writer, &reply_buf);
1063
1064 try client.rcptTo("bob@example.net");
1065 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered());
1066}
1067
1068test sendMessage {
1069 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n");
1070 var out_buf: [128]u8 = undefined;
1071 var writer: Io.Writer = .fixed(&out_buf);
1072 var reply_buf: [64]u8 = undefined;
1073 var client: Client = .init(&reader, &writer, &reply_buf);
1074
1075 try client.sendMessage("Subject: hi\n\nhello\n");
1076 try std.testing.expectEqualStrings(
1077 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n",
1078 writer.buffered(),
1079 );
1080}
1081
1082test rset {
1083 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
1084 var out_buf: [16]u8 = undefined;
1085 var writer: Io.Writer = .fixed(&out_buf);
1086 var reply_buf: [64]u8 = undefined;
1087 var client: Client = .init(&reader, &writer, &reply_buf);
1088
1089 try client.rset();
1090 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered());
1091}
1092
1093test noop {
1094 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
1095 var out_buf: [16]u8 = undefined;
1096 var writer: Io.Writer = .fixed(&out_buf);
1097 var reply_buf: [64]u8 = undefined;
1098 var client: Client = .init(&reader, &writer, &reply_buf);
1099
1100 try client.noop();
1101 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered());
1102}
1103
1104test quit {
1105 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n");
1106 var out_buf: [16]u8 = undefined;
1107 var writer: Io.Writer = .fixed(&out_buf);
1108 var reply_buf: [64]u8 = undefined;
1109 var client: Client = .init(&reader, &writer, &reply_buf);
1110
1111 try client.quit();
1112 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered());
1113}
1114
1115test data {
1116 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
1117 var out_buf: [256]u8 = undefined;
1118 var writer: Io.Writer = .fixed(&out_buf);
1119 var reply_buf: [64]u8 = undefined;
1120 var client: Client = .init(&reader, &writer, &reply_buf);
1121
1122 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily.
1123 var data_writer = try client.data();
1124 try data_writer.interface.writeAll("Subject: chunked\n\nfirst");
1125 try data_writer.interface.writeAll(" second\r");
1126 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r");
1127 try data_writer.interface.writeAll("\n");
1128 try data_writer.interface.writeAll(".x\nend");
1129 try data_writer.end();
1130
1131 try std.testing.expectEqualStrings(
1132 "DATA\r\n" ++
1133 "Subject: chunked\r\n" ++
1134 "\r\n" ++
1135 "first second\r\n" ++
1136 "..needs stuffing\r\n" ++
1137 "split\r\n" ++
1138 "..x\r\n" ++
1139 "end\r\n" ++
1140 ".\r\n",
1141 writer.buffered(),
1142 );
1143}
1144
1145test sendMessageReader {
1146 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n");
1147 var out_buf: [128]u8 = undefined;
1148 var writer: Io.Writer = .fixed(&out_buf);
1149 var reply_buf: [64]u8 = undefined;
1150 var client: Client = .init(&reader, &writer, &reply_buf);
1151
1152 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n");
1153 try client.sendMessageReader(&message);
1154 try std.testing.expectEqualStrings(
1155 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n",
1156 writer.buffered(),
1157 );
1158}
1159
1160test "fuzz client against arbitrary server replies" {
1161 try std.testing.fuzz({}, fuzzClientReplies, .{});
1162}
1163
1164fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void {
1165 _ = context;
1166 var input_buf: [1024]u8 = undefined;
1167 const input = input_buf[0..smith.value(u10)];
1168 smith.bytes(input);
1169
1170 var reader: Io.Reader = .fixed(input);
1171 var out_buf: [4096]u8 = undefined;
1172 var writer: Io.Writer = .fixed(&out_buf);
1173 var reply_buf: [256]u8 = undefined;
1174 var client: Client = .init(&reader, &writer, &reply_buf);
1175
1176 // Whatever the "server" says, the client must fail cleanly, never crash.
1177 _ = client.greet() catch return;
1178 const extensions = client.hello("fuzz.example.org") catch return;
1179 client.authenticate(extensions, "user", "password") catch {};
1180 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {};
1181 client.quit() catch {};
1182}
1183
1184test "fuzz DataWriter equivalence with writeStuffed" {
1185 try std.testing.fuzz({}, fuzzDataWriter, .{});
1186}
1187
1188fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void {
1189 _ = context;
1190 var message_buf: [1024]u8 = undefined;
1191 const message = message_buf[0..smith.value(u10)];
1192 smith.bytes(message);
1193
1194 // Reference implementation: slice-based stuffing.
1195 var expected_buf: [2100]u8 = undefined;
1196 var expected: Io.Writer = .fixed(&expected_buf);
1197 try protocol.writeStuffed(&expected, message);
1198
1199 // Streaming implementation, with fuzzer-chosen chunk boundaries.
1200 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n");
1201 var out_buf: [2200]u8 = undefined;
1202 var writer: Io.Writer = .fixed(&out_buf);
1203 var reply_buf: [64]u8 = undefined;
1204 var client: Client = .init(&responses, &writer, &reply_buf);
1205
1206 var data_writer = try client.data();
1207 var rest: []const u8 = message;
1208 while (rest.len > 0) {
1209 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len));
1210 try data_writer.interface.writeAll(rest[0..n]);
1211 rest = rest[n..];
1212 }
1213 try data_writer.end();
1214
1215 const written = writer.buffered();
1216 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n"));
1217 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n"));
1218 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len];
1219 try std.testing.expectEqualStrings(expected.buffered(), stuffed);
1220}
1221
1222test Extensions {
1223 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 };
1224 try std.testing.expect(extensions.pipelining);
1225 try std.testing.expect(!extensions.starttls);
1226 try std.testing.expect(!extensions.auth.any());
1227 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size);
1228}
1229
1230test bdat {
1231 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n");
1232 var out_buf: [128]u8 = undefined;
1233 var writer: Io.Writer = .fixed(&out_buf);
1234 var reply_buf: [64]u8 = undefined;
1235 var client: Client = .init(&reader, &writer, &reply_buf);
1236
1237 try client.bdat("Subject: hi\r\n\r\n", false);
1238 try client.bdat("body\r\n", true);
1239 try std.testing.expectEqualStrings(
1240 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n",
1241 writer.buffered(),
1242 );
1243}
1244
1245test sendMessageChunked {
1246 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
1247 var out_buf: [128]u8 = undefined;
1248 var writer: Io.Writer = .fixed(&out_buf);
1249 var reply_buf: [64]u8 = undefined;
1250 var client: Client = .init(&reader, &writer, &reply_buf);
1251
1252 // Raw transmission: the leading dot is not stuffed.
1253 try client.sendMessageChunked(".raw\r\n");
1254 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered());
1255}
1256
1257test mailFromUtf8 {
1258 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
1259 var out_buf: [64]u8 = undefined;
1260 var writer: Io.Writer = .fixed(&out_buf);
1261 var reply_buf: [64]u8 = undefined;
1262 var client: Client = .init(&reader, &writer, &reply_buf);
1263
1264 try client.mailFromUtf8("böb@example.com");
1265 try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered());
1266}