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