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
86 kB 2044 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 /// `AUTH=` 438 /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 439 /// who originally submitted this message, for a relay carrying it on 440 /// behalf of somebody else. Needs the server to advertise AUTH. 441 /// 442 /// `.unknown` sends `<>`, which is what a relay should send when it 443 /// cannot vouch for the submitter — RFC 4954 asks for that rather than 444 /// leaving the parameter off, because a server receiving no parameter 445 /// learns nothing while one receiving `<>` learns that the peer 446 /// considered the question. The mailbox form is xtext-encoded, so any 447 /// bytes are safe to pass, and is rejected with `error.ArgumentTooLong` 448 /// past the 500 characters RFC 4954 makes room for. 449 /// 450 /// Being believed is another matter: the receiving server disregards 451 /// this unless this client has authenticated to it. 452 auth: ?protocol.Submitter = null, 453 /// DSN `RET=`: how much of the message a failure report should carry 454 /// back. Needs `Extensions.dsn`. 455 ret: ?protocol.Ret = null, 456 /// DSN `ENVID=`: an identifier quoted back in any report about this 457 /// message. Sent xtext-encoded, so any bytes are safe to pass, and 458 /// rejected with `error.ArgumentTooLong` if the encoded form exceeds the 459 /// 100 characters RFC 3461 allows. Needs `Extensions.dsn`. 460 envid: ?[]const u8 = null, 461}; 462 463/// Parameters for the RCPT command, which in this library means the DSN 464/// ones. Needs `Extensions.dsn`; see `MailOptions`. 465pub const RcptOptions = struct { 466 /// DSN `NOTIFY=`: when the sender wants to hear about this recipient. 467 /// Leave null to let the receiver apply its default. 468 notify: ?protocol.Notify = null, 469 /// DSN `ORCPT=`: the address the message was originally addressed to, 470 /// carried through aliasing so a report can name what the sender wrote. 471 /// The address is sent xtext-encoded; the `addr_type` is not, so it is 472 /// checked instead, and the whole parameter is capped at the 500 473 /// characters RFC 3461 allows. 474 orcpt: ?protocol.Orcpt = null, 475}; 476 477/// Starts a mail transaction. An empty `from` sends the null reverse-path 478/// (`MAIL FROM:<>`), used for bounces. 479/// 480/// Returns `error.UnsafeArgument` for an address that would break out of 481/// the command line; see `protocol.isSafeArgument`. 482pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void { 483 return c.mail(from, .{}); 484} 485 486/// `mailFrom` with ESMTP parameters. 487pub fn mail(c: *Client, from: []const u8, options: MailOptions) (Error || ArgumentError)!void { 488 try c.checkMail(from, options); 489 try c.writeMail(from, options); 490 try c.writer.flush(); 491 _ = try c.expectClass(2); 492 c.accepted_recipients = 0; 493 c.binary = options.body == .binary_mime; 494} 495 496/// Everything about a MAIL command that can be refused before it is 497/// written. Split out so that a pipelined group can be validated in full 498/// before any of it goes on the wire. 499fn checkMail(c: *Client, from: []const u8, options: MailOptions) ArgumentError!void { 500 _ = c; 501 if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 502 if (options.envid) |envid| { 503 if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len) 504 return error.ArgumentTooLong; 505 } 506 if (options.auth) |auth| switch (auth) { 507 .unknown => {}, 508 .mailbox => |mailbox| { 509 if (mailbox.len == 0) return error.UnsafeArgument; 510 if (protocol.xtextEncodedLen(mailbox) > protocol.Submitter.max_len) 511 return error.ArgumentTooLong; 512 }, 513 }; 514} 515 516/// Writes MAIL without flushing or reading its reply. 517fn writeMail(c: *Client, from: []const u8, options: MailOptions) Error!void { 518 try c.writer.print("MAIL FROM:<{s}>", .{from}); 519 if (options.auth) |auth| try c.writer.print(" AUTH={f}", .{auth}); 520 if (options.body) |body| try c.writer.print(" BODY={f}", .{body}); 521 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); 522 if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret}); 523 if (options.envid) |envid| { 524 try c.writer.writeAll(" ENVID="); 525 try protocol.writeXtext(c.writer, envid); 526 } 527 try c.writer.writeAll(protocol.crlf); 528} 529 530/// Adds a recipient to the current transaction. Returns 531/// `error.UnsafeArgument` for an address that would break out of the 532/// command line; see `protocol.isSafeArgument`. 533pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void { 534 return c.rcpt(to, .{}); 535} 536 537/// `rcptTo` with ESMTP parameters. 538pub fn rcpt(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!void { 539 const code = try c.rcptCode(to, options); 540 if (code / 100 != 2) return error.UnexpectedReply; 541} 542 543/// `rcpt`, but a refusal is the returned code rather than an error. The 544/// reply is in `last_reply` either way. 545fn rcptCode(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!u16 { 546 try c.checkRcpt(to, options); 547 try c.writeRcpt(to, options); 548 try c.writer.flush(); 549 const reply = try c.readReply(); 550 if (reply.isPositiveCompletion()) c.accepted_recipients += 1; 551 return reply.code; 552} 553 554/// Everything about a RCPT command that can be refused before it is 555/// written; see `checkMail`. 556fn checkRcpt(c: *Client, to: []const u8, options: RcptOptions) ArgumentError!void { 557 _ = c; 558 if (!protocol.isSafeArgument(to)) return error.UnsafeArgument; 559 if (options.orcpt) |orcpt| { 560 if (orcpt.addr_type.len == 0 or !protocol.isSafeArgument(orcpt.addr_type) or 561 std.mem.findScalar(u8, orcpt.addr_type, ';') != null) 562 return error.UnsafeArgument; 563 if (orcpt.addr_type.len + 1 + protocol.xtextEncodedLen(orcpt.address) > protocol.Orcpt.max_len) 564 return error.ArgumentTooLong; 565 } 566} 567 568/// Writes RCPT without flushing or reading its reply. 569fn writeRcpt(c: *Client, to: []const u8, options: RcptOptions) Error!void { 570 try c.writer.print("RCPT TO:<{s}>", .{to}); 571 if (options.notify) |notify| try c.writer.print(" NOTIFY={f}", .{notify}); 572 if (options.orcpt) |orcpt| try c.writer.print(" ORCPT={f}", .{orcpt}); 573 try c.writer.writeAll(protocol.crlf); 574} 575 576pub const EnvelopeOptions = struct { 577 /// Parameters for the MAIL command. 578 mail: MailOptions = .{}, 579 /// Parameters applied to every RCPT command. Per-recipient parameters 580 /// need `rcpt` called individually. 581 rcpt: RcptOptions = .{}, 582}; 583 584/// Sends MAIL FROM and one RCPT TO per recipient, then reads every reply, 585/// and returns how many recipients the server accepted. 586/// 587/// When the server advertised PIPELINING the commands go out as a single 588/// group and their replies are read together, which turns an envelope of 589/// *n* recipients from *n*+1 round trips into one. Otherwise each command 590/// waits for its own reply, and the result is the same either way. 591/// 592/// DATA is deliberately not part of the group, though RFC 2920 §3.1 allows 593/// it as the last command of one. Once a server has answered DATA with 354 594/// the transaction is committed, and a caller that wanted all-or-nothing 595/// delivery has no way back: the only ways out of the data phase are to 596/// send the message or to send an empty one to whichever recipients *were* 597/// accepted. Stopping the group before DATA keeps that decision with the 598/// caller, and costs one round trip out of the *n*+1 saved. 599/// 600/// `codes`, when given, must have room for `recipients.len` entries and 601/// receives each RCPT reply code in order. Codes rather than replies 602/// because the replies share one buffer: by the time the group has been 603/// read, only the last one's text still exists. 604/// 605/// A refused MAIL FROM is `error.UnexpectedReply`, with the reply in 606/// `last_reply` and the rest of the group drained. Refused *recipients* 607/// are not an error — with several of them the caller is the one who can 608/// say whether what remains is worth sending — so compare the returned 609/// count against `recipients.len`. 610pub fn envelope( 611 c: *Client, 612 from: []const u8, 613 recipients: []const []const u8, 614 codes: ?[]u16, 615 options: EnvelopeOptions, 616) (Error || ArgumentError)!usize { 617 if (codes) |slice| std.debug.assert(slice.len >= recipients.len); 618 if (!c.pipelining) { 619 try c.mail(from, options.mail); 620 var accepted: usize = 0; 621 for (recipients, 0..) |recipient, index| { 622 const code = try c.rcptCode(recipient, options.rcpt); 623 if (codes) |slice| slice[index] = code; 624 if (code / 100 == 2) accepted += 1; 625 } 626 return accepted; 627 } 628 629 // Everything is validated before anything is written: a group that 630 // turned out to be unsendable halfway through would leave the session 631 // holding a partial command. 632 try c.checkMail(from, options.mail); 633 for (recipients) |recipient| try c.checkRcpt(recipient, options.rcpt); 634 635 try c.writeMail(from, options.mail); 636 for (recipients) |recipient| try c.writeRcpt(recipient, options.rcpt); 637 try c.writer.flush(); 638 639 // RFC 2920 §3.1: every status in the group must be checked, and all of 640 // them must be read whatever the first one said, or the replies still 641 // queued would be mistaken for the answers to whatever comes next. 642 const mail_reply = try c.readReply(); 643 const mail_ok = mail_reply.isPositiveCompletion(); 644 if (mail_ok) { 645 c.accepted_recipients = 0; 646 c.binary = options.mail.body == .binary_mime; 647 } 648 649 var accepted: usize = 0; 650 for (0..recipients.len) |index| { 651 if (!mail_ok) { 652 // The MAIL reply is the one worth keeping, so the rest of the 653 // group is drained without disturbing it. 654 try c.discardReply(); 655 if (codes) |slice| slice[index] = 0; 656 continue; 657 } 658 const reply = try c.readReply(); 659 if (codes) |slice| slice[index] = reply.code; 660 if (reply.isPositiveCompletion()) { 661 accepted += 1; 662 c.accepted_recipients += 1; 663 } 664 } 665 if (!mail_ok) return error.UnexpectedReply; 666 return accepted; 667} 668 669/// Sends the message content for the current transaction (DATA). Line 670/// endings in `data` are normalized to CRLF and leading dots are stuffed. 671pub fn sendMessage(c: *Client, message_data: []const u8) Error!void { 672 var data_writer = try c.data(); 673 try data_writer.interface.writeAll(message_data); 674 try data_writer.end(); 675} 676 677/// Streams the message content for the current transaction from `message` 678/// until end of stream. Line endings are normalized to CRLF and leading 679/// dots stuffed; nothing is buffered beyond the transport writer, so lines 680/// and messages of any length work. 681pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void { 682 var data_writer = try c.data(); 683 while (true) { 684 const chunk = message.peekGreedy(1) catch |err| switch (err) { 685 error.EndOfStream => break, 686 error.ReadFailed => return error.ReadFailed, 687 }; 688 try data_writer.interface.writeAll(chunk); 689 message.toss(chunk.len); 690 } 691 try data_writer.end(); 692} 693 694/// Starts the DATA phase for streaming a message body: write the content 695/// through the returned writer's `interface`, then call `end`. Line endings 696/// are normalized to CRLF and leading dots stuffed as the data flows. 697pub fn data(c: *Client) Error!DataWriter { 698 if (c.binary) return error.BinaryRequiresChunking; 699 try c.send("DATA", .{}); 700 _ = try c.expect(354); 701 return .{ 702 .client = c, 703 .interface = .{ 704 .buffer = &.{}, 705 .vtable = &.{ .drain = DataWriter.drain }, 706 }, 707 }; 708} 709 710/// Streaming writer for a message body; obtained from `data`. The dot 711/// stuffing and CRLF normalization state lives here, so chunks may split 712/// lines (and even CRLF pairs) at any byte boundary. 713pub const DataWriter = struct { 714 client: *Client, 715 interface: Io.Writer, 716 at_line_start: bool = true, 717 /// A '\r' was seen but not yet emitted; whether it is a line ending 718 /// depends on the next byte. 719 pending_cr: bool = false, 720 721 /// Terminates the message (adding a final CRLF if the content did not 722 /// end with one, then ".\r\n") and reads the server's verdict. 723 /// 724 /// In LMTP that is one verdict per accepted recipient rather than one 725 /// for the message. All of them are read — leaving any unread would 726 /// desynchronize the session — and a non-2xx among them becomes 727 /// `error.RecipientRejected`, with that first refusal left in 728 /// `last_reply`: once one has been read, the rest of the group is 729 /// drained without disturbing it. Which *recipient* it belonged to is 730 /// only available from `endResults`, which is the whole reason for 731 /// speaking LMTP and the way to see every verdict. 732 pub fn end(dw: *DataWriter) Error!void { 733 var verdicts = try dw.endResults(); 734 const per_recipient = verdicts.remaining > 1; 735 while (try verdicts.next()) |reply| { 736 if (reply.isPositiveCompletion()) continue; 737 while (verdicts.remaining > 0) : (verdicts.remaining -= 1) 738 try dw.client.discardReply(); 739 // With one reply there was never any ambiguity to begin with. 740 return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; 741 } 742 } 743 744 /// Terminates the message and returns the verdicts to read: one in 745 /// SMTP, one per accepted recipient in LMTP, in the order the RCPT 746 /// commands were issued. Every one of them must be read before the 747 /// session is used again. 748 pub fn endResults(dw: *DataWriter) Error!Results { 749 try dw.interface.flush(); 750 const c = dw.client; 751 if (dw.pending_cr) { 752 // A trailing bare CR counts as a line ending, matching 753 // `protocol.writeStuffed`. 754 dw.pending_cr = false; 755 dw.at_line_start = true; 756 try c.writer.writeAll(protocol.crlf); 757 } 758 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf); 759 try c.writer.writeAll("." ++ protocol.crlf); 760 try c.writer.flush(); 761 return c.results(); 762 } 763 764 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { 765 const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w)); 766 try dw.writeChunk(w.buffered()); 767 w.end = 0; 768 if (chunks.len == 0) return 0; 769 var n: usize = 0; 770 for (chunks[0 .. chunks.len - 1]) |bytes| { 771 try dw.writeChunk(bytes); 772 n += bytes.len; 773 } 774 const pattern = chunks[chunks.len - 1]; 775 for (0..splat) |_| { 776 try dw.writeChunk(pattern); 777 n += pattern.len; 778 } 779 return n; 780 } 781 782 test end { 783 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 784 var out_buf: [64]u8 = undefined; 785 var writer: Io.Writer = .fixed(&out_buf); 786 var reply_buf: [64]u8 = undefined; 787 var client: Client = .init(&reader, &writer, &reply_buf); 788 789 var data_writer = try client.data(); 790 try data_writer.interface.writeAll("no trailing newline"); 791 try data_writer.end(); // adds the final CRLF, sends ".", reads 250 792 try std.testing.expectEqualStrings( 793 "DATA\r\nno trailing newline\r\n.\r\n", 794 writer.buffered(), 795 ); 796 } 797 798 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void { 799 const out = dw.client.writer; 800 var rest = bytes; 801 while (rest.len > 0) { 802 if (dw.pending_cr) { 803 dw.pending_cr = false; 804 if (rest[0] == '\n') { 805 try out.writeAll(protocol.crlf); 806 dw.at_line_start = true; 807 rest = rest[1..]; 808 continue; 809 } 810 // A bare CR mid-line passes through untouched. 811 try out.writeByte('\r'); 812 dw.at_line_start = false; 813 } 814 if (dw.at_line_start and rest[0] == '.') { 815 try out.writeAll(".."); 816 dw.at_line_start = false; 817 rest = rest[1..]; 818 continue; 819 } 820 const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse { 821 try out.writeAll(rest); 822 dw.at_line_start = false; 823 break; 824 }; 825 if (special > 0) { 826 try out.writeAll(rest[0..special]); 827 dw.at_line_start = false; 828 } 829 switch (rest[special]) { 830 '\r' => dw.pending_cr = true, 831 '\n' => { 832 try out.writeAll(protocol.crlf); 833 dw.at_line_start = true; 834 }, 835 else => unreachable, 836 } 837 rest = rest[special + 1 ..]; 838 } 839 } 840}; 841 842/// The verdicts a server sends at the end of a message: one in SMTP, one 843/// per accepted recipient in LMTP. Each `next` overwrites the client's 844/// reply buffer, so a reply must be used before the following call. 845pub const Results = struct { 846 client: *Client, 847 remaining: usize, 848 /// The index into the recipients accepted since the last MAIL that the 849 /// next reply belongs to. Meaningful in LMTP, where replies come back 850 /// in the order the RCPT commands were issued. 851 index: usize = 0, 852 853 pub fn next(r: *Results) Error!?Reply { 854 if (r.remaining == 0) return null; 855 r.remaining -= 1; 856 r.index += 1; 857 return try r.client.readReply(); 858 } 859}; 860 861/// The verdicts still to be read after a message has been terminated. Use 862/// `DataWriter.endResults`, which sends the terminator first; this is the 863/// reading half on its own, for a caller that framed the message itself. 864pub fn results(c: *Client) Results { 865 return .{ 866 .client = c, 867 .remaining = switch (c.mode) { 868 .smtp => 1, 869 .lmtp => c.accepted_recipients, 870 }, 871 }; 872} 873 874/// Like `mailFrom`, but requests the SMTPUTF8 extension 875/// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the 876/// envelope addresses and message headers may contain UTF-8. Use only when 877/// `Extensions.smtputf8` was advertised. 878pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void { 879 return c.mail(from, .{ .smtputf8 = true }); 880} 881 882/// Sends one BDAT chunk (the CHUNKING extension, 883/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the 884/// server's reply. Use only when `Extensions.chunking` was advertised. The 885/// chunk is transmitted verbatim — no dot-stuffing and no line-ending 886/// normalization — so message content must already use CRLF line endings. 887/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator. 888pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void { 889 if (last) { 890 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len}); 891 } else { 892 try c.writer.print("BDAT {d}\r\n", .{chunk.len}); 893 } 894 try c.writer.writeAll(chunk); 895 try c.writer.flush(); 896 if (!last) { 897 _ = try c.expectClass(2); 898 return; 899 } 900 // RFC 2033 gives the LAST chunk the same per-recipient answer that the 901 // final dot of DATA gets, so it is read the same way. 902 var chunk_results = c.results(); 903 const per_recipient = chunk_results.remaining > 1; 904 while (try chunk_results.next()) |reply| { 905 if (reply.isPositiveCompletion()) continue; 906 while (chunk_results.remaining > 0) : (chunk_results.remaining -= 1) 907 try c.discardReply(); 908 return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; 909 } 910} 911 912/// Sends the message content for the current transaction as a single BDAT 913/// chunk. See `bdat` for the transmission caveats. 914pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void { 915 try c.bdat(message_data, true); 916} 917 918/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 919/// then DATA. Call after `greet` and `hello`. 920pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) (Error || ArgumentError)!void { 921 const accepted = try c.envelope(from, recipients, null, .{}); 922 if (accepted != recipients.len) { 923 // All or nothing, so nothing: the envelope is abandoned before DATA 924 // rather than delivering to the subset that was accepted. A caller 925 // who wants the subset calls `envelope` and decides for itself. 926 c.rset() catch {}; 927 return error.UnexpectedReply; 928 } 929 try c.sendMessage(message_data); 930} 931 932/// Aborts the current mail transaction. 933pub fn rset(c: *Client) Error!void { 934 try c.send("RSET", .{}); 935 _ = try c.expectClass(2); 936 c.accepted_recipients = 0; 937 c.binary = false; 938} 939 940pub fn noop(c: *Client) Error!void { 941 try c.send("NOOP", .{}); 942 _ = try c.expectClass(2); 943} 944 945/// Ends the session. The connection should be closed afterwards. 946pub fn quit(c: *Client) Error!void { 947 try c.send("QUIT", .{}); 948 _ = try c.expect(221); 949} 950 951fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 952 try c.writer.print(fmt ++ protocol.crlf, args); 953 try c.writer.flush(); 954} 955 956fn readReply(c: *Client) Error!Reply { 957 const reply = try Reply.read(c.reader, c.reply_buffer); 958 c.last_reply = reply; 959 return reply; 960} 961 962/// Reads one reply and throws it away, without touching `reply_buffer`. 963/// 964/// That is the point of it: the replies to a pipelined group all arrive 965/// before any of them can be acted on, and each one read into 966/// `reply_buffer` overwrites the last. Draining the rest of a group this 967/// way leaves the failing reply that was already read intact in 968/// `last_reply`, so an error can still say what went wrong. 969fn discardReply(c: *Client) Error!void { 970 while (true) { 971 const line = protocol.readLine(c.reader) catch |err| switch (err) { 972 error.LineTooLong => return error.ReplyTooLong, 973 error.ReadFailed, error.EndOfStream => |e| return e, 974 }; 975 if (line.len < 4) return if (line.len == 3) {} else error.InvalidReply; 976 // A '-' in the fourth column continues the reply; a space ends it. 977 switch (line[3]) { 978 '-' => continue, 979 ' ' => return, 980 else => return error.InvalidReply, 981 } 982 } 983} 984 985fn expect(c: *Client, code: u16) Error!Reply { 986 const reply = try c.readReply(); 987 if (reply.code != code) return error.UnexpectedReply; 988 return reply; 989} 990 991fn expectClass(c: *Client, class: u16) Error!Reply { 992 const reply = try c.readReply(); 993 if (reply.code / 100 != class) return error.UnexpectedReply; 994 return reply; 995} 996 997test sendMail { 998 const responses = "220 mx.example.com ESMTP\r\n" ++ 999 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 1000 "250 2.1.0 Ok\r\n" ++ 1001 "250 2.1.5 Ok\r\n" ++ 1002 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1003 "250 2.0.0 Ok\r\n" ++ 1004 "221 2.0.0 Bye\r\n"; 1005 var reader: Io.Reader = .fixed(responses); 1006 var out_buf: [1024]u8 = undefined; 1007 var writer: Io.Writer = .fixed(&out_buf); 1008 var reply_buf: [512]u8 = undefined; 1009 var client: Client = .init(&reader, &writer, &reply_buf); 1010 1011 _ = try client.greet(); 1012 const ext = try client.hello("client.example.org"); 1013 try std.testing.expect(ext.pipelining); 1014 try std.testing.expect(ext.eight_bit_mime); 1015 try std.testing.expect(!ext.starttls); 1016 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 1017 1018 try client.sendMail( 1019 "alice@example.com", 1020 &.{"bob@example.net"}, 1021 "Subject: hi\r\n\r\n.leading dot\r\n", 1022 ); 1023 try client.quit(); 1024 1025 try std.testing.expectEqualStrings( 1026 "EHLO client.example.org\r\n" ++ 1027 "MAIL FROM:<alice@example.com>\r\n" ++ 1028 "RCPT TO:<bob@example.net>\r\n" ++ 1029 "DATA\r\n" ++ 1030 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 1031 "QUIT\r\n", 1032 writer.buffered(), 1033 ); 1034} 1035 1036test "HELO fallback for non-ESMTP servers" { 1037 const responses = "220 old.example.com\r\n" ++ 1038 "502 command not implemented\r\n" ++ 1039 "250 old.example.com\r\n"; 1040 var reader: Io.Reader = .fixed(responses); 1041 var out_buf: [256]u8 = undefined; 1042 var writer: Io.Writer = .fixed(&out_buf); 1043 var reply_buf: [256]u8 = undefined; 1044 var client: Client = .init(&reader, &writer, &reply_buf); 1045 1046 _ = try client.greet(); 1047 const ext = try client.hello("client.example.org"); 1048 try std.testing.expectEqual(Extensions{}, ext); 1049 try std.testing.expectEqualStrings( 1050 "EHLO client.example.org\r\nHELO client.example.org\r\n", 1051 writer.buffered(), 1052 ); 1053} 1054 1055test "rejected recipient surfaces the reply" { 1056 const responses = "550 5.1.1 No such user\r\n"; 1057 var reader: Io.Reader = .fixed(responses); 1058 var out_buf: [256]u8 = undefined; 1059 var writer: Io.Writer = .fixed(&out_buf); 1060 var reply_buf: [256]u8 = undefined; 1061 var client: Client = .init(&reader, &writer, &reply_buf); 1062 1063 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 1064 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 1065 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 1066} 1067 1068test starttls { 1069 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 1070 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 1071 "220 2.0.0 Ready to start TLS\r\n"; 1072 var reader: Io.Reader = .fixed(plain_responses); 1073 var out_buf: [256]u8 = undefined; 1074 var writer: Io.Writer = .fixed(&out_buf); 1075 var reply_buf: [256]u8 = undefined; 1076 var client: Client = .init(&reader, &writer, &reply_buf); 1077 1078 _ = try client.greet(); 1079 const ext = try client.hello("client.example.org"); 1080 try std.testing.expect(ext.starttls); 1081 try client.starttls(); 1082 1083 // Simulate the post-handshake encrypted transport with fresh buffers; 1084 // the session must re-EHLO on it. 1085 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 1086 var tls_reader: Io.Reader = .fixed(tls_responses); 1087 var tls_out_buf: [256]u8 = undefined; 1088 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 1089 client.setTransport(&tls_reader, &tls_writer, .encrypted); 1090 1091 const tls_ext = try client.hello("client.example.org"); 1092 try std.testing.expect(!tls_ext.starttls); 1093 try std.testing.expect(tls_ext.eight_bit_mime); 1094 try std.testing.expectEqualStrings( 1095 "EHLO client.example.org\r\nSTARTTLS\r\n", 1096 writer.buffered(), 1097 ); 1098 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 1099} 1100 1101test "BODY=BINARYMIME commits the transaction to BDAT" { 1102 const responses = "250-mx.example.com\r\n250-CHUNKING\r\n250 BINARYMIME\r\n" ++ 1103 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.0.0 Ok\r\n"; 1104 var reader: Io.Reader = .fixed(responses); 1105 var out_buf: [512]u8 = undefined; 1106 var writer: Io.Writer = .fixed(&out_buf); 1107 var reply_buf: [256]u8 = undefined; 1108 var client: Client = .init(&reader, &writer, &reply_buf); 1109 1110 const ext = try client.hello("client.example.org"); 1111 try std.testing.expect(ext.binary_mime and ext.chunking); 1112 1113 try client.mail("alice@example.com", .{ .body = .binary_mime }); 1114 try client.rcptTo("bob@example.net"); 1115 // The server would answer DATA with 503; the client will not get that 1116 // far, because the round trip has nothing to discover. 1117 try std.testing.expectError(error.BinaryRequiresChunking, client.data()); 1118 1119 // BDAT is the way, and it sends the octets untouched: a bare CR, a NUL 1120 // and a lone dot all cross unchanged. 1121 try client.bdat("\x00\r.\r\n", true); 1122 try std.testing.expect(std.mem.endsWith( 1123 u8, 1124 writer.buffered(), 1125 "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1126 "RCPT TO:<bob@example.net>\r\n" ++ 1127 "BDAT 5 LAST\r\n\x00\r.\r\n", 1128 )); 1129} 1130 1131test "the binary commitment is lifted by RSET and by the next transaction" { 1132 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"; 1133 var reader: Io.Reader = .fixed(responses); 1134 var out_buf: [512]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 try client.mail("alice@example.com", .{ .body = .binary_mime }); 1140 try std.testing.expect(client.binary); 1141 try client.rset(); 1142 try std.testing.expect(!client.binary); 1143 // And a plain MAIL leaves DATA available again. 1144 try client.mail("alice@example.com", .{}); 1145 try client.sendMessage("hi\r\n"); 1146} 1147 1148test "a pipelined envelope writes the whole group before reading a reply" { 1149 // The MAIL is refused. A client working one command at a time would 1150 // stop there; a pipelined one has already sent everything, and that is 1151 // what makes the difference observable from the wire alone. 1152 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ 1153 "550 5.1.8 Bad sender\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n"; 1154 var reader: Io.Reader = .fixed(responses); 1155 var out_buf: [512]u8 = undefined; 1156 var writer: Io.Writer = .fixed(&out_buf); 1157 var reply_buf: [256]u8 = undefined; 1158 var client: Client = .init(&reader, &writer, &reply_buf); 1159 1160 _ = try client.hello("client.example.org"); 1161 try std.testing.expect(client.pipelining); 1162 1163 const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" }; 1164 try std.testing.expectError( 1165 error.UnexpectedReply, 1166 client.envelope("alice@example.com", recipients, null, .{}), 1167 ); 1168 try std.testing.expectEqualStrings( 1169 "EHLO client.example.org\r\n" ++ 1170 "MAIL FROM:<alice@example.com>\r\n" ++ 1171 "RCPT TO:<bob@example.net>\r\n" ++ 1172 "RCPT TO:<carol@example.net>\r\n", 1173 writer.buffered(), 1174 ); 1175 // The MAIL reply is the one kept, even though two more were read after 1176 // it, and the group was drained so the stream is where it should be. 1177 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 1178 try std.testing.expectEqualStrings("5.1.8 Bad sender", client.last_reply.?.text); 1179 try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); 1180} 1181 1182test "without PIPELINING the commands wait for each other" { 1183 // Same refusal, no PIPELINING advertised: the RCPTs are never sent. 1184 const responses = "250-mx.example.com\r\n250 8BITMIME\r\n550 5.1.8 Bad sender\r\n"; 1185 var reader: Io.Reader = .fixed(responses); 1186 var out_buf: [512]u8 = undefined; 1187 var writer: Io.Writer = .fixed(&out_buf); 1188 var reply_buf: [256]u8 = undefined; 1189 var client: Client = .init(&reader, &writer, &reply_buf); 1190 1191 _ = try client.hello("client.example.org"); 1192 try std.testing.expect(!client.pipelining); 1193 1194 const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" }; 1195 try std.testing.expectError( 1196 error.UnexpectedReply, 1197 client.envelope("alice@example.com", recipients, null, .{}), 1198 ); 1199 try std.testing.expectEqualStrings( 1200 "EHLO client.example.org\r\nMAIL FROM:<alice@example.com>\r\n", 1201 writer.buffered(), 1202 ); 1203} 1204 1205test "envelope reports which recipients were refused" { 1206 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ 1207 "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"; 1208 var reader: Io.Reader = .fixed(responses); 1209 var out_buf: [512]u8 = undefined; 1210 var writer: Io.Writer = .fixed(&out_buf); 1211 var reply_buf: [256]u8 = undefined; 1212 var client: Client = .init(&reader, &writer, &reply_buf); 1213 1214 _ = try client.hello("client.example.org"); 1215 const recipients: []const []const u8 = &.{ 1216 "bob@example.net", 1217 "nobody@example.net", 1218 "carol@example.net", 1219 }; 1220 var codes: [3]u16 = undefined; 1221 const accepted = try client.envelope("alice@example.com", recipients, &codes, .{}); 1222 1223 try std.testing.expectEqual(@as(usize, 2), accepted); 1224 try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes); 1225 // A refused recipient is not an error here, so the transaction is still 1226 // open and the client knows how many it may deliver to. 1227 try std.testing.expectEqual(@as(usize, 2), client.accepted_recipients); 1228} 1229 1230test "the same envelope works the same way without pipelining" { 1231 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"; 1232 var reader: Io.Reader = .fixed(responses); 1233 var out_buf: [512]u8 = undefined; 1234 var writer: Io.Writer = .fixed(&out_buf); 1235 var reply_buf: [256]u8 = undefined; 1236 var client: Client = .init(&reader, &writer, &reply_buf); 1237 1238 const recipients: []const []const u8 = &.{ 1239 "bob@example.net", 1240 "nobody@example.net", 1241 "carol@example.net", 1242 }; 1243 var codes: [3]u16 = undefined; 1244 const accepted = try client.envelope("alice@example.com", recipients, &codes, .{}); 1245 try std.testing.expectEqual(@as(usize, 2), accepted); 1246 try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes); 1247} 1248 1249test "sendMail abandons the transaction rather than deliver to some" { 1250 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ 1251 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n" ++ 1252 "250 2.0.0 Ok\r\n"; // the RSET 1253 var reader: Io.Reader = .fixed(responses); 1254 var out_buf: [512]u8 = undefined; 1255 var writer: Io.Writer = .fixed(&out_buf); 1256 var reply_buf: [256]u8 = undefined; 1257 var client: Client = .init(&reader, &writer, &reply_buf); 1258 1259 _ = try client.hello("client.example.org"); 1260 const recipients: []const []const u8 = &.{ "bob@example.net", "nobody@example.net" }; 1261 try std.testing.expectError( 1262 error.UnexpectedReply, 1263 client.sendMail("alice@example.com", recipients, "hi\r\n"), 1264 ); 1265 // DATA was never sent, so nothing reached the recipient that was 1266 // accepted, and the session was left clean for the next transaction. 1267 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "DATA") == null); 1268 try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "RSET\r\n")); 1269} 1270 1271test "LMTP greets with LHLO and reads one verdict per recipient" { 1272 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ // LHLO 1273 "250 2.1.0 Ok\r\n" ++ // MAIL 1274 "250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ // two RCPTs 1275 "354 End data\r\n" ++ 1276 "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; // one per recipient 1277 var reader: Io.Reader = .fixed(responses); 1278 var out_buf: [512]u8 = undefined; 1279 var writer: Io.Writer = .fixed(&out_buf); 1280 var reply_buf: [256]u8 = undefined; 1281 var client: Client = .init(&reader, &writer, &reply_buf); 1282 client.mode = .lmtp; 1283 1284 _ = try client.hello("client.example.org"); 1285 try client.mailFrom("alice@example.com"); 1286 try client.rcptTo("good@example.net"); 1287 try client.rcptTo("bad@example.net"); 1288 1289 var data_writer = try client.data(); 1290 try data_writer.interface.writeAll("hi\r\n"); 1291 var verdicts = try data_writer.endResults(); 1292 1293 const first = (try verdicts.next()).?; 1294 try std.testing.expectEqual(@as(u16, 250), first.code); 1295 try std.testing.expectEqual(@as(usize, 1), verdicts.index); 1296 const second = (try verdicts.next()).?; 1297 try std.testing.expectEqual(@as(u16, 550), second.code); 1298 try std.testing.expectEqualStrings("5.2.1 Mailbox disabled", second.text); 1299 try std.testing.expectEqual(@as(?Reply, null), try verdicts.next()); 1300 1301 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "LHLO client.example.org\r\n")); 1302} 1303 1304test "end reports an LMTP rejection distinctly from an SMTP one" { 1305 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" ++ 1306 "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; 1307 var reader: Io.Reader = .fixed(responses); 1308 var out_buf: [512]u8 = undefined; 1309 var writer: Io.Writer = .fixed(&out_buf); 1310 var reply_buf: [256]u8 = undefined; 1311 var client: Client = .init(&reader, &writer, &reply_buf); 1312 client.mode = .lmtp; 1313 1314 try client.mailFrom("alice@example.com"); 1315 try client.rcptTo("good@example.net"); 1316 try client.rcptTo("bad@example.net"); 1317 // Both verdicts are read even though the first already decided the 1318 // outcome, or the next command would be answered by a stale reply. 1319 try std.testing.expectError(error.RecipientRejected, client.sendMessage("hi\r\n")); 1320 1321 // The single-reply case keeps `error.UnexpectedReply`, where 1322 // `last_reply` can actually say what happened. 1323 var smtp_reader: Io.Reader = .fixed("354 End data\r\n550 5.7.1 Rejected\r\n"); 1324 var smtp_out: [256]u8 = undefined; 1325 var smtp_writer: Io.Writer = .fixed(&smtp_out); 1326 var smtp_reply_buf: [256]u8 = undefined; 1327 var smtp: Client = .init(&smtp_reader, &smtp_writer, &smtp_reply_buf); 1328 try std.testing.expectError(error.UnexpectedReply, smtp.sendMessage("hi\r\n")); 1329 try std.testing.expectEqualStrings("5.7.1 Rejected", smtp.last_reply.?.text); 1330} 1331 1332test "the recipient count resets with each new transaction" { 1333 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n" ++ // MAIL, RCPT 1334 "250 2.0.0 Ok\r\n" ++ // RSET 1335 "250 2.1.0 Ok\r\n"; // MAIL again 1336 var reader: Io.Reader = .fixed(responses); 1337 var out_buf: [512]u8 = undefined; 1338 var writer: Io.Writer = .fixed(&out_buf); 1339 var reply_buf: [256]u8 = undefined; 1340 var client: Client = .init(&reader, &writer, &reply_buf); 1341 client.mode = .lmtp; 1342 1343 try client.mailFrom("alice@example.com"); 1344 try client.rcptTo("bob@example.net"); 1345 try std.testing.expectEqual(@as(usize, 1), client.results().remaining); 1346 try client.rset(); 1347 try std.testing.expectEqual(@as(usize, 0), client.results().remaining); 1348 try client.mailFrom("alice@example.com"); 1349 try std.testing.expectEqual(@as(usize, 0), client.results().remaining); 1350} 1351 1352test "mail and rcpt carry the DSN parameters" { 1353 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n"; 1354 var reader: Io.Reader = .fixed(responses); 1355 var out_buf: [256]u8 = undefined; 1356 var writer: Io.Writer = .fixed(&out_buf); 1357 var reply_buf: [64]u8 = undefined; 1358 var client: Client = .init(&reader, &writer, &reply_buf); 1359 1360 try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" }); 1361 try client.rcpt("bob@example.net", .{ 1362 .notify = .{ .on = .{ .failure = true, .delay = true } }, 1363 .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" }, 1364 }); 1365 try std.testing.expectEqualStrings( 1366 "MAIL FROM:<me@example.com> RET=HDRS ENVID=batch+207\r\n" ++ 1367 "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n", 1368 writer.buffered(), 1369 ); 1370} 1371 1372test "mail carries AUTH= for a relay speaking for somebody else" { 1373 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n250 2.1.0 Ok\r\n"); 1374 var out_buf: [512]u8 = undefined; 1375 var writer: Io.Writer = .fixed(&out_buf); 1376 var reply_buf: [64]u8 = undefined; 1377 var client: Client = .init(&reader, &writer, &reply_buf); 1378 1379 try client.mail("relay@example.com", .{ 1380 .auth = .{ .mailbox = "e=mc2@example.com" }, 1381 }); 1382 // The '=' is escaped, because an unescaped one would end the parameter. 1383 try std.testing.expectEqualStrings( 1384 "MAIL FROM:<relay@example.com> AUTH=e+3Dmc2@example.com\r\n", 1385 writer.buffered(), 1386 ); 1387 1388 // `<>` is what a relay sends when it cannot vouch for anybody, and RFC 1389 // 4954 asks for that rather than leaving the parameter off. 1390 var second_buf: [256]u8 = undefined; 1391 var second: Io.Writer = .fixed(&second_buf); 1392 client.setTransport(&reader, &second, .plaintext); 1393 try client.mail("relay@example.com", .{ .auth = .unknown }); 1394 try std.testing.expectEqualStrings( 1395 "MAIL FROM:<relay@example.com> AUTH=<>\r\n", 1396 second.buffered(), 1397 ); 1398} 1399 1400test "an AUTH= mailbox that will not fit is refused before it is sent" { 1401 var reader: Io.Reader = .fixed(""); 1402 var out_buf: [1024]u8 = undefined; 1403 var writer: Io.Writer = .fixed(&out_buf); 1404 var reply_buf: [64]u8 = undefined; 1405 var client: Client = .init(&reader, &writer, &reply_buf); 1406 1407 try std.testing.expectError(error.ArgumentTooLong, client.mail("a@b", .{ 1408 .auth = .{ .mailbox = "x" ** (protocol.Submitter.max_len + 1) }, 1409 })); 1410 // An empty mailbox is not `<>`; the caller meant one or the other. 1411 try std.testing.expectError(error.UnsafeArgument, client.mail("a@b", .{ 1412 .auth = .{ .mailbox = "" }, 1413 })); 1414 try std.testing.expectEqualStrings("", writer.buffered()); 1415} 1416 1417test "NOTIFY=NEVER is written on its own" { 1418 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 1419 var out_buf: [128]u8 = undefined; 1420 var writer: Io.Writer = .fixed(&out_buf); 1421 var reply_buf: [64]u8 = undefined; 1422 var client: Client = .init(&reader, &writer, &reply_buf); 1423 1424 try client.rcpt("bob@example.net", .{ .notify = .never }); 1425 try std.testing.expectEqualStrings( 1426 "RCPT TO:<bob@example.net> NOTIFY=NEVER\r\n", 1427 writer.buffered(), 1428 ); 1429} 1430 1431test "DSN parameter values that exceed their limits are refused" { 1432 var reader: Io.Reader = .fixed(""); 1433 var out_buf: [1024]u8 = undefined; 1434 var writer: Io.Writer = .fixed(&out_buf); 1435 var reply_buf: [64]u8 = undefined; 1436 var client: Client = .init(&reader, &writer, &reply_buf); 1437 1438 // 34 spaces encode to 102 characters, over the ENVID limit of 100, 1439 // though the value itself is well under it. 1440 const spaces = " " ** 34; 1441 try std.testing.expectError( 1442 error.ArgumentTooLong, 1443 client.mail("me@example.com", .{ .envid = spaces }), 1444 ); 1445 try std.testing.expectError(error.ArgumentTooLong, client.rcpt("bob@example.net", .{ 1446 .orcpt = .{ .addr_type = "rfc822", .address = "x" ** 500 }, 1447 })); 1448 // An addr-type is written literally, so it is checked rather than encoded. 1449 try std.testing.expectError(error.UnsafeArgument, client.rcpt("bob@example.net", .{ 1450 .orcpt = .{ .addr_type = "rfc822;evil", .address = "x@example.net" }, 1451 })); 1452 try std.testing.expectEqualStrings("", writer.buffered()); 1453} 1454 1455test "hello reports DSN support" { 1456 const responses = "250-mx.example.com\r\n250-DSN\r\n250 8BITMIME\r\n"; 1457 var reader: Io.Reader = .fixed(responses); 1458 var out_buf: [128]u8 = undefined; 1459 var writer: Io.Writer = .fixed(&out_buf); 1460 var reply_buf: [256]u8 = undefined; 1461 var client: Client = .init(&reader, &writer, &reply_buf); 1462 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1463 client.sasl_buffer = &sasl_buf; 1464 1465 const ext = try client.hello("client.example.org"); 1466 try std.testing.expect(ext.dsn); 1467} 1468 1469test authenticate { 1470 const responses = "250-mx.example.com\r\n250 AUTH PLAIN LOGIN\r\n" ++ 1471 "235 2.7.0 Accepted\r\n"; 1472 var reader: Io.Reader = .fixed(responses); 1473 var out_buf: [256]u8 = undefined; 1474 var writer: Io.Writer = .fixed(&out_buf); 1475 var reply_buf: [256]u8 = undefined; 1476 var client: Client = .init(&reader, &writer, &reply_buf); 1477 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1478 client.sasl_buffer = &sasl_buf; 1479 client.security = .encrypted; 1480 1481 const extensions = try client.hello("client.example.org"); 1482 var plain: sasl.Plain = .init("alice", "secret"); 1483 const mechanism = sasl.Client.selectFromList( 1484 &.{plain.client()}, 1485 extensions.auth, 1486 true, 1487 ).?; 1488 try client.authenticate(mechanism); 1489 1490 // base64("\x00alice\x00secret"), sent as the initial response in one 1491 // round trip rather than waiting to be asked. 1492 try std.testing.expect(std.mem.endsWith( 1493 u8, 1494 writer.buffered(), 1495 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n", 1496 )); 1497} 1498 1499test "a challenge-response mechanism runs through the 334s" { 1500 const responses = "250-mx.example.com\r\n250 AUTH CRAM-MD5\r\n" ++ 1501 // base64 of RFC 2195's challenge 1502 "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1503 "235 2.7.0 Accepted\r\n"; 1504 var reader: Io.Reader = .fixed(responses); 1505 var out_buf: [512]u8 = undefined; 1506 var writer: Io.Writer = .fixed(&out_buf); 1507 var reply_buf: [256]u8 = undefined; 1508 var client: Client = .init(&reader, &writer, &reply_buf); 1509 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1510 client.sasl_buffer = &sasl_buf; 1511 1512 const extensions = try client.hello("client.example.org"); 1513 var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); 1514 // CRAM-MD5 is not cleartext, so it is usable on this plaintext session. 1515 const mechanism = sasl.Client.selectFromList(&.{cram.client()}, extensions.auth, false).?; 1516 try client.authenticate(mechanism); 1517 1518 // No initial response, then the digest RFC 2195 publishes, base64'd. 1519 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "AUTH CRAM-MD5\r\n") != null); 1520 try std.testing.expect(std.mem.endsWith( 1521 u8, 1522 writer.buffered(), 1523 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 1524 )); 1525} 1526 1527test "a mechanism that sends a credential in the clear is refused first" { 1528 var reader: Io.Reader = .fixed(""); 1529 var out_buf: [256]u8 = undefined; 1530 var writer: Io.Writer = .fixed(&out_buf); 1531 var reply_buf: [64]u8 = undefined; 1532 var client: Client = .init(&reader, &writer, &reply_buf); 1533 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1534 client.sasl_buffer = &sasl_buf; 1535 1536 var plain: sasl.Plain = .init("alice", "secret"); 1537 try std.testing.expectError( 1538 error.InsecureTransport, 1539 client.authenticate(plain.client()), 1540 ); 1541 // Nothing reached the wire, which is the point: the refusal happens 1542 // before the credential is written, not after the server rejects it. 1543 try std.testing.expectEqualStrings("", writer.buffered()); 1544 1545 client.allow_cleartext_auth = true; 1546 var accepting: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1547 client.setTransport(&accepting, &writer, .plaintext); 1548 try client.authenticate(plain.client()); 1549} 1550 1551test "a server accepting without finishing the exchange is not authenticated" { 1552 // A mechanism that has not proved what it set out to prove, which is 1553 // SCRAM's shape: `satisfied` stays false until the server's own proof 1554 // has been verified. 1555 const Unfinished = struct { 1556 fn name(_: *anyopaque) []const u8 { 1557 return "MUTUAL-TEST"; 1558 } 1559 fn initial(_: *anyopaque, out: *Io.Writer) sasl.Client.Error!sasl.Client.Initial { 1560 try out.writeAll("hello"); 1561 return .written; 1562 } 1563 fn respond(_: *anyopaque, _: []const u8, _: *Io.Writer) sasl.Client.Error!void {} 1564 fn satisfied(_: *anyopaque) bool { 1565 return false; 1566 } 1567 fn cleartext(_: *anyopaque) bool { 1568 return false; 1569 } 1570 const vtable: sasl.Client.VTable = .{ 1571 .name = name, 1572 .initial = initial, 1573 .respond = respond, 1574 .satisfied = satisfied, 1575 .cleartext = cleartext, 1576 }; 1577 }; 1578 var nothing: u8 = 0; 1579 const mechanism: sasl.Client = .{ .context = &nothing, .vtable = &Unfinished.vtable }; 1580 1581 var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1582 var out_buf: [256]u8 = undefined; 1583 var writer: Io.Writer = .fixed(&out_buf); 1584 var reply_buf: [64]u8 = undefined; 1585 var client: Client = .init(&reader, &writer, &reply_buf); 1586 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1587 client.sasl_buffer = &sasl_buf; 1588 1589 // The server said yes. The mechanism disagrees, and it is the one that 1590 // knows — this is the case nothing in this library could express before 1591 // the mechanisms moved out of it. 1592 try std.testing.expectError( 1593 error.ServerNotAuthenticated, 1594 client.authenticate(mechanism), 1595 ); 1596} 1597 1598test "a mechanism that fails mid-exchange cancels rather than stranding the session" { 1599 // PLAIN is never challenged, so a 334 makes it return BadChallenge. 1600 const responses = "334 c29tZXRoaW5n\r\n501 5.5.2 Cancelled\r\n"; 1601 var reader: Io.Reader = .fixed(responses); 1602 var out_buf: [256]u8 = undefined; 1603 var writer: Io.Writer = .fixed(&out_buf); 1604 var reply_buf: [64]u8 = undefined; 1605 var client: Client = .init(&reader, &writer, &reply_buf); 1606 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1607 client.sasl_buffer = &sasl_buf; 1608 client.security = .encrypted; 1609 1610 var plain: sasl.Plain = .init("alice", "secret"); 1611 try std.testing.expectError(error.BadChallenge, client.authenticate(plain.client())); 1612 // RFC 4954 §4's cancellation went out, so the server is not left waiting 1613 // for a line that was never coming. 1614 try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "*\r\n")); 1615 try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); 1616} 1617 1618test "authenticate needs a buffer, and says so rather than overrunning one" { 1619 var reader: Io.Reader = .fixed(""); 1620 var out_buf: [256]u8 = undefined; 1621 var writer: Io.Writer = .fixed(&out_buf); 1622 var reply_buf: [64]u8 = undefined; 1623 var client: Client = .init(&reader, &writer, &reply_buf); 1624 client.security = .encrypted; 1625 1626 var plain: sasl.Plain = .init("alice", "secret"); 1627 // No buffer at all: this is the default, and it is an error rather than 1628 // a hidden allocation or a stack array the caller cannot see. 1629 try std.testing.expectError( 1630 error.SaslBufferTooSmall, 1631 client.authenticate(plain.client()), 1632 ); 1633 1634 var tiny: [sasl_buffer_min - 1]u8 = undefined; 1635 client.sasl_buffer = &tiny; 1636 try std.testing.expectError( 1637 error.SaslBufferTooSmall, 1638 client.authenticate(plain.client()), 1639 ); 1640 try std.testing.expectEqualStrings("", writer.buffered()); 1641} 1642 1643test "the two halves of the buffer take turns rather than coexist" { 1644 // A challenge decodes into the coded half, the mechanism's answer is 1645 // written into the plain half, and the answer encodes back over the 1646 // challenge. The minimum buffer is enough to run a real exchange, which 1647 // is what this checks: at 896 bytes there are 512 coded and 384 plain. 1648 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1649 "235 2.7.0 Accepted\r\n"; 1650 var reader: Io.Reader = .fixed(responses); 1651 var out_buf: [512]u8 = undefined; 1652 var writer: Io.Writer = .fixed(&out_buf); 1653 var reply_buf: [256]u8 = undefined; 1654 var client: Client = .init(&reader, &writer, &reply_buf); 1655 var scratch: [sasl_buffer_min]u8 = undefined; 1656 client.sasl_buffer = &scratch; 1657 1658 var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); 1659 try client.authenticate(cram.client()); 1660 try std.testing.expect(std.mem.endsWith( 1661 u8, 1662 writer.buffered(), 1663 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 1664 )); 1665} 1666 1667test "a rejection surfaces as AuthenticationFailed with the reply" { 1668 var reader: Io.Reader = .fixed("535 5.7.8 Authentication credentials invalid\r\n"); 1669 var out_buf: [256]u8 = undefined; 1670 var writer: Io.Writer = .fixed(&out_buf); 1671 var reply_buf: [256]u8 = undefined; 1672 var client: Client = .init(&reader, &writer, &reply_buf); 1673 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1674 client.sasl_buffer = &sasl_buf; 1675 client.security = .encrypted; 1676 1677 var plain: sasl.Plain = .init("alice", "secret"); 1678 try std.testing.expectError( 1679 error.AuthenticationFailed, 1680 client.authenticate(plain.client()), 1681 ); 1682 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 1683} 1684 1685test "a rejection's enhanced status code says more than its reply code" { 1686 // Two different refusals behind the same 550: one about the address, 1687 // one about policy. The three-digit code cannot tell them apart and the 1688 // enhanced one can, which is the whole reason to read it. 1689 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n550 5.1.1 No such user\r\n"); 1690 var out_buf: [256]u8 = undefined; 1691 var writer: Io.Writer = .fixed(&out_buf); 1692 var reply_buf: [256]u8 = undefined; 1693 var client: Client = .init(&reader, &writer, &reply_buf); 1694 1695 try client.mailFrom("alice@example.com"); 1696 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.net")); 1697 1698 const reply = client.last_reply.?; 1699 const status = reply.enhanced().?; 1700 try std.testing.expect(status.agrees(reply.code)); 1701 try std.testing.expectEqual(protocol.Enhanced.Subject.addressing, status.subjectClass()); 1702 try std.testing.expectEqual(@as(u16, 1), status.detail); 1703 // And the part meant for a person, without the code in front of it. 1704 try std.testing.expectEqualStrings("No such user", reply.message()); 1705} 1706 1707test "a server that contradicts itself is detectable" { 1708 // 250 carrying a 5.x.x code. Nothing in RFC 3463 says what to do about 1709 // it, but a caller can at least see it rather than trusting either half. 1710 var reader: Io.Reader = .fixed("250 5.1.1 Ok?\r\n"); 1711 var out_buf: [128]u8 = undefined; 1712 var writer: Io.Writer = .fixed(&out_buf); 1713 var reply_buf: [128]u8 = undefined; 1714 var client: Client = .init(&reader, &writer, &reply_buf); 1715 1716 try client.mailFrom("alice@example.com"); // the 2xx is what `mail` checks 1717 const reply = client.last_reply.?; 1718 try std.testing.expect(!reply.enhanced().?.agrees(reply.code)); 1719} 1720 1721test "a multiline reply repeats the code on every line" { 1722 const responses = "250-mx.example.com\r\n250 SIZE 1000000\r\n" ++ 1723 "452-4.5.3 Too many recipients\r\n452 4.5.3 Try fewer\r\n"; 1724 var reader: Io.Reader = .fixed(responses); 1725 var out_buf: [256]u8 = undefined; 1726 var writer: Io.Writer = .fixed(&out_buf); 1727 var reply_buf: [256]u8 = undefined; 1728 var client: Client = .init(&reader, &writer, &reply_buf); 1729 1730 _ = try client.hello("client.example.org"); 1731 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("b@example.net")); 1732 1733 const reply = client.last_reply.?; 1734 // `message` strips the first line's code; the rest are reached through 1735 // `lines`, which is what the doc comment says to do. 1736 try std.testing.expectEqualStrings("Too many recipients\nTry fewer", blk: { 1737 var joined: [64]u8 = undefined; 1738 var out: Io.Writer = .fixed(&joined); 1739 var it = reply.lines(); 1740 var first = true; 1741 while (it.next()) |line| { 1742 if (!first) try out.writeByte('\n'); 1743 first = false; 1744 try out.writeAll(protocol.Enhanced.strip(line)); 1745 } 1746 break :blk out.buffered(); 1747 }); 1748} 1749 1750test "an address carrying CRLF cannot inject a command" { 1751 // Without the check this would put a second RCPT on the wire. 1752 const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net"; 1753 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 1754 var out_buf: [256]u8 = undefined; 1755 var writer: Io.Writer = .fixed(&out_buf); 1756 var reply_buf: [64]u8 = undefined; 1757 var client: Client = .init(&reader, &writer, &reply_buf); 1758 1759 try std.testing.expectError(error.UnsafeArgument, client.rcptTo(smuggled)); 1760 try std.testing.expectError(error.UnsafeArgument, client.mailFrom(smuggled)); 1761 try std.testing.expectError(error.UnsafeArgument, client.mailFromUtf8(smuggled)); 1762 try std.testing.expectError(error.UnsafeArgument, client.hello("host\r\nQUIT")); 1763 // Nothing reached the wire, so the session is still where it was. 1764 try std.testing.expectEqualStrings("", writer.buffered()); 1765} 1766 1767test hello { 1768 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 1769 var reader: Io.Reader = .fixed(responses); 1770 var out_buf: [256]u8 = undefined; 1771 var writer: Io.Writer = .fixed(&out_buf); 1772 var reply_buf: [256]u8 = undefined; 1773 var client: Client = .init(&reader, &writer, &reply_buf); 1774 1775 const ext = try client.hello("c.example"); 1776 try std.testing.expectEqualStrings("PLAIN LOGIN CRAM-MD5", ext.auth); 1777} 1778 1779test init { 1780 var reader: Io.Reader = .fixed(""); 1781 var out_buf: [16]u8 = undefined; 1782 var writer: Io.Writer = .fixed(&out_buf); 1783 var reply_buf: [128]u8 = undefined; 1784 const client: Client = .init(&reader, &writer, &reply_buf); 1785 try std.testing.expect(client.last_reply == null); 1786} 1787 1788test greet { 1789 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n"); 1790 var out_buf: [16]u8 = undefined; 1791 var writer: Io.Writer = .fixed(&out_buf); 1792 var reply_buf: [128]u8 = undefined; 1793 var client: Client = .init(&reader, &writer, &reply_buf); 1794 1795 const reply = try client.greet(); 1796 try std.testing.expectEqual(@as(u16, 220), reply.code); 1797 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text); 1798} 1799 1800test setTransport { 1801 var reader: Io.Reader = .fixed(""); 1802 var out_buf: [16]u8 = undefined; 1803 var writer: Io.Writer = .fixed(&out_buf); 1804 var reply_buf: [64]u8 = undefined; 1805 var client: Client = .init(&reader, &writer, &reply_buf); 1806 1807 // After a TLS handshake, point the session at the encrypted streams. 1808 var tls_reader: Io.Reader = .fixed(""); 1809 var tls_out_buf: [16]u8 = undefined; 1810 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 1811 client.setTransport(&tls_reader, &tls_writer, .encrypted); 1812 try std.testing.expectEqual(&tls_reader, client.reader); 1813 try std.testing.expectEqual(&tls_writer, client.writer); 1814 try std.testing.expectEqual(Security.encrypted, client.security); 1815} 1816 1817test mailFrom { 1818 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 1819 var out_buf: [64]u8 = undefined; 1820 var writer: Io.Writer = .fixed(&out_buf); 1821 var reply_buf: [64]u8 = undefined; 1822 var client: Client = .init(&reader, &writer, &reply_buf); 1823 1824 try client.mailFrom("alice@example.com"); 1825 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered()); 1826} 1827 1828test rcptTo { 1829 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 1830 var out_buf: [64]u8 = undefined; 1831 var writer: Io.Writer = .fixed(&out_buf); 1832 var reply_buf: [64]u8 = undefined; 1833 var client: Client = .init(&reader, &writer, &reply_buf); 1834 1835 try client.rcptTo("bob@example.net"); 1836 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered()); 1837} 1838 1839test sendMessage { 1840 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n"); 1841 var out_buf: [128]u8 = undefined; 1842 var writer: Io.Writer = .fixed(&out_buf); 1843 var reply_buf: [64]u8 = undefined; 1844 var client: Client = .init(&reader, &writer, &reply_buf); 1845 1846 try client.sendMessage("Subject: hi\n\nhello\n"); 1847 try std.testing.expectEqualStrings( 1848 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n", 1849 writer.buffered(), 1850 ); 1851} 1852 1853test rset { 1854 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 1855 var out_buf: [16]u8 = undefined; 1856 var writer: Io.Writer = .fixed(&out_buf); 1857 var reply_buf: [64]u8 = undefined; 1858 var client: Client = .init(&reader, &writer, &reply_buf); 1859 1860 try client.rset(); 1861 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered()); 1862} 1863 1864test noop { 1865 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 1866 var out_buf: [16]u8 = undefined; 1867 var writer: Io.Writer = .fixed(&out_buf); 1868 var reply_buf: [64]u8 = undefined; 1869 var client: Client = .init(&reader, &writer, &reply_buf); 1870 1871 try client.noop(); 1872 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered()); 1873} 1874 1875test quit { 1876 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n"); 1877 var out_buf: [16]u8 = undefined; 1878 var writer: Io.Writer = .fixed(&out_buf); 1879 var reply_buf: [64]u8 = undefined; 1880 var client: Client = .init(&reader, &writer, &reply_buf); 1881 1882 try client.quit(); 1883 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered()); 1884} 1885 1886test data { 1887 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 1888 var out_buf: [256]u8 = undefined; 1889 var writer: Io.Writer = .fixed(&out_buf); 1890 var reply_buf: [64]u8 = undefined; 1891 var client: Client = .init(&reader, &writer, &reply_buf); 1892 1893 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily. 1894 var data_writer = try client.data(); 1895 try data_writer.interface.writeAll("Subject: chunked\n\nfirst"); 1896 try data_writer.interface.writeAll(" second\r"); 1897 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r"); 1898 try data_writer.interface.writeAll("\n"); 1899 try data_writer.interface.writeAll(".x\nend"); 1900 try data_writer.end(); 1901 1902 try std.testing.expectEqualStrings( 1903 "DATA\r\n" ++ 1904 "Subject: chunked\r\n" ++ 1905 "\r\n" ++ 1906 "first second\r\n" ++ 1907 "..needs stuffing\r\n" ++ 1908 "split\r\n" ++ 1909 "..x\r\n" ++ 1910 "end\r\n" ++ 1911 ".\r\n", 1912 writer.buffered(), 1913 ); 1914} 1915 1916test sendMessageReader { 1917 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 1918 var out_buf: [128]u8 = undefined; 1919 var writer: Io.Writer = .fixed(&out_buf); 1920 var reply_buf: [64]u8 = undefined; 1921 var client: Client = .init(&reader, &writer, &reply_buf); 1922 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1923 client.sasl_buffer = &sasl_buf; 1924 1925 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n"); 1926 try client.sendMessageReader(&message); 1927 try std.testing.expectEqualStrings( 1928 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n", 1929 writer.buffered(), 1930 ); 1931} 1932 1933test "fuzz client against arbitrary server replies" { 1934 try std.testing.fuzz({}, fuzzClientReplies, .{}); 1935} 1936 1937fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void { 1938 _ = context; 1939 var input_buf: [1024]u8 = undefined; 1940 const input = input_buf[0..smith.value(u10)]; 1941 smith.bytes(input); 1942 1943 var reader: Io.Reader = .fixed(input); 1944 var out_buf: [4096]u8 = undefined; 1945 var writer: Io.Writer = .fixed(&out_buf); 1946 var reply_buf: [256]u8 = undefined; 1947 var client: Client = .init(&reader, &writer, &reply_buf); 1948 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1949 client.sasl_buffer = &sasl_buf; 1950 1951 // Whatever the "server" says, the client must fail cleanly, never crash. 1952 _ = client.greet() catch return; 1953 const extensions = client.hello("fuzz.example.org") catch return; 1954 var plain: sasl.Plain = .init("user", "password"); 1955 client.allow_cleartext_auth = true; 1956 if (sasl.Client.selectFromList(&.{plain.client()}, extensions.auth, true)) |mechanism| 1957 client.authenticate(mechanism) catch {}; 1958 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; 1959 client.quit() catch {}; 1960} 1961 1962test "fuzz DataWriter equivalence with writeStuffed" { 1963 try std.testing.fuzz({}, fuzzDataWriter, .{}); 1964} 1965 1966fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void { 1967 _ = context; 1968 var message_buf: [1024]u8 = undefined; 1969 const message = message_buf[0..smith.value(u10)]; 1970 smith.bytes(message); 1971 1972 // Reference implementation: slice-based stuffing. 1973 var expected_buf: [2100]u8 = undefined; 1974 var expected: Io.Writer = .fixed(&expected_buf); 1975 try protocol.writeStuffed(&expected, message); 1976 1977 // Streaming implementation, with fuzzer-chosen chunk boundaries. 1978 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n"); 1979 var out_buf: [2200]u8 = undefined; 1980 var writer: Io.Writer = .fixed(&out_buf); 1981 var reply_buf: [64]u8 = undefined; 1982 var client: Client = .init(&responses, &writer, &reply_buf); 1983 1984 var data_writer = try client.data(); 1985 var rest: []const u8 = message; 1986 while (rest.len > 0) { 1987 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len)); 1988 try data_writer.interface.writeAll(rest[0..n]); 1989 rest = rest[n..]; 1990 } 1991 try data_writer.end(); 1992 1993 const written = writer.buffered(); 1994 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n")); 1995 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); 1996 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; 1997 try std.testing.expectEqualStrings(expected.buffered(), stuffed); 1998} 1999 2000test Extensions { 2001 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; 2002 try std.testing.expect(extensions.pipelining); 2003 try std.testing.expect(!extensions.starttls); 2004 try std.testing.expectEqualStrings("", extensions.auth); 2005 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 2006} 2007 2008test bdat { 2009 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n"); 2010 var out_buf: [128]u8 = undefined; 2011 var writer: Io.Writer = .fixed(&out_buf); 2012 var reply_buf: [64]u8 = undefined; 2013 var client: Client = .init(&reader, &writer, &reply_buf); 2014 2015 try client.bdat("Subject: hi\r\n\r\n", false); 2016 try client.bdat("body\r\n", true); 2017 try std.testing.expectEqualStrings( 2018 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n", 2019 writer.buffered(), 2020 ); 2021} 2022 2023test sendMessageChunked { 2024 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 2025 var out_buf: [128]u8 = undefined; 2026 var writer: Io.Writer = .fixed(&out_buf); 2027 var reply_buf: [64]u8 = undefined; 2028 var client: Client = .init(&reader, &writer, &reply_buf); 2029 2030 // Raw transmission: the leading dot is not stuffed. 2031 try client.sendMessageChunked(".raw\r\n"); 2032 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); 2033} 2034 2035test mailFromUtf8 { 2036 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 2037 var out_buf: [64]u8 = undefined; 2038 var writer: Io.Writer = .fixed(&out_buf); 2039 var reply_buf: [64]u8 = undefined; 2040 var client: Client = .init(&reader, &writer, &reply_buf); 2041 2042 try client.mailFromUtf8("böb@example.com"); 2043 try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered()); 2044}