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