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