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
35 kB 935 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 33pub const Error = error{ 34 WriteFailed, 35 ReadFailed, 36 EndOfStream, 37 LineTooLong, 38 InvalidReply, 39 ReplyTooLong, 40 /// The server answered with an unexpected code; see `last_reply`. 41 UnexpectedReply, 42}; 43 44/// Extensions advertised in the server's EHLO response. 45pub const Extensions = struct { 46 pipelining: bool = false, 47 eight_bit_mime: bool = false, 48 starttls: bool = false, 49 smtputf8: bool = false, 50 chunking: bool = false, 51 enhanced_status_codes: bool = false, 52 /// AUTH mechanisms advertised by the server. 53 auth: Auth = .{}, 54 /// Value of the SIZE extension, if advertised with a value. 55 max_size: ?u64 = null, 56 57 pub const Auth = struct { 58 plain: bool = false, 59 login: bool = false, 60 cram_md5: bool = false, 61 62 pub fn any(a: Auth) bool { 63 return a.plain or a.login or a.cram_md5; 64 } 65 66 test any { 67 try std.testing.expect((Auth{ .login = true }).any()); 68 try std.testing.expect(!(Auth{}).any()); 69 } 70 71 fn parse(arg: []const u8) Auth { 72 var auth: Auth = .{}; 73 var it = std.mem.tokenizeScalar(u8, arg, ' '); 74 while (it.next()) |mechanism| { 75 if (ieql(mechanism, "PLAIN")) { 76 auth.plain = true; 77 } else if (ieql(mechanism, "LOGIN")) { 78 auth.login = true; 79 } else if (ieql(mechanism, "CRAM-MD5")) { 80 auth.cram_md5 = true; 81 } 82 } 83 return auth; 84 } 85 }; 86 87 fn parse(reply: Reply) Extensions { 88 var ext: Extensions = .{}; 89 var it = reply.lines(); 90 _ = it.next(); // The first line is the server's greeting, not a keyword. 91 while (it.next()) |line| { 92 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; 93 const kw = line[0..kw_end]; 94 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else ""; 95 if (ieql(kw, "PIPELINING")) { 96 ext.pipelining = true; 97 } else if (ieql(kw, "8BITMIME")) { 98 ext.eight_bit_mime = true; 99 } else if (ieql(kw, "STARTTLS")) { 100 ext.starttls = true; 101 } else if (ieql(kw, "SMTPUTF8")) { 102 ext.smtputf8 = true; 103 } else if (ieql(kw, "CHUNKING")) { 104 ext.chunking = true; 105 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 106 ext.enhanced_status_codes = true; 107 } else if (ieql(kw, "AUTH")) { 108 ext.auth = Auth.parse(arg); 109 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { 110 // Some legacy servers advertise "AUTH=PLAIN LOGIN". 111 var legacy_arg_buf: [128]u8 = undefined; 112 const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..]; 113 ext.auth = Auth.parse(joined); 114 } else if (ieql(kw, "SIZE")) { 115 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 116 } 117 } 118 return ext; 119 } 120 121 fn ieql(a: []const u8, b: []const u8) bool { 122 return std.ascii.eqlIgnoreCase(a, b); 123 } 124}; 125 126/// `reply_buffer` must be large enough for the largest expected reply text 127/// (the EHLO response is usually the largest); 512 bytes is plenty in 128/// practice. 129pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client { 130 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer }; 131} 132 133/// Reads the server's 220 greeting. Call once, right after connecting. 134pub fn greet(c: *Client) Error!Reply { 135 return c.expect(220); 136} 137 138/// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1)) 139/// and returns the extensions the server advertised, falling back 140/// to plain HELO for servers that do not speak ESMTP. 141pub fn hello(c: *Client, client_name: []const u8) Error!Extensions { 142 try c.send("EHLO {s}", .{client_name}); 143 const reply = try c.readReply(); 144 if (reply.isPositiveCompletion()) return Extensions.parse(reply); 145 if (reply.code == 500 or reply.code == 502) { 146 try c.send("HELO {s}", .{client_name}); 147 _ = try c.expectClass(2); 148 return .{}; 149 } 150 return error.UnexpectedReply; 151} 152 153/// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and 154/// reads the server's 220 go-ahead. On 155/// success, perform a TLS handshake over the underlying stream (see `Tls`), 156/// switch to the encrypted transport with `setTransport`, and then call 157/// `hello` again — the server discards everything it learned before the 158/// handshake, including the EHLO state. 159pub fn starttls(c: *Client) Error!void { 160 try c.send("STARTTLS", .{}); 161 _ = try c.expect(220); 162} 163 164/// Replaces the session's transport, typically with a TLS reader/writer 165/// after `starttls`. 166pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void { 167 c.reader = reader; 168 c.writer = writer; 169} 170 171pub const AuthError = Error || error{ 172 CredentialsTooLong, 173 /// The server rejected the credentials; see `last_reply`. 174 AuthenticationFailed, 175 /// The server's CRAM-MD5 challenge was not valid base64. 176 InvalidChallenge, 177 /// The server advertised none of the supported mechanisms. 178 NoSupportedMechanism, 179}; 180 181/// Authenticates with the best mechanism the server advertised (PLAIN, 182/// then LOGIN, then CRAM-MD5). Note that PLAIN and LOGIN send credentials 183/// unprotected: use TLS on real networks. 184pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void { 185 if (extensions.auth.plain) return c.authPlain("", username, password); 186 if (extensions.auth.login) return c.authLogin(username, password); 187 if (extensions.auth.cram_md5) return c.authCramMd5(username, password); 188 return error.NoSupportedMechanism; 189} 190 191/// Authenticates with AUTH PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)). 192/// Pass an empty `authzid` unless 193/// you need to act on behalf of another identity. Note that sending 194/// credentials over an unencrypted connection exposes them to the network. 195pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { 196 var plain_buf: [512]u8 = undefined; 197 var plain: Io.Writer = .fixed(&plain_buf); 198 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch 199 return error.CredentialsTooLong; 200 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; 201 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); 202 try c.send("AUTH PLAIN {s}", .{b64}); 203 try c.expectAuthSuccess(); 204} 205 206/// Authenticates with AUTH LOGIN, the legacy two-step username/password 207/// exchange still required by some servers (no RFC; the de-facto 208/// [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 209/// mechanism). 210pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { 211 try c.send("AUTH LOGIN", .{}); 212 _ = try c.expect(334); // Username: prompt 213 try c.sendBase64(username); 214 _ = try c.expect(334); // Password: prompt 215 try c.sendBase64(password); 216 try c.expectAuthSuccess(); 217} 218 219/// Authenticates with AUTH CRAM-MD5 ([RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195)): 220/// the password never crosses 221/// the wire, only an HMAC-MD5 of the server's challenge. 222pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { 223 try c.send("AUTH CRAM-MD5", .{}); 224 const reply = try c.expect(334); 225 226 var challenge_buf: [512]u8 = undefined; 227 const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch 228 return error.InvalidChallenge; 229 if (challenge_len > challenge_buf.len) return error.InvalidChallenge; 230 std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch 231 return error.InvalidChallenge; 232 233 var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined; 234 std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password); 235 const digest = std.fmt.bytesToHex(mac, .lower); 236 237 var response_buf: [384]u8 = undefined; 238 var response: Io.Writer = .fixed(&response_buf); 239 response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong; 240 try c.sendBase64(response.buffered()); 241 try c.expectAuthSuccess(); 242} 243 244/// Sends `bytes` base64-encoded as a bare continuation line. 245fn sendBase64(c: *Client, bytes: []const u8) AuthError!void { 246 var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined; 247 if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len) 248 return error.CredentialsTooLong; 249 const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes); 250 try c.send("{s}", .{b64}); 251} 252 253fn expectAuthSuccess(c: *Client) AuthError!void { 254 const reply = try c.readReply(); 255 if (reply.code != 235) return error.AuthenticationFailed; 256} 257 258/// Starts a mail transaction. An empty `from` sends the null reverse-path 259/// (`MAIL FROM:<>`), used for bounces. 260pub fn mailFrom(c: *Client, from: []const u8) Error!void { 261 try c.send("MAIL FROM:<{s}>", .{from}); 262 _ = try c.expectClass(2); 263} 264 265pub fn rcptTo(c: *Client, to: []const u8) Error!void { 266 try c.send("RCPT TO:<{s}>", .{to}); 267 _ = try c.expectClass(2); 268} 269 270/// Sends the message content for the current transaction (DATA). Line 271/// endings in `data` are normalized to CRLF and leading dots are stuffed. 272pub fn sendMessage(c: *Client, message_data: []const u8) Error!void { 273 var data_writer = try c.data(); 274 try data_writer.interface.writeAll(message_data); 275 try data_writer.end(); 276} 277 278/// Streams the message content for the current transaction from `message` 279/// until end of stream. Line endings are normalized to CRLF and leading 280/// dots stuffed; nothing is buffered beyond the transport writer, so lines 281/// and messages of any length work. 282pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void { 283 var data_writer = try c.data(); 284 while (true) { 285 const chunk = message.peekGreedy(1) catch |err| switch (err) { 286 error.EndOfStream => break, 287 error.ReadFailed => return error.ReadFailed, 288 }; 289 try data_writer.interface.writeAll(chunk); 290 message.toss(chunk.len); 291 } 292 try data_writer.end(); 293} 294 295/// Starts the DATA phase for streaming a message body: write the content 296/// through the returned writer's `interface`, then call `end`. Line endings 297/// are normalized to CRLF and leading dots stuffed as the data flows. 298pub fn data(c: *Client) Error!DataWriter { 299 try c.send("DATA", .{}); 300 _ = try c.expect(354); 301 return .{ 302 .client = c, 303 .interface = .{ 304 .buffer = &.{}, 305 .vtable = &.{ .drain = DataWriter.drain }, 306 }, 307 }; 308} 309 310/// Streaming writer for a message body; obtained from `data`. The dot 311/// stuffing and CRLF normalization state lives here, so chunks may split 312/// lines (and even CRLF pairs) at any byte boundary. 313pub const DataWriter = struct { 314 client: *Client, 315 interface: Io.Writer, 316 at_line_start: bool = true, 317 /// A '\r' was seen but not yet emitted; whether it is a line ending 318 /// depends on the next byte. 319 pending_cr: bool = false, 320 321 /// Terminates the message (adding a final CRLF if the content did not 322 /// end with one, then ".\r\n") and reads the server's verdict. 323 pub fn end(dw: *DataWriter) Error!void { 324 try dw.interface.flush(); 325 const c = dw.client; 326 if (dw.pending_cr) { 327 // A trailing bare CR counts as a line ending, matching 328 // `protocol.writeStuffed`. 329 dw.pending_cr = false; 330 dw.at_line_start = true; 331 try c.writer.writeAll(protocol.crlf); 332 } 333 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf); 334 try c.writer.writeAll("." ++ protocol.crlf); 335 try c.writer.flush(); 336 _ = try c.expectClass(2); 337 } 338 339 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { 340 const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w)); 341 try dw.writeChunk(w.buffered()); 342 w.end = 0; 343 if (chunks.len == 0) return 0; 344 var n: usize = 0; 345 for (chunks[0 .. chunks.len - 1]) |bytes| { 346 try dw.writeChunk(bytes); 347 n += bytes.len; 348 } 349 const pattern = chunks[chunks.len - 1]; 350 for (0..splat) |_| { 351 try dw.writeChunk(pattern); 352 n += pattern.len; 353 } 354 return n; 355 } 356 357 test end { 358 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 359 var out_buf: [64]u8 = undefined; 360 var writer: Io.Writer = .fixed(&out_buf); 361 var reply_buf: [64]u8 = undefined; 362 var client: Client = .init(&reader, &writer, &reply_buf); 363 364 var data_writer = try client.data(); 365 try data_writer.interface.writeAll("no trailing newline"); 366 try data_writer.end(); // adds the final CRLF, sends ".", reads 250 367 try std.testing.expectEqualStrings( 368 "DATA\r\nno trailing newline\r\n.\r\n", 369 writer.buffered(), 370 ); 371 } 372 373 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void { 374 const out = dw.client.writer; 375 var rest = bytes; 376 while (rest.len > 0) { 377 if (dw.pending_cr) { 378 dw.pending_cr = false; 379 if (rest[0] == '\n') { 380 try out.writeAll(protocol.crlf); 381 dw.at_line_start = true; 382 rest = rest[1..]; 383 continue; 384 } 385 // A bare CR mid-line passes through untouched. 386 try out.writeByte('\r'); 387 dw.at_line_start = false; 388 } 389 if (dw.at_line_start and rest[0] == '.') { 390 try out.writeAll(".."); 391 dw.at_line_start = false; 392 rest = rest[1..]; 393 continue; 394 } 395 const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse { 396 try out.writeAll(rest); 397 dw.at_line_start = false; 398 break; 399 }; 400 if (special > 0) { 401 try out.writeAll(rest[0..special]); 402 dw.at_line_start = false; 403 } 404 switch (rest[special]) { 405 '\r' => dw.pending_cr = true, 406 '\n' => { 407 try out.writeAll(protocol.crlf); 408 dw.at_line_start = true; 409 }, 410 else => unreachable, 411 } 412 rest = rest[special + 1 ..]; 413 } 414 } 415}; 416 417/// Sends one BDAT chunk (the CHUNKING extension, 418/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the 419/// server's reply. Use only when `Extensions.chunking` was advertised. The 420/// chunk is transmitted verbatim — no dot-stuffing and no line-ending 421/// normalization — so message content must already use CRLF line endings. 422/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator. 423pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void { 424 if (last) { 425 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len}); 426 } else { 427 try c.writer.print("BDAT {d}\r\n", .{chunk.len}); 428 } 429 try c.writer.writeAll(chunk); 430 try c.writer.flush(); 431 _ = try c.expectClass(2); 432} 433 434/// Sends the message content for the current transaction as a single BDAT 435/// chunk. See `bdat` for the transmission caveats. 436pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void { 437 try c.bdat(message_data, true); 438} 439 440/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 441/// then DATA. Call after `greet` and `hello`. 442pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) Error!void { 443 try c.mailFrom(from); 444 for (recipients) |recipient| try c.rcptTo(recipient); 445 try c.sendMessage(message_data); 446} 447 448/// Aborts the current mail transaction. 449pub fn rset(c: *Client) Error!void { 450 try c.send("RSET", .{}); 451 _ = try c.expectClass(2); 452} 453 454pub fn noop(c: *Client) Error!void { 455 try c.send("NOOP", .{}); 456 _ = try c.expectClass(2); 457} 458 459/// Ends the session. The connection should be closed afterwards. 460pub fn quit(c: *Client) Error!void { 461 try c.send("QUIT", .{}); 462 _ = try c.expect(221); 463} 464 465fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 466 try c.writer.print(fmt ++ protocol.crlf, args); 467 try c.writer.flush(); 468} 469 470fn readReply(c: *Client) Error!Reply { 471 const reply = try Reply.read(c.reader, c.reply_buffer); 472 c.last_reply = reply; 473 return reply; 474} 475 476fn expect(c: *Client, code: u16) Error!Reply { 477 const reply = try c.readReply(); 478 if (reply.code != code) return error.UnexpectedReply; 479 return reply; 480} 481 482fn expectClass(c: *Client, class: u16) Error!Reply { 483 const reply = try c.readReply(); 484 if (reply.code / 100 != class) return error.UnexpectedReply; 485 return reply; 486} 487 488test sendMail { 489 const responses = "220 mx.example.com ESMTP\r\n" ++ 490 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 491 "250 2.1.0 Ok\r\n" ++ 492 "250 2.1.5 Ok\r\n" ++ 493 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 494 "250 2.0.0 Ok\r\n" ++ 495 "221 2.0.0 Bye\r\n"; 496 var reader: Io.Reader = .fixed(responses); 497 var out_buf: [1024]u8 = undefined; 498 var writer: Io.Writer = .fixed(&out_buf); 499 var reply_buf: [512]u8 = undefined; 500 var client: Client = .init(&reader, &writer, &reply_buf); 501 502 _ = try client.greet(); 503 const ext = try client.hello("client.example.org"); 504 try std.testing.expect(ext.pipelining); 505 try std.testing.expect(ext.eight_bit_mime); 506 try std.testing.expect(!ext.starttls); 507 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 508 509 try client.sendMail( 510 "alice@example.com", 511 &.{"bob@example.net"}, 512 "Subject: hi\r\n\r\n.leading dot\r\n", 513 ); 514 try client.quit(); 515 516 try std.testing.expectEqualStrings( 517 "EHLO client.example.org\r\n" ++ 518 "MAIL FROM:<alice@example.com>\r\n" ++ 519 "RCPT TO:<bob@example.net>\r\n" ++ 520 "DATA\r\n" ++ 521 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 522 "QUIT\r\n", 523 writer.buffered(), 524 ); 525} 526 527test "HELO fallback for non-ESMTP servers" { 528 const responses = "220 old.example.com\r\n" ++ 529 "502 command not implemented\r\n" ++ 530 "250 old.example.com\r\n"; 531 var reader: Io.Reader = .fixed(responses); 532 var out_buf: [256]u8 = undefined; 533 var writer: Io.Writer = .fixed(&out_buf); 534 var reply_buf: [256]u8 = undefined; 535 var client: Client = .init(&reader, &writer, &reply_buf); 536 537 _ = try client.greet(); 538 const ext = try client.hello("client.example.org"); 539 try std.testing.expectEqual(Extensions{}, ext); 540 try std.testing.expectEqualStrings( 541 "EHLO client.example.org\r\nHELO client.example.org\r\n", 542 writer.buffered(), 543 ); 544} 545 546test "rejected recipient surfaces the reply" { 547 const responses = "550 5.1.1 No such user\r\n"; 548 var reader: Io.Reader = .fixed(responses); 549 var out_buf: [256]u8 = undefined; 550 var writer: Io.Writer = .fixed(&out_buf); 551 var reply_buf: [256]u8 = undefined; 552 var client: Client = .init(&reader, &writer, &reply_buf); 553 554 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 555 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 556 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 557} 558 559test starttls { 560 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 561 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 562 "220 2.0.0 Ready to start TLS\r\n"; 563 var reader: Io.Reader = .fixed(plain_responses); 564 var out_buf: [256]u8 = undefined; 565 var writer: Io.Writer = .fixed(&out_buf); 566 var reply_buf: [256]u8 = undefined; 567 var client: Client = .init(&reader, &writer, &reply_buf); 568 569 _ = try client.greet(); 570 const ext = try client.hello("client.example.org"); 571 try std.testing.expect(ext.starttls); 572 try client.starttls(); 573 574 // Simulate the post-handshake encrypted transport with fresh buffers; 575 // the session must re-EHLO on it. 576 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 577 var tls_reader: Io.Reader = .fixed(tls_responses); 578 var tls_out_buf: [256]u8 = undefined; 579 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 580 client.setTransport(&tls_reader, &tls_writer); 581 582 const tls_ext = try client.hello("client.example.org"); 583 try std.testing.expect(!tls_ext.starttls); 584 try std.testing.expect(tls_ext.eight_bit_mime); 585 try std.testing.expectEqualStrings( 586 "EHLO client.example.org\r\nSTARTTLS\r\n", 587 writer.buffered(), 588 ); 589 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 590} 591 592test authPlain { 593 const responses = "235 2.7.0 Accepted\r\n"; 594 var reader: Io.Reader = .fixed(responses); 595 var out_buf: [256]u8 = undefined; 596 var writer: Io.Writer = .fixed(&out_buf); 597 var reply_buf: [256]u8 = undefined; 598 var client: Client = .init(&reader, &writer, &reply_buf); 599 600 try client.authPlain("", "user", "pass"); 601 // base64("\x00user\x00pass") 602 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 603} 604 605test authLogin { 606 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n"; 607 var reader: Io.Reader = .fixed(responses); 608 var out_buf: [256]u8 = undefined; 609 var writer: Io.Writer = .fixed(&out_buf); 610 var reply_buf: [256]u8 = undefined; 611 var client: Client = .init(&reader, &writer, &reply_buf); 612 613 try client.authLogin("user", "pass"); 614 try std.testing.expectEqualStrings( 615 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n", 616 writer.buffered(), 617 ); 618} 619 620test authCramMd5 { 621 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", 622 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. 623 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 624 "235 2.7.0 Accepted\r\n"; 625 var reader: Io.Reader = .fixed(responses); 626 var out_buf: [256]u8 = undefined; 627 var writer: Io.Writer = .fixed(&out_buf); 628 var reply_buf: [256]u8 = undefined; 629 var client: Client = .init(&reader, &writer, &reply_buf); 630 631 try client.authCramMd5("tim", "tanstaaftanstaaf"); 632 try std.testing.expectEqualStrings( 633 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 634 writer.buffered(), 635 ); 636} 637 638test authenticate { 639 var out_buf: [256]u8 = undefined; 640 var reply_buf: [256]u8 = undefined; 641 { 642 // Only CRAM-MD5 advertised. 643 const responses = "334 YWJj\r\n235 ok\r\n"; 644 var reader: Io.Reader = .fixed(responses); 645 var writer: Io.Writer = .fixed(&out_buf); 646 var client: Client = .init(&reader, &writer, &reply_buf); 647 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p"); 648 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 649 } 650 { 651 // Nothing advertised. 652 var reader: Io.Reader = .fixed(""); 653 var writer: Io.Writer = .fixed(&out_buf); 654 var client: Client = .init(&reader, &writer, &reply_buf); 655 try std.testing.expectError( 656 error.NoSupportedMechanism, 657 client.authenticate(.{}, "u", "p"), 658 ); 659 } 660} 661 662test "rejected credentials surface AuthenticationFailed" { 663 const responses = "535 5.7.8 Authentication credentials invalid\r\n"; 664 var reader: Io.Reader = .fixed(responses); 665 var out_buf: [256]u8 = undefined; 666 var writer: Io.Writer = .fixed(&out_buf); 667 var reply_buf: [256]u8 = undefined; 668 var client: Client = .init(&reader, &writer, &reply_buf); 669 670 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p")); 671 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 672} 673 674test hello { 675 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 676 var reader: Io.Reader = .fixed(responses); 677 var out_buf: [256]u8 = undefined; 678 var writer: Io.Writer = .fixed(&out_buf); 679 var reply_buf: [256]u8 = undefined; 680 var client: Client = .init(&reader, &writer, &reply_buf); 681 682 const ext = try client.hello("c.example"); 683 try std.testing.expect(ext.auth.plain); 684 try std.testing.expect(ext.auth.login); 685 try std.testing.expect(ext.auth.cram_md5); 686 try std.testing.expect(ext.auth.any()); 687} 688 689test init { 690 var reader: Io.Reader = .fixed(""); 691 var out_buf: [16]u8 = undefined; 692 var writer: Io.Writer = .fixed(&out_buf); 693 var reply_buf: [128]u8 = undefined; 694 const client: Client = .init(&reader, &writer, &reply_buf); 695 try std.testing.expect(client.last_reply == null); 696} 697 698test greet { 699 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n"); 700 var out_buf: [16]u8 = undefined; 701 var writer: Io.Writer = .fixed(&out_buf); 702 var reply_buf: [128]u8 = undefined; 703 var client: Client = .init(&reader, &writer, &reply_buf); 704 705 const reply = try client.greet(); 706 try std.testing.expectEqual(@as(u16, 220), reply.code); 707 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text); 708} 709 710test setTransport { 711 var reader: Io.Reader = .fixed(""); 712 var out_buf: [16]u8 = undefined; 713 var writer: Io.Writer = .fixed(&out_buf); 714 var reply_buf: [64]u8 = undefined; 715 var client: Client = .init(&reader, &writer, &reply_buf); 716 717 // After a TLS handshake, point the session at the encrypted streams. 718 var tls_reader: Io.Reader = .fixed(""); 719 var tls_out_buf: [16]u8 = undefined; 720 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 721 client.setTransport(&tls_reader, &tls_writer); 722 try std.testing.expectEqual(&tls_reader, client.reader); 723 try std.testing.expectEqual(&tls_writer, client.writer); 724} 725 726test mailFrom { 727 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 728 var out_buf: [64]u8 = undefined; 729 var writer: Io.Writer = .fixed(&out_buf); 730 var reply_buf: [64]u8 = undefined; 731 var client: Client = .init(&reader, &writer, &reply_buf); 732 733 try client.mailFrom("alice@example.com"); 734 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered()); 735} 736 737test rcptTo { 738 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 739 var out_buf: [64]u8 = undefined; 740 var writer: Io.Writer = .fixed(&out_buf); 741 var reply_buf: [64]u8 = undefined; 742 var client: Client = .init(&reader, &writer, &reply_buf); 743 744 try client.rcptTo("bob@example.net"); 745 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered()); 746} 747 748test sendMessage { 749 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n"); 750 var out_buf: [128]u8 = undefined; 751 var writer: Io.Writer = .fixed(&out_buf); 752 var reply_buf: [64]u8 = undefined; 753 var client: Client = .init(&reader, &writer, &reply_buf); 754 755 try client.sendMessage("Subject: hi\n\nhello\n"); 756 try std.testing.expectEqualStrings( 757 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n", 758 writer.buffered(), 759 ); 760} 761 762test rset { 763 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 764 var out_buf: [16]u8 = undefined; 765 var writer: Io.Writer = .fixed(&out_buf); 766 var reply_buf: [64]u8 = undefined; 767 var client: Client = .init(&reader, &writer, &reply_buf); 768 769 try client.rset(); 770 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered()); 771} 772 773test noop { 774 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 775 var out_buf: [16]u8 = undefined; 776 var writer: Io.Writer = .fixed(&out_buf); 777 var reply_buf: [64]u8 = undefined; 778 var client: Client = .init(&reader, &writer, &reply_buf); 779 780 try client.noop(); 781 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered()); 782} 783 784test quit { 785 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n"); 786 var out_buf: [16]u8 = undefined; 787 var writer: Io.Writer = .fixed(&out_buf); 788 var reply_buf: [64]u8 = undefined; 789 var client: Client = .init(&reader, &writer, &reply_buf); 790 791 try client.quit(); 792 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered()); 793} 794 795test data { 796 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 797 var out_buf: [256]u8 = undefined; 798 var writer: Io.Writer = .fixed(&out_buf); 799 var reply_buf: [64]u8 = undefined; 800 var client: Client = .init(&reader, &writer, &reply_buf); 801 802 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily. 803 var data_writer = try client.data(); 804 try data_writer.interface.writeAll("Subject: chunked\n\nfirst"); 805 try data_writer.interface.writeAll(" second\r"); 806 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r"); 807 try data_writer.interface.writeAll("\n"); 808 try data_writer.interface.writeAll(".x\nend"); 809 try data_writer.end(); 810 811 try std.testing.expectEqualStrings( 812 "DATA\r\n" ++ 813 "Subject: chunked\r\n" ++ 814 "\r\n" ++ 815 "first second\r\n" ++ 816 "..needs stuffing\r\n" ++ 817 "split\r\n" ++ 818 "..x\r\n" ++ 819 "end\r\n" ++ 820 ".\r\n", 821 writer.buffered(), 822 ); 823} 824 825test sendMessageReader { 826 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 827 var out_buf: [128]u8 = undefined; 828 var writer: Io.Writer = .fixed(&out_buf); 829 var reply_buf: [64]u8 = undefined; 830 var client: Client = .init(&reader, &writer, &reply_buf); 831 832 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n"); 833 try client.sendMessageReader(&message); 834 try std.testing.expectEqualStrings( 835 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n", 836 writer.buffered(), 837 ); 838} 839 840test "fuzz client against arbitrary server replies" { 841 try std.testing.fuzz({}, fuzzClientReplies, .{}); 842} 843 844fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void { 845 _ = context; 846 var input_buf: [1024]u8 = undefined; 847 const input = input_buf[0..smith.value(u10)]; 848 smith.bytes(input); 849 850 var reader: Io.Reader = .fixed(input); 851 var out_buf: [4096]u8 = undefined; 852 var writer: Io.Writer = .fixed(&out_buf); 853 var reply_buf: [256]u8 = undefined; 854 var client: Client = .init(&reader, &writer, &reply_buf); 855 856 // Whatever the "server" says, the client must fail cleanly, never crash. 857 _ = client.greet() catch return; 858 const extensions = client.hello("fuzz.example.org") catch return; 859 client.authenticate(extensions, "user", "password") catch {}; 860 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; 861 client.quit() catch {}; 862} 863 864test "fuzz DataWriter equivalence with writeStuffed" { 865 try std.testing.fuzz({}, fuzzDataWriter, .{}); 866} 867 868fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void { 869 _ = context; 870 var message_buf: [1024]u8 = undefined; 871 const message = message_buf[0..smith.value(u10)]; 872 smith.bytes(message); 873 874 // Reference implementation: slice-based stuffing. 875 var expected_buf: [2100]u8 = undefined; 876 var expected: Io.Writer = .fixed(&expected_buf); 877 try protocol.writeStuffed(&expected, message); 878 879 // Streaming implementation, with fuzzer-chosen chunk boundaries. 880 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n"); 881 var out_buf: [2200]u8 = undefined; 882 var writer: Io.Writer = .fixed(&out_buf); 883 var reply_buf: [64]u8 = undefined; 884 var client: Client = .init(&responses, &writer, &reply_buf); 885 886 var data_writer = try client.data(); 887 var rest: []const u8 = message; 888 while (rest.len > 0) { 889 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len)); 890 try data_writer.interface.writeAll(rest[0..n]); 891 rest = rest[n..]; 892 } 893 try data_writer.end(); 894 895 const written = writer.buffered(); 896 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n")); 897 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); 898 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; 899 try std.testing.expectEqualStrings(expected.buffered(), stuffed); 900} 901 902test Extensions { 903 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; 904 try std.testing.expect(extensions.pipelining); 905 try std.testing.expect(!extensions.starttls); 906 try std.testing.expect(!extensions.auth.any()); 907 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 908} 909 910test bdat { 911 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n"); 912 var out_buf: [128]u8 = undefined; 913 var writer: Io.Writer = .fixed(&out_buf); 914 var reply_buf: [64]u8 = undefined; 915 var client: Client = .init(&reader, &writer, &reply_buf); 916 917 try client.bdat("Subject: hi\r\n\r\n", false); 918 try client.bdat("body\r\n", true); 919 try std.testing.expectEqualStrings( 920 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n", 921 writer.buffered(), 922 ); 923} 924 925test sendMessageChunked { 926 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 927 var out_buf: [128]u8 = undefined; 928 var writer: Io.Writer = .fixed(&out_buf); 929 var reply_buf: [64]u8 = undefined; 930 var client: Client = .init(&reader, &writer, &reply_buf); 931 932 // Raw transmission: the leading dot is not stuffed. 933 try client.sendMessageChunked(".raw\r\n"); 934 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); 935}