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