An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-smtp / src / Client.zig
59 kB 1450 lines
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}