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