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
36 kB 955 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/// Like `mailFrom`, but requests the SMTPUTF8 extension 418/// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the 419/// envelope addresses and message headers may contain UTF-8. Use only when 420/// `Extensions.smtputf8` was advertised. 421pub fn mailFromUtf8(c: *Client, from: []const u8) Error!void { 422 try c.send("MAIL FROM:<{s}> SMTPUTF8", .{from}); 423 _ = try c.expectClass(2); 424} 425 426/// Sends one BDAT chunk (the CHUNKING extension, 427/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the 428/// server's reply. Use only when `Extensions.chunking` was advertised. The 429/// chunk is transmitted verbatim — no dot-stuffing and no line-ending 430/// normalization — so message content must already use CRLF line endings. 431/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator. 432pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void { 433 if (last) { 434 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len}); 435 } else { 436 try c.writer.print("BDAT {d}\r\n", .{chunk.len}); 437 } 438 try c.writer.writeAll(chunk); 439 try c.writer.flush(); 440 _ = try c.expectClass(2); 441} 442 443/// Sends the message content for the current transaction as a single BDAT 444/// chunk. See `bdat` for the transmission caveats. 445pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void { 446 try c.bdat(message_data, true); 447} 448 449/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 450/// then DATA. Call after `greet` and `hello`. 451pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) Error!void { 452 try c.mailFrom(from); 453 for (recipients) |recipient| try c.rcptTo(recipient); 454 try c.sendMessage(message_data); 455} 456 457/// Aborts the current mail transaction. 458pub fn rset(c: *Client) Error!void { 459 try c.send("RSET", .{}); 460 _ = try c.expectClass(2); 461} 462 463pub fn noop(c: *Client) Error!void { 464 try c.send("NOOP", .{}); 465 _ = try c.expectClass(2); 466} 467 468/// Ends the session. The connection should be closed afterwards. 469pub fn quit(c: *Client) Error!void { 470 try c.send("QUIT", .{}); 471 _ = try c.expect(221); 472} 473 474fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 475 try c.writer.print(fmt ++ protocol.crlf, args); 476 try c.writer.flush(); 477} 478 479fn readReply(c: *Client) Error!Reply { 480 const reply = try Reply.read(c.reader, c.reply_buffer); 481 c.last_reply = reply; 482 return reply; 483} 484 485fn expect(c: *Client, code: u16) Error!Reply { 486 const reply = try c.readReply(); 487 if (reply.code != code) return error.UnexpectedReply; 488 return reply; 489} 490 491fn expectClass(c: *Client, class: u16) Error!Reply { 492 const reply = try c.readReply(); 493 if (reply.code / 100 != class) return error.UnexpectedReply; 494 return reply; 495} 496 497test sendMail { 498 const responses = "220 mx.example.com ESMTP\r\n" ++ 499 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 500 "250 2.1.0 Ok\r\n" ++ 501 "250 2.1.5 Ok\r\n" ++ 502 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 503 "250 2.0.0 Ok\r\n" ++ 504 "221 2.0.0 Bye\r\n"; 505 var reader: Io.Reader = .fixed(responses); 506 var out_buf: [1024]u8 = undefined; 507 var writer: Io.Writer = .fixed(&out_buf); 508 var reply_buf: [512]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.expect(ext.pipelining); 514 try std.testing.expect(ext.eight_bit_mime); 515 try std.testing.expect(!ext.starttls); 516 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 517 518 try client.sendMail( 519 "alice@example.com", 520 &.{"bob@example.net"}, 521 "Subject: hi\r\n\r\n.leading dot\r\n", 522 ); 523 try client.quit(); 524 525 try std.testing.expectEqualStrings( 526 "EHLO client.example.org\r\n" ++ 527 "MAIL FROM:<alice@example.com>\r\n" ++ 528 "RCPT TO:<bob@example.net>\r\n" ++ 529 "DATA\r\n" ++ 530 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 531 "QUIT\r\n", 532 writer.buffered(), 533 ); 534} 535 536test "HELO fallback for non-ESMTP servers" { 537 const responses = "220 old.example.com\r\n" ++ 538 "502 command not implemented\r\n" ++ 539 "250 old.example.com\r\n"; 540 var reader: Io.Reader = .fixed(responses); 541 var out_buf: [256]u8 = undefined; 542 var writer: Io.Writer = .fixed(&out_buf); 543 var reply_buf: [256]u8 = undefined; 544 var client: Client = .init(&reader, &writer, &reply_buf); 545 546 _ = try client.greet(); 547 const ext = try client.hello("client.example.org"); 548 try std.testing.expectEqual(Extensions{}, ext); 549 try std.testing.expectEqualStrings( 550 "EHLO client.example.org\r\nHELO client.example.org\r\n", 551 writer.buffered(), 552 ); 553} 554 555test "rejected recipient surfaces the reply" { 556 const responses = "550 5.1.1 No such user\r\n"; 557 var reader: Io.Reader = .fixed(responses); 558 var out_buf: [256]u8 = undefined; 559 var writer: Io.Writer = .fixed(&out_buf); 560 var reply_buf: [256]u8 = undefined; 561 var client: Client = .init(&reader, &writer, &reply_buf); 562 563 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 564 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 565 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 566} 567 568test starttls { 569 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 570 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 571 "220 2.0.0 Ready to start TLS\r\n"; 572 var reader: Io.Reader = .fixed(plain_responses); 573 var out_buf: [256]u8 = undefined; 574 var writer: Io.Writer = .fixed(&out_buf); 575 var reply_buf: [256]u8 = undefined; 576 var client: Client = .init(&reader, &writer, &reply_buf); 577 578 _ = try client.greet(); 579 const ext = try client.hello("client.example.org"); 580 try std.testing.expect(ext.starttls); 581 try client.starttls(); 582 583 // Simulate the post-handshake encrypted transport with fresh buffers; 584 // the session must re-EHLO on it. 585 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 586 var tls_reader: Io.Reader = .fixed(tls_responses); 587 var tls_out_buf: [256]u8 = undefined; 588 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 589 client.setTransport(&tls_reader, &tls_writer); 590 591 const tls_ext = try client.hello("client.example.org"); 592 try std.testing.expect(!tls_ext.starttls); 593 try std.testing.expect(tls_ext.eight_bit_mime); 594 try std.testing.expectEqualStrings( 595 "EHLO client.example.org\r\nSTARTTLS\r\n", 596 writer.buffered(), 597 ); 598 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 599} 600 601test authPlain { 602 const responses = "235 2.7.0 Accepted\r\n"; 603 var reader: Io.Reader = .fixed(responses); 604 var out_buf: [256]u8 = undefined; 605 var writer: Io.Writer = .fixed(&out_buf); 606 var reply_buf: [256]u8 = undefined; 607 var client: Client = .init(&reader, &writer, &reply_buf); 608 609 try client.authPlain("", "user", "pass"); 610 // base64("\x00user\x00pass") 611 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 612} 613 614test authLogin { 615 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n"; 616 var reader: Io.Reader = .fixed(responses); 617 var out_buf: [256]u8 = undefined; 618 var writer: Io.Writer = .fixed(&out_buf); 619 var reply_buf: [256]u8 = undefined; 620 var client: Client = .init(&reader, &writer, &reply_buf); 621 622 try client.authLogin("user", "pass"); 623 try std.testing.expectEqualStrings( 624 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n", 625 writer.buffered(), 626 ); 627} 628 629test authCramMd5 { 630 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", 631 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. 632 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 633 "235 2.7.0 Accepted\r\n"; 634 var reader: Io.Reader = .fixed(responses); 635 var out_buf: [256]u8 = undefined; 636 var writer: Io.Writer = .fixed(&out_buf); 637 var reply_buf: [256]u8 = undefined; 638 var client: Client = .init(&reader, &writer, &reply_buf); 639 640 try client.authCramMd5("tim", "tanstaaftanstaaf"); 641 try std.testing.expectEqualStrings( 642 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 643 writer.buffered(), 644 ); 645} 646 647test authenticate { 648 var out_buf: [256]u8 = undefined; 649 var reply_buf: [256]u8 = undefined; 650 { 651 // Only CRAM-MD5 advertised. 652 const responses = "334 YWJj\r\n235 ok\r\n"; 653 var reader: Io.Reader = .fixed(responses); 654 var writer: Io.Writer = .fixed(&out_buf); 655 var client: Client = .init(&reader, &writer, &reply_buf); 656 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p"); 657 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 658 } 659 { 660 // Nothing advertised. 661 var reader: Io.Reader = .fixed(""); 662 var writer: Io.Writer = .fixed(&out_buf); 663 var client: Client = .init(&reader, &writer, &reply_buf); 664 try std.testing.expectError( 665 error.NoSupportedMechanism, 666 client.authenticate(.{}, "u", "p"), 667 ); 668 } 669} 670 671test "rejected credentials surface AuthenticationFailed" { 672 const responses = "535 5.7.8 Authentication credentials invalid\r\n"; 673 var reader: Io.Reader = .fixed(responses); 674 var out_buf: [256]u8 = undefined; 675 var writer: Io.Writer = .fixed(&out_buf); 676 var reply_buf: [256]u8 = undefined; 677 var client: Client = .init(&reader, &writer, &reply_buf); 678 679 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p")); 680 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 681} 682 683test hello { 684 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 685 var reader: Io.Reader = .fixed(responses); 686 var out_buf: [256]u8 = undefined; 687 var writer: Io.Writer = .fixed(&out_buf); 688 var reply_buf: [256]u8 = undefined; 689 var client: Client = .init(&reader, &writer, &reply_buf); 690 691 const ext = try client.hello("c.example"); 692 try std.testing.expect(ext.auth.plain); 693 try std.testing.expect(ext.auth.login); 694 try std.testing.expect(ext.auth.cram_md5); 695 try std.testing.expect(ext.auth.any()); 696} 697 698test init { 699 var reader: Io.Reader = .fixed(""); 700 var out_buf: [16]u8 = undefined; 701 var writer: Io.Writer = .fixed(&out_buf); 702 var reply_buf: [128]u8 = undefined; 703 const client: Client = .init(&reader, &writer, &reply_buf); 704 try std.testing.expect(client.last_reply == null); 705} 706 707test greet { 708 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n"); 709 var out_buf: [16]u8 = undefined; 710 var writer: Io.Writer = .fixed(&out_buf); 711 var reply_buf: [128]u8 = undefined; 712 var client: Client = .init(&reader, &writer, &reply_buf); 713 714 const reply = try client.greet(); 715 try std.testing.expectEqual(@as(u16, 220), reply.code); 716 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text); 717} 718 719test setTransport { 720 var reader: Io.Reader = .fixed(""); 721 var out_buf: [16]u8 = undefined; 722 var writer: Io.Writer = .fixed(&out_buf); 723 var reply_buf: [64]u8 = undefined; 724 var client: Client = .init(&reader, &writer, &reply_buf); 725 726 // After a TLS handshake, point the session at the encrypted streams. 727 var tls_reader: Io.Reader = .fixed(""); 728 var tls_out_buf: [16]u8 = undefined; 729 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 730 client.setTransport(&tls_reader, &tls_writer); 731 try std.testing.expectEqual(&tls_reader, client.reader); 732 try std.testing.expectEqual(&tls_writer, client.writer); 733} 734 735test mailFrom { 736 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 737 var out_buf: [64]u8 = undefined; 738 var writer: Io.Writer = .fixed(&out_buf); 739 var reply_buf: [64]u8 = undefined; 740 var client: Client = .init(&reader, &writer, &reply_buf); 741 742 try client.mailFrom("alice@example.com"); 743 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered()); 744} 745 746test rcptTo { 747 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 748 var out_buf: [64]u8 = undefined; 749 var writer: Io.Writer = .fixed(&out_buf); 750 var reply_buf: [64]u8 = undefined; 751 var client: Client = .init(&reader, &writer, &reply_buf); 752 753 try client.rcptTo("bob@example.net"); 754 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered()); 755} 756 757test sendMessage { 758 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n"); 759 var out_buf: [128]u8 = undefined; 760 var writer: Io.Writer = .fixed(&out_buf); 761 var reply_buf: [64]u8 = undefined; 762 var client: Client = .init(&reader, &writer, &reply_buf); 763 764 try client.sendMessage("Subject: hi\n\nhello\n"); 765 try std.testing.expectEqualStrings( 766 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n", 767 writer.buffered(), 768 ); 769} 770 771test rset { 772 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 773 var out_buf: [16]u8 = undefined; 774 var writer: Io.Writer = .fixed(&out_buf); 775 var reply_buf: [64]u8 = undefined; 776 var client: Client = .init(&reader, &writer, &reply_buf); 777 778 try client.rset(); 779 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered()); 780} 781 782test noop { 783 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 784 var out_buf: [16]u8 = undefined; 785 var writer: Io.Writer = .fixed(&out_buf); 786 var reply_buf: [64]u8 = undefined; 787 var client: Client = .init(&reader, &writer, &reply_buf); 788 789 try client.noop(); 790 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered()); 791} 792 793test quit { 794 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n"); 795 var out_buf: [16]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 try client.quit(); 801 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered()); 802} 803 804test data { 805 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 806 var out_buf: [256]u8 = undefined; 807 var writer: Io.Writer = .fixed(&out_buf); 808 var reply_buf: [64]u8 = undefined; 809 var client: Client = .init(&reader, &writer, &reply_buf); 810 811 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily. 812 var data_writer = try client.data(); 813 try data_writer.interface.writeAll("Subject: chunked\n\nfirst"); 814 try data_writer.interface.writeAll(" second\r"); 815 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r"); 816 try data_writer.interface.writeAll("\n"); 817 try data_writer.interface.writeAll(".x\nend"); 818 try data_writer.end(); 819 820 try std.testing.expectEqualStrings( 821 "DATA\r\n" ++ 822 "Subject: chunked\r\n" ++ 823 "\r\n" ++ 824 "first second\r\n" ++ 825 "..needs stuffing\r\n" ++ 826 "split\r\n" ++ 827 "..x\r\n" ++ 828 "end\r\n" ++ 829 ".\r\n", 830 writer.buffered(), 831 ); 832} 833 834test sendMessageReader { 835 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 836 var out_buf: [128]u8 = undefined; 837 var writer: Io.Writer = .fixed(&out_buf); 838 var reply_buf: [64]u8 = undefined; 839 var client: Client = .init(&reader, &writer, &reply_buf); 840 841 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n"); 842 try client.sendMessageReader(&message); 843 try std.testing.expectEqualStrings( 844 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n", 845 writer.buffered(), 846 ); 847} 848 849test "fuzz client against arbitrary server replies" { 850 try std.testing.fuzz({}, fuzzClientReplies, .{}); 851} 852 853fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void { 854 _ = context; 855 var input_buf: [1024]u8 = undefined; 856 const input = input_buf[0..smith.value(u10)]; 857 smith.bytes(input); 858 859 var reader: Io.Reader = .fixed(input); 860 var out_buf: [4096]u8 = undefined; 861 var writer: Io.Writer = .fixed(&out_buf); 862 var reply_buf: [256]u8 = undefined; 863 var client: Client = .init(&reader, &writer, &reply_buf); 864 865 // Whatever the "server" says, the client must fail cleanly, never crash. 866 _ = client.greet() catch return; 867 const extensions = client.hello("fuzz.example.org") catch return; 868 client.authenticate(extensions, "user", "password") catch {}; 869 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; 870 client.quit() catch {}; 871} 872 873test "fuzz DataWriter equivalence with writeStuffed" { 874 try std.testing.fuzz({}, fuzzDataWriter, .{}); 875} 876 877fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void { 878 _ = context; 879 var message_buf: [1024]u8 = undefined; 880 const message = message_buf[0..smith.value(u10)]; 881 smith.bytes(message); 882 883 // Reference implementation: slice-based stuffing. 884 var expected_buf: [2100]u8 = undefined; 885 var expected: Io.Writer = .fixed(&expected_buf); 886 try protocol.writeStuffed(&expected, message); 887 888 // Streaming implementation, with fuzzer-chosen chunk boundaries. 889 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n"); 890 var out_buf: [2200]u8 = undefined; 891 var writer: Io.Writer = .fixed(&out_buf); 892 var reply_buf: [64]u8 = undefined; 893 var client: Client = .init(&responses, &writer, &reply_buf); 894 895 var data_writer = try client.data(); 896 var rest: []const u8 = message; 897 while (rest.len > 0) { 898 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len)); 899 try data_writer.interface.writeAll(rest[0..n]); 900 rest = rest[n..]; 901 } 902 try data_writer.end(); 903 904 const written = writer.buffered(); 905 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n")); 906 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); 907 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; 908 try std.testing.expectEqualStrings(expected.buffered(), stuffed); 909} 910 911test Extensions { 912 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; 913 try std.testing.expect(extensions.pipelining); 914 try std.testing.expect(!extensions.starttls); 915 try std.testing.expect(!extensions.auth.any()); 916 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 917} 918 919test bdat { 920 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n"); 921 var out_buf: [128]u8 = undefined; 922 var writer: Io.Writer = .fixed(&out_buf); 923 var reply_buf: [64]u8 = undefined; 924 var client: Client = .init(&reader, &writer, &reply_buf); 925 926 try client.bdat("Subject: hi\r\n\r\n", false); 927 try client.bdat("body\r\n", true); 928 try std.testing.expectEqualStrings( 929 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n", 930 writer.buffered(), 931 ); 932} 933 934test sendMessageChunked { 935 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 936 var out_buf: [128]u8 = undefined; 937 var writer: Io.Writer = .fixed(&out_buf); 938 var reply_buf: [64]u8 = undefined; 939 var client: Client = .init(&reader, &writer, &reply_buf); 940 941 // Raw transmission: the leading dot is not stuffed. 942 try client.sendMessageChunked(".raw\r\n"); 943 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); 944} 945 946test mailFromUtf8 { 947 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 948 var out_buf: [64]u8 = undefined; 949 var writer: Io.Writer = .fixed(&out_buf); 950 var reply_buf: [64]u8 = undefined; 951 var client: Client = .init(&reader, &writer, &reply_buf); 952 953 try client.mailFromUtf8("böb@example.com"); 954 try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered()); 955}