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