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