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