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
51 kB 1266 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/// Permits `authenticate`, `authPlain` and `authLogin` to send credentials 41/// over a `.plaintext` transport, which they otherwise refuse with 42/// `error.InsecureTransport`. 43/// 44/// The honest use is a connection protected by something outside this 45/// library's view — a unix socket, an SSH tunnel, a loopback test — where 46/// setting `security` to `.encrypted` would be a lie. Anything else is 47/// handing the password to the network. 48allow_cleartext_auth: bool = false, 49 50/// Whether the transport encrypts what is written to it. 51pub const Security = enum { plaintext, encrypted }; 52 53pub const Error = error{ 54 WriteFailed, 55 ReadFailed, 56 EndOfStream, 57 LineTooLong, 58 InvalidReply, 59 ReplyTooLong, 60 /// The server answered with an unexpected code; see `last_reply`. 61 UnexpectedReply, 62}; 63 64pub const ArgumentError = error{ 65 /// An argument contained CR, LF or NUL and was not sent. See 66 /// `protocol.isSafeArgument` for why those three bytes and no others. 67 UnsafeArgument, 68 /// An ESMTP parameter value exceeded the length its RFC allows — 69 /// `ENVID` past 100 characters or `ORCPT` past 500, measured on the 70 /// xtext-encoded form that would go on the wire. 71 ArgumentTooLong, 72}; 73 74/// Extensions advertised in the server's EHLO response. 75pub const Extensions = struct { 76 pipelining: bool = false, 77 eight_bit_mime: bool = false, 78 starttls: bool = false, 79 smtputf8: bool = false, 80 chunking: bool = false, 81 enhanced_status_codes: bool = false, 82 /// The server accepts the DSN parameters of 83 /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and 84 /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT. 85 dsn: bool = false, 86 /// AUTH mechanisms advertised by the server. 87 auth: Auth = .{}, 88 /// Value of the SIZE extension, if advertised with a value. 89 max_size: ?u64 = null, 90 91 pub const Auth = struct { 92 plain: bool = false, 93 login: bool = false, 94 cram_md5: bool = false, 95 96 pub fn any(a: Auth) bool { 97 return a.plain or a.login or a.cram_md5; 98 } 99 100 test any { 101 try std.testing.expect((Auth{ .login = true }).any()); 102 try std.testing.expect(!(Auth{}).any()); 103 } 104 105 fn parse(arg: []const u8) Auth { 106 var auth: Auth = .{}; 107 var it = std.mem.tokenizeScalar(u8, arg, ' '); 108 while (it.next()) |mechanism| { 109 if (ieql(mechanism, "PLAIN")) { 110 auth.plain = true; 111 } else if (ieql(mechanism, "LOGIN")) { 112 auth.login = true; 113 } else if (ieql(mechanism, "CRAM-MD5")) { 114 auth.cram_md5 = true; 115 } 116 } 117 return auth; 118 } 119 }; 120 121 fn parse(reply: Reply) Extensions { 122 var ext: Extensions = .{}; 123 var it = reply.lines(); 124 _ = it.next(); // The first line is the server's greeting, not a keyword. 125 while (it.next()) |line| { 126 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; 127 const kw = line[0..kw_end]; 128 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else ""; 129 if (ieql(kw, "PIPELINING")) { 130 ext.pipelining = true; 131 } else if (ieql(kw, "8BITMIME")) { 132 ext.eight_bit_mime = true; 133 } else if (ieql(kw, "STARTTLS")) { 134 ext.starttls = true; 135 } else if (ieql(kw, "SMTPUTF8")) { 136 ext.smtputf8 = true; 137 } else if (ieql(kw, "CHUNKING")) { 138 ext.chunking = true; 139 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 140 ext.enhanced_status_codes = true; 141 } else if (ieql(kw, "DSN")) { 142 ext.dsn = true; 143 } else if (ieql(kw, "AUTH")) { 144 ext.auth = Auth.parse(arg); 145 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { 146 // Some legacy servers advertise "AUTH=PLAIN LOGIN". 147 var legacy_arg_buf: [128]u8 = undefined; 148 const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..]; 149 ext.auth = Auth.parse(joined); 150 } else if (ieql(kw, "SIZE")) { 151 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 152 } 153 } 154 return ext; 155 } 156 157 fn ieql(a: []const u8, b: []const u8) bool { 158 return std.ascii.eqlIgnoreCase(a, b); 159 } 160}; 161 162/// `reply_buffer` must be large enough for the largest expected reply text 163/// (the EHLO response is usually the largest); 512 bytes is plenty in 164/// practice. 165pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client { 166 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer }; 167} 168 169/// Reads the server's 220 greeting. Call once, right after connecting. 170pub fn greet(c: *Client) Error!Reply { 171 return c.expect(220); 172} 173 174/// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1)) 175/// and returns the extensions the server advertised, falling back 176/// to plain HELO for servers that do not speak ESMTP. 177pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions { 178 if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument; 179 try c.send("EHLO {s}", .{client_name}); 180 const reply = try c.readReply(); 181 if (reply.isPositiveCompletion()) return Extensions.parse(reply); 182 if (reply.code == 500 or reply.code == 502) { 183 try c.send("HELO {s}", .{client_name}); 184 _ = try c.expectClass(2); 185 return .{}; 186 } 187 return error.UnexpectedReply; 188} 189 190/// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and 191/// reads the server's 220 go-ahead. On 192/// success, perform a TLS handshake over the underlying stream (see `Tls`), 193/// switch to the encrypted transport with `setTransport`, and then call 194/// `hello` again — the server discards everything it learned before the 195/// handshake, including the EHLO state. 196pub fn starttls(c: *Client) Error!void { 197 try c.send("STARTTLS", .{}); 198 _ = try c.expect(220); 199} 200 201/// Replaces the session's transport, typically with a TLS reader/writer 202/// after `starttls`, and records whether the new one is encrypted. Pass 203/// `.encrypted` for a TLS transport; that is what lets `authenticate` use a 204/// mechanism that sends the password. 205pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer, security: Security) void { 206 c.reader = reader; 207 c.writer = writer; 208 c.security = security; 209} 210 211pub const AuthError = Error || ArgumentError || error{ 212 CredentialsTooLong, 213 /// The transport is not encrypted and the mechanism would have put the 214 /// password on the wire in the clear. Upgrade the session with 215 /// `starttls`, or set `allow_cleartext_auth` if the connection is 216 /// protected by something this library cannot see. 217 InsecureTransport, 218 /// The server rejected the credentials; see `last_reply`. 219 AuthenticationFailed, 220 /// The server's CRAM-MD5 challenge was not valid base64. 221 InvalidChallenge, 222 /// The server advertised none of the supported mechanisms. 223 NoSupportedMechanism, 224}; 225 226/// Authenticates with the best mechanism the server advertised, which 227/// depends on `security`. 228/// 229/// Over an encrypted transport that is PLAIN, then LOGIN, then CRAM-MD5: 230/// the network cannot read any of them, so the order is by how reliably 231/// servers implement them. Over a plaintext one the order inverts to 232/// CRAM-MD5 first, because it is the only one of the three that does not 233/// put the password on the wire; if the server does not offer it, the 234/// remaining mechanisms are refused with `error.InsecureTransport` rather 235/// than used, unless `allow_cleartext_auth` says otherwise. 236pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void { 237 if (c.security == .plaintext and extensions.auth.cram_md5) 238 return c.authCramMd5(username, password); 239 if (extensions.auth.plain) return c.authPlain("", username, password); 240 if (extensions.auth.login) return c.authLogin(username, password); 241 if (extensions.auth.cram_md5) return c.authCramMd5(username, password); 242 return error.NoSupportedMechanism; 243} 244 245/// Refuses a mechanism that would transmit the password unprotected. 246fn requireConfidentiality(c: *Client) AuthError!void { 247 if (c.security == .encrypted or c.allow_cleartext_auth) return; 248 return error.InsecureTransport; 249} 250 251/// Authenticates with AUTH PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)). 252/// Pass an empty `authzid` unless 253/// you need to act on behalf of another identity. 254/// 255/// The credentials cross the wire in the clear (base64 is not encryption), 256/// so this returns `error.InsecureTransport` unless `security` is 257/// `.encrypted` or `allow_cleartext_auth` is set. 258pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { 259 try c.requireConfidentiality(); 260 // NUL separates the three fields, so one hidden in a field would move 261 // the boundaries and authenticate as somebody else. 262 if (!protocol.isSafeArgument(authzid) or !protocol.isSafeArgument(username) or 263 !protocol.isSafeArgument(password)) return error.UnsafeArgument; 264 var plain_buf: [512]u8 = undefined; 265 var plain: Io.Writer = .fixed(&plain_buf); 266 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch 267 return error.CredentialsTooLong; 268 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; 269 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); 270 try c.send("AUTH PLAIN {s}", .{b64}); 271 try c.expectAuthSuccess(); 272} 273 274/// Authenticates with AUTH LOGIN, the legacy two-step username/password 275/// exchange still required by some servers (no RFC; the de-facto 276/// [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 277/// mechanism). Like AUTH PLAIN it sends the credentials in the clear, so 278/// it returns `error.InsecureTransport` unless `security` is `.encrypted` 279/// or `allow_cleartext_auth` is set. 280pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { 281 try c.requireConfidentiality(); 282 try c.send("AUTH LOGIN", .{}); 283 _ = try c.expect(334); // Username: prompt 284 try c.sendBase64(username); 285 _ = try c.expect(334); // Password: prompt 286 try c.sendBase64(password); 287 try c.expectAuthSuccess(); 288} 289 290/// Authenticates with AUTH CRAM-MD5 ([RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195)): 291/// the password never crosses 292/// the wire, only an HMAC-MD5 of the server's challenge — which is why this 293/// one is allowed over a plaintext transport, and why `authenticate` 294/// prefers it there. The challenge is still replayable and MD5 is long 295/// past retirement, so it is a way to avoid handing over the password, not 296/// a substitute for TLS. 297pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { 298 if (!protocol.isSafeArgument(username)) return error.UnsafeArgument; 299 try c.send("AUTH CRAM-MD5", .{}); 300 const reply = try c.expect(334); 301 302 var challenge_buf: [512]u8 = undefined; 303 const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch 304 return error.InvalidChallenge; 305 if (challenge_len > challenge_buf.len) return error.InvalidChallenge; 306 std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch 307 return error.InvalidChallenge; 308 309 var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined; 310 std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password); 311 const digest = std.fmt.bytesToHex(mac, .lower); 312 313 var response_buf: [384]u8 = undefined; 314 var response: Io.Writer = .fixed(&response_buf); 315 response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong; 316 try c.sendBase64(response.buffered()); 317 try c.expectAuthSuccess(); 318} 319 320/// Sends `bytes` base64-encoded as a bare continuation line. 321fn sendBase64(c: *Client, bytes: []const u8) AuthError!void { 322 var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined; 323 if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len) 324 return error.CredentialsTooLong; 325 const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes); 326 try c.send("{s}", .{b64}); 327} 328 329fn expectAuthSuccess(c: *Client) AuthError!void { 330 const reply = try c.readReply(); 331 if (reply.code != 235) return error.AuthenticationFailed; 332} 333 334/// Parameters for the MAIL command. Send only what the server advertised: 335/// an unrecognized parameter is a 555 from a conforming server, so check 336/// `Extensions` first. 337pub const MailOptions = struct { 338 /// Requests the SMTPUTF8 extension 339 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)), which 340 /// lets the envelope and headers carry UTF-8. Needs `Extensions.smtputf8`. 341 smtputf8: bool = false, 342 /// DSN `RET=`: how much of the message a failure report should carry 343 /// back. Needs `Extensions.dsn`. 344 ret: ?protocol.Ret = null, 345 /// DSN `ENVID=`: an identifier quoted back in any report about this 346 /// message. Sent xtext-encoded, so any bytes are safe to pass, and 347 /// rejected with `error.ArgumentTooLong` if the encoded form exceeds the 348 /// 100 characters RFC 3461 allows. Needs `Extensions.dsn`. 349 envid: ?[]const u8 = null, 350}; 351 352/// Parameters for the RCPT command, which in this library means the DSN 353/// ones. Needs `Extensions.dsn`; see `MailOptions`. 354pub const RcptOptions = struct { 355 /// DSN `NOTIFY=`: when the sender wants to hear about this recipient. 356 /// Leave null to let the receiver apply its default. 357 notify: ?protocol.Notify = null, 358 /// DSN `ORCPT=`: the address the message was originally addressed to, 359 /// carried through aliasing so a report can name what the sender wrote. 360 /// The address is sent xtext-encoded; the `addr_type` is not, so it is 361 /// checked instead, and the whole parameter is capped at the 500 362 /// characters RFC 3461 allows. 363 orcpt: ?protocol.Orcpt = null, 364}; 365 366/// Starts a mail transaction. An empty `from` sends the null reverse-path 367/// (`MAIL FROM:<>`), used for bounces. 368/// 369/// Returns `error.UnsafeArgument` for an address that would break out of 370/// the command line; see `protocol.isSafeArgument`. 371pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void { 372 return c.mail(from, .{}); 373} 374 375/// `mailFrom` with ESMTP parameters. 376pub fn mail(c: *Client, from: []const u8, options: MailOptions) (Error || ArgumentError)!void { 377 if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 378 if (options.envid) |envid| { 379 if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len) 380 return error.ArgumentTooLong; 381 } 382 try c.writer.print("MAIL FROM:<{s}>", .{from}); 383 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); 384 if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret}); 385 if (options.envid) |envid| { 386 try c.writer.writeAll(" ENVID="); 387 try protocol.writeXtext(c.writer, envid); 388 } 389 try c.writer.writeAll(protocol.crlf); 390 try c.writer.flush(); 391 _ = try c.expectClass(2); 392} 393 394/// Adds a recipient to the current transaction. Returns 395/// `error.UnsafeArgument` for an address that would break out of the 396/// command line; see `protocol.isSafeArgument`. 397pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void { 398 return c.rcpt(to, .{}); 399} 400 401/// `rcptTo` with ESMTP parameters. 402pub fn rcpt(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!void { 403 if (!protocol.isSafeArgument(to)) return error.UnsafeArgument; 404 if (options.orcpt) |orcpt| { 405 if (orcpt.addr_type.len == 0 or !protocol.isSafeArgument(orcpt.addr_type) or 406 std.mem.findScalar(u8, orcpt.addr_type, ';') != null) 407 return error.UnsafeArgument; 408 if (orcpt.addr_type.len + 1 + protocol.xtextEncodedLen(orcpt.address) > protocol.Orcpt.max_len) 409 return error.ArgumentTooLong; 410 } 411 try c.writer.print("RCPT TO:<{s}>", .{to}); 412 if (options.notify) |notify| try c.writer.print(" NOTIFY={f}", .{notify}); 413 if (options.orcpt) |orcpt| try c.writer.print(" ORCPT={f}", .{orcpt}); 414 try c.writer.writeAll(protocol.crlf); 415 try c.writer.flush(); 416 _ = try c.expectClass(2); 417} 418 419/// Sends the message content for the current transaction (DATA). Line 420/// endings in `data` are normalized to CRLF and leading dots are stuffed. 421pub fn sendMessage(c: *Client, message_data: []const u8) Error!void { 422 var data_writer = try c.data(); 423 try data_writer.interface.writeAll(message_data); 424 try data_writer.end(); 425} 426 427/// Streams the message content for the current transaction from `message` 428/// until end of stream. Line endings are normalized to CRLF and leading 429/// dots stuffed; nothing is buffered beyond the transport writer, so lines 430/// and messages of any length work. 431pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void { 432 var data_writer = try c.data(); 433 while (true) { 434 const chunk = message.peekGreedy(1) catch |err| switch (err) { 435 error.EndOfStream => break, 436 error.ReadFailed => return error.ReadFailed, 437 }; 438 try data_writer.interface.writeAll(chunk); 439 message.toss(chunk.len); 440 } 441 try data_writer.end(); 442} 443 444/// Starts the DATA phase for streaming a message body: write the content 445/// through the returned writer's `interface`, then call `end`. Line endings 446/// are normalized to CRLF and leading dots stuffed as the data flows. 447pub fn data(c: *Client) Error!DataWriter { 448 try c.send("DATA", .{}); 449 _ = try c.expect(354); 450 return .{ 451 .client = c, 452 .interface = .{ 453 .buffer = &.{}, 454 .vtable = &.{ .drain = DataWriter.drain }, 455 }, 456 }; 457} 458 459/// Streaming writer for a message body; obtained from `data`. The dot 460/// stuffing and CRLF normalization state lives here, so chunks may split 461/// lines (and even CRLF pairs) at any byte boundary. 462pub const DataWriter = struct { 463 client: *Client, 464 interface: Io.Writer, 465 at_line_start: bool = true, 466 /// A '\r' was seen but not yet emitted; whether it is a line ending 467 /// depends on the next byte. 468 pending_cr: bool = false, 469 470 /// Terminates the message (adding a final CRLF if the content did not 471 /// end with one, then ".\r\n") and reads the server's verdict. 472 pub fn end(dw: *DataWriter) Error!void { 473 try dw.interface.flush(); 474 const c = dw.client; 475 if (dw.pending_cr) { 476 // A trailing bare CR counts as a line ending, matching 477 // `protocol.writeStuffed`. 478 dw.pending_cr = false; 479 dw.at_line_start = true; 480 try c.writer.writeAll(protocol.crlf); 481 } 482 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf); 483 try c.writer.writeAll("." ++ protocol.crlf); 484 try c.writer.flush(); 485 _ = try c.expectClass(2); 486 } 487 488 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { 489 const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w)); 490 try dw.writeChunk(w.buffered()); 491 w.end = 0; 492 if (chunks.len == 0) return 0; 493 var n: usize = 0; 494 for (chunks[0 .. chunks.len - 1]) |bytes| { 495 try dw.writeChunk(bytes); 496 n += bytes.len; 497 } 498 const pattern = chunks[chunks.len - 1]; 499 for (0..splat) |_| { 500 try dw.writeChunk(pattern); 501 n += pattern.len; 502 } 503 return n; 504 } 505 506 test end { 507 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 508 var out_buf: [64]u8 = undefined; 509 var writer: Io.Writer = .fixed(&out_buf); 510 var reply_buf: [64]u8 = undefined; 511 var client: Client = .init(&reader, &writer, &reply_buf); 512 513 var data_writer = try client.data(); 514 try data_writer.interface.writeAll("no trailing newline"); 515 try data_writer.end(); // adds the final CRLF, sends ".", reads 250 516 try std.testing.expectEqualStrings( 517 "DATA\r\nno trailing newline\r\n.\r\n", 518 writer.buffered(), 519 ); 520 } 521 522 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void { 523 const out = dw.client.writer; 524 var rest = bytes; 525 while (rest.len > 0) { 526 if (dw.pending_cr) { 527 dw.pending_cr = false; 528 if (rest[0] == '\n') { 529 try out.writeAll(protocol.crlf); 530 dw.at_line_start = true; 531 rest = rest[1..]; 532 continue; 533 } 534 // A bare CR mid-line passes through untouched. 535 try out.writeByte('\r'); 536 dw.at_line_start = false; 537 } 538 if (dw.at_line_start and rest[0] == '.') { 539 try out.writeAll(".."); 540 dw.at_line_start = false; 541 rest = rest[1..]; 542 continue; 543 } 544 const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse { 545 try out.writeAll(rest); 546 dw.at_line_start = false; 547 break; 548 }; 549 if (special > 0) { 550 try out.writeAll(rest[0..special]); 551 dw.at_line_start = false; 552 } 553 switch (rest[special]) { 554 '\r' => dw.pending_cr = true, 555 '\n' => { 556 try out.writeAll(protocol.crlf); 557 dw.at_line_start = true; 558 }, 559 else => unreachable, 560 } 561 rest = rest[special + 1 ..]; 562 } 563 } 564}; 565 566/// Like `mailFrom`, but requests the SMTPUTF8 extension 567/// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the 568/// envelope addresses and message headers may contain UTF-8. Use only when 569/// `Extensions.smtputf8` was advertised. 570pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void { 571 return c.mail(from, .{ .smtputf8 = true }); 572} 573 574/// Sends one BDAT chunk (the CHUNKING extension, 575/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the 576/// server's reply. Use only when `Extensions.chunking` was advertised. The 577/// chunk is transmitted verbatim — no dot-stuffing and no line-ending 578/// normalization — so message content must already use CRLF line endings. 579/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator. 580pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void { 581 if (last) { 582 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len}); 583 } else { 584 try c.writer.print("BDAT {d}\r\n", .{chunk.len}); 585 } 586 try c.writer.writeAll(chunk); 587 try c.writer.flush(); 588 _ = try c.expectClass(2); 589} 590 591/// Sends the message content for the current transaction as a single BDAT 592/// chunk. See `bdat` for the transmission caveats. 593pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void { 594 try c.bdat(message_data, true); 595} 596 597/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 598/// then DATA. Call after `greet` and `hello`. 599pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) (Error || ArgumentError)!void { 600 try c.mailFrom(from); 601 for (recipients) |recipient| try c.rcptTo(recipient); 602 try c.sendMessage(message_data); 603} 604 605/// Aborts the current mail transaction. 606pub fn rset(c: *Client) Error!void { 607 try c.send("RSET", .{}); 608 _ = try c.expectClass(2); 609} 610 611pub fn noop(c: *Client) Error!void { 612 try c.send("NOOP", .{}); 613 _ = try c.expectClass(2); 614} 615 616/// Ends the session. The connection should be closed afterwards. 617pub fn quit(c: *Client) Error!void { 618 try c.send("QUIT", .{}); 619 _ = try c.expect(221); 620} 621 622fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 623 try c.writer.print(fmt ++ protocol.crlf, args); 624 try c.writer.flush(); 625} 626 627fn readReply(c: *Client) Error!Reply { 628 const reply = try Reply.read(c.reader, c.reply_buffer); 629 c.last_reply = reply; 630 return reply; 631} 632 633fn expect(c: *Client, code: u16) Error!Reply { 634 const reply = try c.readReply(); 635 if (reply.code != code) return error.UnexpectedReply; 636 return reply; 637} 638 639fn expectClass(c: *Client, class: u16) Error!Reply { 640 const reply = try c.readReply(); 641 if (reply.code / 100 != class) return error.UnexpectedReply; 642 return reply; 643} 644 645test sendMail { 646 const responses = "220 mx.example.com ESMTP\r\n" ++ 647 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 648 "250 2.1.0 Ok\r\n" ++ 649 "250 2.1.5 Ok\r\n" ++ 650 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 651 "250 2.0.0 Ok\r\n" ++ 652 "221 2.0.0 Bye\r\n"; 653 var reader: Io.Reader = .fixed(responses); 654 var out_buf: [1024]u8 = undefined; 655 var writer: Io.Writer = .fixed(&out_buf); 656 var reply_buf: [512]u8 = undefined; 657 var client: Client = .init(&reader, &writer, &reply_buf); 658 659 _ = try client.greet(); 660 const ext = try client.hello("client.example.org"); 661 try std.testing.expect(ext.pipelining); 662 try std.testing.expect(ext.eight_bit_mime); 663 try std.testing.expect(!ext.starttls); 664 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 665 666 try client.sendMail( 667 "alice@example.com", 668 &.{"bob@example.net"}, 669 "Subject: hi\r\n\r\n.leading dot\r\n", 670 ); 671 try client.quit(); 672 673 try std.testing.expectEqualStrings( 674 "EHLO client.example.org\r\n" ++ 675 "MAIL FROM:<alice@example.com>\r\n" ++ 676 "RCPT TO:<bob@example.net>\r\n" ++ 677 "DATA\r\n" ++ 678 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 679 "QUIT\r\n", 680 writer.buffered(), 681 ); 682} 683 684test "HELO fallback for non-ESMTP servers" { 685 const responses = "220 old.example.com\r\n" ++ 686 "502 command not implemented\r\n" ++ 687 "250 old.example.com\r\n"; 688 var reader: Io.Reader = .fixed(responses); 689 var out_buf: [256]u8 = undefined; 690 var writer: Io.Writer = .fixed(&out_buf); 691 var reply_buf: [256]u8 = undefined; 692 var client: Client = .init(&reader, &writer, &reply_buf); 693 694 _ = try client.greet(); 695 const ext = try client.hello("client.example.org"); 696 try std.testing.expectEqual(Extensions{}, ext); 697 try std.testing.expectEqualStrings( 698 "EHLO client.example.org\r\nHELO client.example.org\r\n", 699 writer.buffered(), 700 ); 701} 702 703test "rejected recipient surfaces the reply" { 704 const responses = "550 5.1.1 No such user\r\n"; 705 var reader: Io.Reader = .fixed(responses); 706 var out_buf: [256]u8 = undefined; 707 var writer: Io.Writer = .fixed(&out_buf); 708 var reply_buf: [256]u8 = undefined; 709 var client: Client = .init(&reader, &writer, &reply_buf); 710 711 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 712 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 713 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 714} 715 716test starttls { 717 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 718 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 719 "220 2.0.0 Ready to start TLS\r\n"; 720 var reader: Io.Reader = .fixed(plain_responses); 721 var out_buf: [256]u8 = undefined; 722 var writer: Io.Writer = .fixed(&out_buf); 723 var reply_buf: [256]u8 = undefined; 724 var client: Client = .init(&reader, &writer, &reply_buf); 725 726 _ = try client.greet(); 727 const ext = try client.hello("client.example.org"); 728 try std.testing.expect(ext.starttls); 729 try client.starttls(); 730 731 // Simulate the post-handshake encrypted transport with fresh buffers; 732 // the session must re-EHLO on it. 733 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 734 var tls_reader: Io.Reader = .fixed(tls_responses); 735 var tls_out_buf: [256]u8 = undefined; 736 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 737 client.setTransport(&tls_reader, &tls_writer, .encrypted); 738 739 const tls_ext = try client.hello("client.example.org"); 740 try std.testing.expect(!tls_ext.starttls); 741 try std.testing.expect(tls_ext.eight_bit_mime); 742 try std.testing.expectEqualStrings( 743 "EHLO client.example.org\r\nSTARTTLS\r\n", 744 writer.buffered(), 745 ); 746 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 747} 748 749test authPlain { 750 const responses = "235 2.7.0 Accepted\r\n"; 751 var reader: Io.Reader = .fixed(responses); 752 var out_buf: [256]u8 = undefined; 753 var writer: Io.Writer = .fixed(&out_buf); 754 var reply_buf: [256]u8 = undefined; 755 var client: Client = .init(&reader, &writer, &reply_buf); 756 client.security = .encrypted; // PLAIN is refused in the clear. 757 758 try client.authPlain("", "user", "pass"); 759 // base64("\x00user\x00pass") 760 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 761} 762 763test authLogin { 764 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n"; 765 var reader: Io.Reader = .fixed(responses); 766 var out_buf: [256]u8 = undefined; 767 var writer: Io.Writer = .fixed(&out_buf); 768 var reply_buf: [256]u8 = undefined; 769 var client: Client = .init(&reader, &writer, &reply_buf); 770 client.security = .encrypted; // LOGIN is refused in the clear. 771 772 try client.authLogin("user", "pass"); 773 try std.testing.expectEqualStrings( 774 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n", 775 writer.buffered(), 776 ); 777} 778 779test authCramMd5 { 780 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", 781 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. 782 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 783 "235 2.7.0 Accepted\r\n"; 784 var reader: Io.Reader = .fixed(responses); 785 var out_buf: [256]u8 = undefined; 786 var writer: Io.Writer = .fixed(&out_buf); 787 var reply_buf: [256]u8 = undefined; 788 var client: Client = .init(&reader, &writer, &reply_buf); 789 790 try client.authCramMd5("tim", "tanstaaftanstaaf"); 791 try std.testing.expectEqualStrings( 792 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 793 writer.buffered(), 794 ); 795} 796 797test authenticate { 798 var out_buf: [256]u8 = undefined; 799 var reply_buf: [256]u8 = undefined; 800 { 801 // Only CRAM-MD5 advertised. 802 const responses = "334 YWJj\r\n235 ok\r\n"; 803 var reader: Io.Reader = .fixed(responses); 804 var writer: Io.Writer = .fixed(&out_buf); 805 var client: Client = .init(&reader, &writer, &reply_buf); 806 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p"); 807 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 808 } 809 { 810 // Nothing advertised. 811 var reader: Io.Reader = .fixed(""); 812 var writer: Io.Writer = .fixed(&out_buf); 813 var client: Client = .init(&reader, &writer, &reply_buf); 814 try std.testing.expectError( 815 error.NoSupportedMechanism, 816 client.authenticate(.{}, "u", "p"), 817 ); 818 } 819} 820 821test "mail and rcpt carry the DSN parameters" { 822 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n"; 823 var reader: Io.Reader = .fixed(responses); 824 var out_buf: [256]u8 = undefined; 825 var writer: Io.Writer = .fixed(&out_buf); 826 var reply_buf: [64]u8 = undefined; 827 var client: Client = .init(&reader, &writer, &reply_buf); 828 829 try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" }); 830 try client.rcpt("bob@example.net", .{ 831 .notify = .{ .on = .{ .failure = true, .delay = true } }, 832 .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" }, 833 }); 834 try std.testing.expectEqualStrings( 835 "MAIL FROM:<me@example.com> RET=HDRS ENVID=batch+207\r\n" ++ 836 "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n", 837 writer.buffered(), 838 ); 839} 840 841test "NOTIFY=NEVER is written on its own" { 842 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 843 var out_buf: [128]u8 = undefined; 844 var writer: Io.Writer = .fixed(&out_buf); 845 var reply_buf: [64]u8 = undefined; 846 var client: Client = .init(&reader, &writer, &reply_buf); 847 848 try client.rcpt("bob@example.net", .{ .notify = .never }); 849 try std.testing.expectEqualStrings( 850 "RCPT TO:<bob@example.net> NOTIFY=NEVER\r\n", 851 writer.buffered(), 852 ); 853} 854 855test "DSN parameter values that exceed their limits are refused" { 856 var reader: Io.Reader = .fixed(""); 857 var out_buf: [1024]u8 = undefined; 858 var writer: Io.Writer = .fixed(&out_buf); 859 var reply_buf: [64]u8 = undefined; 860 var client: Client = .init(&reader, &writer, &reply_buf); 861 862 // 34 spaces encode to 102 characters, over the ENVID limit of 100, 863 // though the value itself is well under it. 864 const spaces = " " ** 34; 865 try std.testing.expectError( 866 error.ArgumentTooLong, 867 client.mail("me@example.com", .{ .envid = spaces }), 868 ); 869 try std.testing.expectError(error.ArgumentTooLong, client.rcpt("bob@example.net", .{ 870 .orcpt = .{ .addr_type = "rfc822", .address = "x" ** 500 }, 871 })); 872 // An addr-type is written literally, so it is checked rather than encoded. 873 try std.testing.expectError(error.UnsafeArgument, client.rcpt("bob@example.net", .{ 874 .orcpt = .{ .addr_type = "rfc822;evil", .address = "x@example.net" }, 875 })); 876 try std.testing.expectEqualStrings("", writer.buffered()); 877} 878 879test "hello reports DSN support" { 880 const responses = "250-mx.example.com\r\n250-DSN\r\n250 8BITMIME\r\n"; 881 var reader: Io.Reader = .fixed(responses); 882 var out_buf: [128]u8 = undefined; 883 var writer: Io.Writer = .fixed(&out_buf); 884 var reply_buf: [256]u8 = undefined; 885 var client: Client = .init(&reader, &writer, &reply_buf); 886 887 const ext = try client.hello("client.example.org"); 888 try std.testing.expect(ext.dsn); 889} 890 891test "an address carrying CRLF cannot inject a command" { 892 // Without the check this would put a second RCPT on the wire. 893 const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net"; 894 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 895 var out_buf: [256]u8 = undefined; 896 var writer: Io.Writer = .fixed(&out_buf); 897 var reply_buf: [64]u8 = undefined; 898 var client: Client = .init(&reader, &writer, &reply_buf); 899 900 try std.testing.expectError(error.UnsafeArgument, client.rcptTo(smuggled)); 901 try std.testing.expectError(error.UnsafeArgument, client.mailFrom(smuggled)); 902 try std.testing.expectError(error.UnsafeArgument, client.mailFromUtf8(smuggled)); 903 try std.testing.expectError(error.UnsafeArgument, client.hello("host\r\nQUIT")); 904 // Nothing reached the wire, so the session is still where it was. 905 try std.testing.expectEqualStrings("", writer.buffered()); 906} 907 908test "a NUL in a PLAIN field cannot shift the credential boundaries" { 909 var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 910 var out_buf: [256]u8 = undefined; 911 var writer: Io.Writer = .fixed(&out_buf); 912 var reply_buf: [64]u8 = undefined; 913 var client: Client = .init(&reader, &writer, &reply_buf); 914 client.security = .encrypted; 915 916 // Decoded by the server as authzid "", username "admin", password "x". 917 try std.testing.expectError( 918 error.UnsafeArgument, 919 client.authPlain("", "user\x00admin\x00x", "pass"), 920 ); 921 try std.testing.expectEqualStrings("", writer.buffered()); 922} 923 924test "cleartext mechanisms are refused on an unencrypted transport" { 925 var reader: Io.Reader = .fixed(""); 926 var out_buf: [256]u8 = undefined; 927 var writer: Io.Writer = .fixed(&out_buf); 928 var reply_buf: [64]u8 = undefined; 929 var client: Client = .init(&reader, &writer, &reply_buf); 930 931 try std.testing.expectError(error.InsecureTransport, client.authPlain("", "u", "p")); 932 try std.testing.expectError(error.InsecureTransport, client.authLogin("u", "p")); 933 // A server offering only those two leaves `authenticate` nothing to use. 934 const cleartext_only: Extensions = .{ .auth = .{ .plain = true, .login = true } }; 935 try std.testing.expectError( 936 error.InsecureTransport, 937 client.authenticate(cleartext_only, "u", "p"), 938 ); 939 try std.testing.expectEqualStrings("", writer.buffered()); 940} 941 942test "authenticate prefers CRAM-MD5 in the clear and PLAIN once encrypted" { 943 const challenge = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 944 "235 2.7.0 Accepted\r\n"; 945 const advertised: Extensions = .{ 946 .auth = .{ .plain = true, .login = true, .cram_md5 = true }, 947 }; 948 949 var reader: Io.Reader = .fixed(challenge); 950 var out_buf: [256]u8 = undefined; 951 var writer: Io.Writer = .fixed(&out_buf); 952 var reply_buf: [256]u8 = undefined; 953 var client: Client = .init(&reader, &writer, &reply_buf); 954 955 // In the clear: the one mechanism that keeps the password off the wire. 956 try client.authenticate(advertised, "tim", "tanstaaftanstaaf"); 957 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 958 959 var tls_reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 960 var tls_out_buf: [256]u8 = undefined; 961 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 962 client.setTransport(&tls_reader, &tls_writer, .encrypted); 963 964 try client.authenticate(advertised, "user", "pass"); 965 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", tls_writer.buffered()); 966} 967 968test "allow_cleartext_auth is the way past the refusal" { 969 var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 970 var out_buf: [256]u8 = undefined; 971 var writer: Io.Writer = .fixed(&out_buf); 972 var reply_buf: [64]u8 = undefined; 973 var client: Client = .init(&reader, &writer, &reply_buf); 974 client.allow_cleartext_auth = true; 975 976 try client.authPlain("", "user", "pass"); 977 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 978} 979 980test "rejected credentials surface AuthenticationFailed" { 981 const responses = "535 5.7.8 Authentication credentials invalid\r\n"; 982 var reader: Io.Reader = .fixed(responses); 983 var out_buf: [256]u8 = undefined; 984 var writer: Io.Writer = .fixed(&out_buf); 985 var reply_buf: [256]u8 = undefined; 986 var client: Client = .init(&reader, &writer, &reply_buf); 987 client.security = .encrypted; 988 989 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p")); 990 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 991} 992 993test hello { 994 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 995 var reader: Io.Reader = .fixed(responses); 996 var out_buf: [256]u8 = undefined; 997 var writer: Io.Writer = .fixed(&out_buf); 998 var reply_buf: [256]u8 = undefined; 999 var client: Client = .init(&reader, &writer, &reply_buf); 1000 1001 const ext = try client.hello("c.example"); 1002 try std.testing.expect(ext.auth.plain); 1003 try std.testing.expect(ext.auth.login); 1004 try std.testing.expect(ext.auth.cram_md5); 1005 try std.testing.expect(ext.auth.any()); 1006} 1007 1008test init { 1009 var reader: Io.Reader = .fixed(""); 1010 var out_buf: [16]u8 = undefined; 1011 var writer: Io.Writer = .fixed(&out_buf); 1012 var reply_buf: [128]u8 = undefined; 1013 const client: Client = .init(&reader, &writer, &reply_buf); 1014 try std.testing.expect(client.last_reply == null); 1015} 1016 1017test greet { 1018 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n"); 1019 var out_buf: [16]u8 = undefined; 1020 var writer: Io.Writer = .fixed(&out_buf); 1021 var reply_buf: [128]u8 = undefined; 1022 var client: Client = .init(&reader, &writer, &reply_buf); 1023 1024 const reply = try client.greet(); 1025 try std.testing.expectEqual(@as(u16, 220), reply.code); 1026 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text); 1027} 1028 1029test setTransport { 1030 var reader: Io.Reader = .fixed(""); 1031 var out_buf: [16]u8 = undefined; 1032 var writer: Io.Writer = .fixed(&out_buf); 1033 var reply_buf: [64]u8 = undefined; 1034 var client: Client = .init(&reader, &writer, &reply_buf); 1035 1036 // After a TLS handshake, point the session at the encrypted streams. 1037 var tls_reader: Io.Reader = .fixed(""); 1038 var tls_out_buf: [16]u8 = undefined; 1039 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 1040 client.setTransport(&tls_reader, &tls_writer, .encrypted); 1041 try std.testing.expectEqual(&tls_reader, client.reader); 1042 try std.testing.expectEqual(&tls_writer, client.writer); 1043 try std.testing.expectEqual(Security.encrypted, client.security); 1044} 1045 1046test mailFrom { 1047 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 1048 var out_buf: [64]u8 = undefined; 1049 var writer: Io.Writer = .fixed(&out_buf); 1050 var reply_buf: [64]u8 = undefined; 1051 var client: Client = .init(&reader, &writer, &reply_buf); 1052 1053 try client.mailFrom("alice@example.com"); 1054 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered()); 1055} 1056 1057test rcptTo { 1058 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 1059 var out_buf: [64]u8 = undefined; 1060 var writer: Io.Writer = .fixed(&out_buf); 1061 var reply_buf: [64]u8 = undefined; 1062 var client: Client = .init(&reader, &writer, &reply_buf); 1063 1064 try client.rcptTo("bob@example.net"); 1065 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered()); 1066} 1067 1068test sendMessage { 1069 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n"); 1070 var out_buf: [128]u8 = undefined; 1071 var writer: Io.Writer = .fixed(&out_buf); 1072 var reply_buf: [64]u8 = undefined; 1073 var client: Client = .init(&reader, &writer, &reply_buf); 1074 1075 try client.sendMessage("Subject: hi\n\nhello\n"); 1076 try std.testing.expectEqualStrings( 1077 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n", 1078 writer.buffered(), 1079 ); 1080} 1081 1082test rset { 1083 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 1084 var out_buf: [16]u8 = undefined; 1085 var writer: Io.Writer = .fixed(&out_buf); 1086 var reply_buf: [64]u8 = undefined; 1087 var client: Client = .init(&reader, &writer, &reply_buf); 1088 1089 try client.rset(); 1090 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered()); 1091} 1092 1093test noop { 1094 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 1095 var out_buf: [16]u8 = undefined; 1096 var writer: Io.Writer = .fixed(&out_buf); 1097 var reply_buf: [64]u8 = undefined; 1098 var client: Client = .init(&reader, &writer, &reply_buf); 1099 1100 try client.noop(); 1101 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered()); 1102} 1103 1104test quit { 1105 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n"); 1106 var out_buf: [16]u8 = undefined; 1107 var writer: Io.Writer = .fixed(&out_buf); 1108 var reply_buf: [64]u8 = undefined; 1109 var client: Client = .init(&reader, &writer, &reply_buf); 1110 1111 try client.quit(); 1112 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered()); 1113} 1114 1115test data { 1116 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 1117 var out_buf: [256]u8 = undefined; 1118 var writer: Io.Writer = .fixed(&out_buf); 1119 var reply_buf: [64]u8 = undefined; 1120 var client: Client = .init(&reader, &writer, &reply_buf); 1121 1122 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily. 1123 var data_writer = try client.data(); 1124 try data_writer.interface.writeAll("Subject: chunked\n\nfirst"); 1125 try data_writer.interface.writeAll(" second\r"); 1126 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r"); 1127 try data_writer.interface.writeAll("\n"); 1128 try data_writer.interface.writeAll(".x\nend"); 1129 try data_writer.end(); 1130 1131 try std.testing.expectEqualStrings( 1132 "DATA\r\n" ++ 1133 "Subject: chunked\r\n" ++ 1134 "\r\n" ++ 1135 "first second\r\n" ++ 1136 "..needs stuffing\r\n" ++ 1137 "split\r\n" ++ 1138 "..x\r\n" ++ 1139 "end\r\n" ++ 1140 ".\r\n", 1141 writer.buffered(), 1142 ); 1143} 1144 1145test sendMessageReader { 1146 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 1147 var out_buf: [128]u8 = undefined; 1148 var writer: Io.Writer = .fixed(&out_buf); 1149 var reply_buf: [64]u8 = undefined; 1150 var client: Client = .init(&reader, &writer, &reply_buf); 1151 1152 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n"); 1153 try client.sendMessageReader(&message); 1154 try std.testing.expectEqualStrings( 1155 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n", 1156 writer.buffered(), 1157 ); 1158} 1159 1160test "fuzz client against arbitrary server replies" { 1161 try std.testing.fuzz({}, fuzzClientReplies, .{}); 1162} 1163 1164fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void { 1165 _ = context; 1166 var input_buf: [1024]u8 = undefined; 1167 const input = input_buf[0..smith.value(u10)]; 1168 smith.bytes(input); 1169 1170 var reader: Io.Reader = .fixed(input); 1171 var out_buf: [4096]u8 = undefined; 1172 var writer: Io.Writer = .fixed(&out_buf); 1173 var reply_buf: [256]u8 = undefined; 1174 var client: Client = .init(&reader, &writer, &reply_buf); 1175 1176 // Whatever the "server" says, the client must fail cleanly, never crash. 1177 _ = client.greet() catch return; 1178 const extensions = client.hello("fuzz.example.org") catch return; 1179 client.authenticate(extensions, "user", "password") catch {}; 1180 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; 1181 client.quit() catch {}; 1182} 1183 1184test "fuzz DataWriter equivalence with writeStuffed" { 1185 try std.testing.fuzz({}, fuzzDataWriter, .{}); 1186} 1187 1188fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void { 1189 _ = context; 1190 var message_buf: [1024]u8 = undefined; 1191 const message = message_buf[0..smith.value(u10)]; 1192 smith.bytes(message); 1193 1194 // Reference implementation: slice-based stuffing. 1195 var expected_buf: [2100]u8 = undefined; 1196 var expected: Io.Writer = .fixed(&expected_buf); 1197 try protocol.writeStuffed(&expected, message); 1198 1199 // Streaming implementation, with fuzzer-chosen chunk boundaries. 1200 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n"); 1201 var out_buf: [2200]u8 = undefined; 1202 var writer: Io.Writer = .fixed(&out_buf); 1203 var reply_buf: [64]u8 = undefined; 1204 var client: Client = .init(&responses, &writer, &reply_buf); 1205 1206 var data_writer = try client.data(); 1207 var rest: []const u8 = message; 1208 while (rest.len > 0) { 1209 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len)); 1210 try data_writer.interface.writeAll(rest[0..n]); 1211 rest = rest[n..]; 1212 } 1213 try data_writer.end(); 1214 1215 const written = writer.buffered(); 1216 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n")); 1217 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); 1218 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; 1219 try std.testing.expectEqualStrings(expected.buffered(), stuffed); 1220} 1221 1222test Extensions { 1223 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; 1224 try std.testing.expect(extensions.pipelining); 1225 try std.testing.expect(!extensions.starttls); 1226 try std.testing.expect(!extensions.auth.any()); 1227 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 1228} 1229 1230test bdat { 1231 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n"); 1232 var out_buf: [128]u8 = undefined; 1233 var writer: Io.Writer = .fixed(&out_buf); 1234 var reply_buf: [64]u8 = undefined; 1235 var client: Client = .init(&reader, &writer, &reply_buf); 1236 1237 try client.bdat("Subject: hi\r\n\r\n", false); 1238 try client.bdat("body\r\n", true); 1239 try std.testing.expectEqualStrings( 1240 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n", 1241 writer.buffered(), 1242 ); 1243} 1244 1245test sendMessageChunked { 1246 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 1247 var out_buf: [128]u8 = undefined; 1248 var writer: Io.Writer = .fixed(&out_buf); 1249 var reply_buf: [64]u8 = undefined; 1250 var client: Client = .init(&reader, &writer, &reply_buf); 1251 1252 // Raw transmission: the leading dot is not stuffed. 1253 try client.sendMessageChunked(".raw\r\n"); 1254 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); 1255} 1256 1257test mailFromUtf8 { 1258 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 1259 var out_buf: [64]u8 = undefined; 1260 var writer: Io.Writer = .fixed(&out_buf); 1261 var reply_buf: [64]u8 = undefined; 1262 var client: Client = .init(&reader, &writer, &reply_buf); 1263 1264 try client.mailFromUtf8("böb@example.com"); 1265 try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered()); 1266}