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