An SMTP and LMTP client and server library for Zig, with TLS, SASL, PIPELINING, CHUNKING, DSN and the PROXY protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-smtp / src / Client.zig
88 kB 2105 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 sasl = @import("sasl"); 24const Reply = protocol.Reply; 25 26reader: *Io.Reader, 27writer: *Io.Writer, 28/// Backing storage for reply text; `last_reply.text` points into it. 29reply_buffer: []u8, 30/// Scratch for the AUTH exchange, needed only by `authenticate` — a client 31/// that never authenticates may leave it empty. 32/// 33/// It is the caller's for the same reason `reply_buffer` is: how much room a 34/// mechanism needs is the caller's to know, and the difference is large. The 35/// classic mechanisms want a few hundred bytes; an OAuth bearer token can be 36/// several kilobytes on its own. `sasl_buffer_suggested` is a size that fits 37/// everything short of an unusually fat token. 38/// 39/// It is split four-to-three between base64 and plaintext, which is the 40/// ratio base64 expands by — so the usable message is about three sevenths 41/// of what is given. 42sasl_buffer: []u8 = &.{}, 43/// The most recent reply read from the server. Useful for reporting the 44/// server's actual response after an `error.UnexpectedReply`. 45last_reply: ?Reply = null, 46/// Whether the transport is encrypted. This library cannot tell on its own 47/// — it is handed a reader and a writer and has no idea what is under them 48/// — so it assumes the worst and the caller says otherwise. 49/// 50/// `setTransport` takes the answer as an argument, which covers a STARTTLS 51/// upgrade. A session that speaks TLS from the first byte (port 465) hands 52/// `init` an already-encrypted transport, and sets this itself. 53security: Security = .plaintext, 54/// Whether the server advertised PIPELINING 55/// ([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)), which 56/// `envelope` uses to send a whole envelope in one round trip. Set by 57/// `hello` from the EHLO response, and cleared by a HELO fallback, since 58/// RFC 2920 §3.1 lets a client pipeline only against a server that said it 59/// could take it. 60pipelining: bool = false, 61/// Which protocol to speak. Set before `hello`; see `Protocol`. (Spelled 62/// `mode` rather than `protocol` only because this file's `protocol` 63/// module import already holds that name in this scope; the server's 64/// equivalent is `Server.Options.protocol`.) 65mode: Protocol = .smtp, 66/// Recipients the server has accepted since the last MAIL, which in LMTP 67/// is how many replies the end of the message will draw. 68accepted_recipients: usize = 0, 69/// Whether the current transaction was opened with `BODY=BINARYMIME`, in 70/// which case its content can only go out by BDAT. 71binary: bool = false, 72/// Permits `authenticate`, `authPlain` and `authLogin` to send credentials 73/// over a `.plaintext` transport, which they otherwise refuse with 74/// `error.InsecureTransport`. 75/// 76/// The honest use is a connection protected by something outside this 77/// library's view — a unix socket, an SSH tunnel, a loopback test — where 78/// setting `security` to `.encrypted` would be a lie. Anything else is 79/// handing the password to the network. 80allow_cleartext_auth: bool = false, 81 82/// Whether the transport encrypts what is written to it. 83pub const Security = enum { plaintext, encrypted }; 84 85/// Which protocol this session speaks. `.lmtp` sends `LHLO` in place of 86/// `EHLO` and expects one reply per accepted recipient at the end of a 87/// message instead of one for the message 88/// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)); everything 89/// else is the same. Set it before `hello`. 90pub const Protocol = enum { smtp, lmtp }; 91 92pub const Error = error{ 93 WriteFailed, 94 ReadFailed, 95 EndOfStream, 96 LineTooLong, 97 InvalidReply, 98 ReplyTooLong, 99 /// The server answered with an unexpected code; see `last_reply`. 100 UnexpectedReply, 101 /// The transaction was opened with `BODY=BINARYMIME`, whose content 102 /// can only be sent with `bdat`. A server would answer DATA with 503 103 /// (RFC 3030 §3); this is the same refusal, made before the round trip. 104 BinaryRequiresChunking, 105 /// LMTP only: at least one recipient's verdict at the end of the 106 /// message was not a 2xx. 107 /// 108 /// It is a separate error from `UnexpectedReply` because `last_reply` 109 /// cannot answer "which one": the replies arrive one after another into 110 /// a single buffer, so reading the next overwrites the previous, and by 111 /// the time the last has been read the failing one's text is gone. Use 112 /// `DataWriter.endResults` to see each verdict as it arrives. 113 RecipientRejected, 114}; 115 116pub const ArgumentError = error{ 117 /// The transport is not encrypted and what was asked for needs it — 118 /// a mechanism that would put a reusable credential on the wire, or a 119 /// REQUIRETLS guarantee that would mean nothing without one. 120 /// 121 /// Upgrade the session with `starttls`, or for the credential case set 122 /// `allow_cleartext_auth` if the connection is protected by something 123 /// this library cannot see. 124 InsecureTransport, 125 /// An argument contained CR, LF or NUL and was not sent. See 126 /// `protocol.isSafeArgument` for why those three bytes and no others. 127 UnsafeArgument, 128 /// An ESMTP parameter value exceeded the length its RFC allows — 129 /// `ENVID` past 100 characters or `ORCPT` past 500, measured on the 130 /// xtext-encoded form that would go on the wire. 131 ArgumentTooLong, 132}; 133 134/// Extensions advertised in the server's EHLO response. 135pub const Extensions = struct { 136 pipelining: bool = false, 137 eight_bit_mime: bool = false, 138 starttls: bool = false, 139 smtputf8: bool = false, 140 chunking: bool = false, 141 /// The server takes `BODY=BINARYMIME` 142 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). Always 143 /// accompanied by `chunking`, since binary content can only be sent 144 /// with BDAT — a server advertising one without the other is broken, 145 /// and sending binary to it anyway is what RFC 3030 forbids outright. 146 binary_mime: bool = false, 147 enhanced_status_codes: bool = false, 148 /// The server offers REQUIRETLS 149 /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)). Only 150 /// ever seen on a TLS-protected session, since that is the only kind it 151 /// may be advertised on. 152 requiretls: bool = false, 153 /// The server accepts the DSN parameters of 154 /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and 155 /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT. 156 dsn: bool = false, 157 /// The mechanism names from the server's `AUTH` keyword, space-separated 158 /// exactly as it sent them, for `sasl.Client.selectFromList`. 159 /// 160 /// A slice into the client's reply buffer, so it is valid until the next 161 /// reply is read — which for the usual `hello` then `authenticate` 162 /// sequence is long enough, since nothing is read in between. 163 auth: []const u8 = "", 164 /// Value of the SIZE extension, if advertised with a value. 165 max_size: ?u64 = null, 166 167 fn parse(reply: Reply) Extensions { 168 var ext: Extensions = .{}; 169 var it = reply.lines(); 170 _ = it.next(); // The first line is the server's greeting, not a keyword. 171 while (it.next()) |line| { 172 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; 173 const kw = line[0..kw_end]; 174 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else ""; 175 if (ieql(kw, "PIPELINING")) { 176 ext.pipelining = true; 177 } else if (ieql(kw, "8BITMIME")) { 178 ext.eight_bit_mime = true; 179 } else if (ieql(kw, "STARTTLS")) { 180 ext.starttls = true; 181 } else if (ieql(kw, "SMTPUTF8")) { 182 ext.smtputf8 = true; 183 } else if (ieql(kw, "CHUNKING")) { 184 ext.chunking = true; 185 } else if (ieql(kw, "BINARYMIME")) { 186 ext.binary_mime = true; 187 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 188 ext.enhanced_status_codes = true; 189 } else if (ieql(kw, "DSN")) { 190 ext.dsn = true; 191 } else if (ieql(kw, "REQUIRETLS")) { 192 ext.requiretls = true; 193 } else if (ieql(kw, "AUTH")) { 194 ext.auth = arg; 195 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { 196 // Some servers old enough to predate RFC 4954 advertise 197 // "AUTH=PLAIN LOGIN", with the first name jammed onto the 198 // keyword. Taking the line from the '=' recovers the whole 199 // list, which is why this points into the reply rather than 200 // rebuilding it somewhere that would not outlive the call. 201 ext.auth = line[kw_end - (kw.len - 5) ..]; 202 } else if (ieql(kw, "SIZE")) { 203 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 204 } 205 } 206 return ext; 207 } 208 209 fn ieql(a: []const u8, b: []const u8) bool { 210 return std.ascii.eqlIgnoreCase(a, b); 211 } 212}; 213 214/// `reply_buffer` must be large enough for the largest expected reply text 215/// (the EHLO response is usually the largest); 512 bytes is plenty in 216/// practice. 217pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client { 218 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer }; 219} 220 221/// Reads the server's 220 greeting. Call once, right after connecting. 222pub fn greet(c: *Client) Error!Reply { 223 return c.expect(220); 224} 225 226/// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1)) 227/// and returns the extensions the server advertised, falling back 228/// to plain HELO for servers that do not speak ESMTP. 229pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions { 230 if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument; 231 c.accepted_recipients = 0; 232 c.binary = false; 233 c.pipelining = false; 234 if (c.mode == .lmtp) { 235 // LHLO has EHLO's semantics, and there is no older greeting to fall 236 // back to: an LMTP server that will not take LHLO is not one. 237 try c.send("LHLO {s}", .{client_name}); 238 const extensions = Extensions.parse(try c.expectClass(2)); 239 c.pipelining = extensions.pipelining; 240 return extensions; 241 } 242 try c.send("EHLO {s}", .{client_name}); 243 const reply = try c.readReply(); 244 if (reply.isPositiveCompletion()) { 245 const extensions = Extensions.parse(reply); 246 c.pipelining = extensions.pipelining; 247 return extensions; 248 } 249 if (reply.code == 500 or reply.code == 502) { 250 // A server old enough to refuse EHLO has no extensions at all. 251 try c.send("HELO {s}", .{client_name}); 252 _ = try c.expectClass(2); 253 return .{}; 254 } 255 return error.UnexpectedReply; 256} 257 258/// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and 259/// reads the server's 220 go-ahead. On 260/// success, perform a TLS handshake over the underlying stream (see `Tls`), 261/// switch to the encrypted transport with `setTransport`, and then call 262/// `hello` again — the server discards everything it learned before the 263/// handshake, including the EHLO state. 264pub fn starttls(c: *Client) Error!void { 265 try c.send("STARTTLS", .{}); 266 _ = try c.expect(220); 267} 268 269/// Replaces the session's transport, typically with a TLS reader/writer 270/// after `starttls`, and records whether the new one is encrypted. Pass 271/// `.encrypted` for a TLS transport; that is what lets `authenticate` use a 272/// mechanism that sends the password. 273pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer, security: Security) void { 274 c.reader = reader; 275 c.writer = writer; 276 c.security = security; 277} 278 279pub const AuthError = Error || ArgumentError || sasl.Client.Error || error{ 280 /// The server rejected the credentials; see `last_reply`. 281 AuthenticationFailed, 282 /// The server's challenge was not valid base64, or was longer than the 283 /// buffer given to it. 284 InvalidChallenge, 285 /// `sasl_buffer` was empty or smaller than `sasl_buffer_min`. It is not 286 /// allocated here for the same reason `reply_buffer` is not: how much a 287 /// mechanism needs is the caller's to know. 288 SaslBufferTooSmall, 289 /// The server accepted the exchange but the mechanism had not finished 290 /// proving what it set out to prove. 291 /// 292 /// For a one-way mechanism this cannot happen. For SCRAM it means the 293 /// server reported success without ever producing its own signature — 294 /// which is what something in the middle, holding no verifier, would do. 295 /// The credentials are not compromised by it, but the peer is not the 296 /// server, and the session should be abandoned rather than used. 297 ServerNotAuthenticated, 298}; 299 300/// The smallest `sasl_buffer` worth offering: enough plaintext for PLAIN, 301/// LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS, DIGEST-MD5 and SCRAM, none of which 302/// send more than a few hundred bytes. 303pub const sasl_buffer_min = 896; 304 305/// A `sasl_buffer` size that fits everything, including an OAuth token of a 306/// couple of kilobytes. 307/// 308/// [RFC 4954 §4](https://datatracker.ietf.org/doc/html/rfc4954#section-4) 309/// says a client "MUST be able to handle the maximum encoded size of 310/// challenges and responses generated by their supported authentication 311/// mechanisms" and offers 12288 octets as a sufficient line length. Seven 312/// thousand here is a plaintext message of three thousand, which encodes to 313/// four — comfortably inside that. 314pub const sasl_buffer_suggested = 7168; 315 316/// Runs a SASL exchange with `mechanism` 317/// ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 318/// 319/// The mechanisms themselves live in 320/// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.Plain`, 321/// `sasl.CramMd5`, `sasl.XOAuth2` and the rest, with SCRAM in zig-scram — 322/// because they are shared with every other protocol that speaks SASL and 323/// nothing about them is specific to SMTP. What is specific to SMTP is this 324/// function: `AUTH`, the 334 challenges, the `*` that cancels, and 235. 325/// 326/// ```zig 327/// var plain: sasl.Plain = .init("alice", "secret"); 328/// const extensions = try client.hello("my-host.example.com"); 329/// const mechanism = sasl.Client.selectFromList( 330/// &.{ plain.client() }, 331/// extensions.auth, 332/// client.security == .encrypted, 333/// ) orelse return error.NoSupportedMechanism; 334/// try client.authenticate(mechanism); 335/// ``` 336/// 337/// A mechanism that would put a reusable credential on an unencrypted 338/// transport is refused before anything is sent, as it was when the 339/// mechanisms lived here. When the mechanism itself fails mid-exchange the 340/// session is cancelled with `*` rather than abandoned, so the connection is 341/// left usable and the server's 501 is read rather than waiting in the 342/// stream for whatever comes next. 343pub fn authenticate(c: *Client, mechanism: sasl.Client) AuthError!void { 344 if (mechanism.cleartext()) try c.requireConfidentiality(); 345 const scratch = try splitSaslBuffer(c.sasl_buffer); 346 347 var message: Io.Writer = .fixed(scratch.plain); 348 349 switch (try c.mechanismStep(mechanism.initial(&message))) { 350 .none => try c.send("AUTH {s}", .{mechanism.name()}), 351 .written => { 352 const encoded = std.base64.standard.Encoder.encode(scratch.coded, message.buffered()); 353 // RFC 4954 §4: a zero-length initial response is a single `=`, 354 // because an empty argument would be indistinguishable from 355 // sending none at all. 356 try c.send("AUTH {s} {s}", .{ mechanism.name(), if (encoded.len == 0) "=" else encoded }); 357 }, 358 } 359 360 while (true) { 361 const reply = try c.readReply(); 362 if (reply.code == 235) break; 363 if (reply.code != 334) return error.AuthenticationFailed; 364 365 // The challenge decodes into the coded half, which is free: whatever 366 // was encoded there has already gone out. 367 const challenge = decodeChallenge(scratch.coded, reply.text) orelse { 368 try c.cancelAuth(); 369 return error.InvalidChallenge; 370 }; 371 372 message = .fixed(scratch.plain); 373 try c.mechanismStep(mechanism.respond(challenge, &message)); 374 // ...and the response encodes back over it, the challenge having 375 // been consumed by `respond`. 376 try c.send("{s}", .{std.base64.standard.Encoder.encode(scratch.coded, message.buffered())}); 377 } 378 379 // The server says yes. Whether that means anything is the mechanism's to 380 // say: see `ServerNotAuthenticated`. 381 if (!mechanism.satisfied()) return error.ServerNotAuthenticated; 382} 383 384/// Cancels the exchange on a mechanism error and turns it into ours. 385/// 386/// A mechanism that has failed will not produce another message, so the 387/// server is left waiting for a line that is never coming. RFC 4954 §4 gives 388/// `*` for exactly this, and answers it with 501, which is read here so the 389/// session is clean for whatever the caller does next. 390fn mechanismStep(c: *Client, result: anytype) AuthError!@typeInfo(@TypeOf(result)).error_union.payload { 391 return result catch |err| { 392 c.cancelAuth() catch {}; 393 return err; 394 }; 395} 396 397fn cancelAuth(c: *Client) Error!void { 398 try c.send("*", .{}); 399 _ = c.readReply() catch {}; 400} 401 402/// Decodes a challenge, which may legitimately be empty: RFC 4954 §4 spells a 403/// zero-length challenge `334 ` — the code, a space, and nothing after it. 404fn decodeChallenge(buffer: []u8, text: []const u8) ?[]const u8 { 405 if (text.len == 0) return buffer[0..0]; 406 const len = std.base64.standard.Decoder.calcSizeForSlice(text) catch return null; 407 if (len > buffer.len) return null; 408 std.base64.standard.Decoder.decode(buffer[0..len], text) catch return null; 409 return buffer[0..len]; 410} 411 412/// Refuses a mechanism that would transmit a reusable credential unprotected. 413fn requireConfidentiality(c: *Client) AuthError!void { 414 if (c.security == .encrypted or c.allow_cleartext_auth) return; 415 return error.InsecureTransport; 416} 417 418/// The two halves of `sasl_buffer`. 419/// 420/// `coded` is four sevenths and `plain` three, which is base64's expansion 421/// exactly — so `coded` always holds the encoding of a full `plain`. They 422/// never hold anything at the same time: a challenge decodes into `coded`, 423/// is consumed by the mechanism writing into `plain`, and the answer encodes 424/// back over it. 425const SaslScratch = struct { coded: []u8, plain: []u8 }; 426 427fn splitSaslBuffer(buffer: []u8) AuthError!SaslScratch { 428 if (buffer.len < sasl_buffer_min) return error.SaslBufferTooSmall; 429 const unit = buffer.len / 7; 430 return .{ .coded = buffer[0 .. unit * 4], .plain = buffer[unit * 4 ..][0 .. unit * 3] }; 431} 432 433/// Parameters for the MAIL command. Send only what the server advertised: 434/// an unrecognized parameter is a 555 from a conforming server, so check 435/// `Extensions` first. 436pub const MailOptions = struct { 437 /// Requests the SMTPUTF8 extension 438 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)), which 439 /// lets the envelope and headers carry UTF-8. Needs `Extensions.smtputf8`. 440 smtputf8: bool = false, 441 /// `BODY=`: what kind of content the message carries. 442 /// `.eight_bit_mime` needs `Extensions.eight_bit_mime`; 443 /// `.binary_mime` needs `Extensions.binary_mime`, and commits the 444 /// transaction to BDAT — `data` will refuse to open a DATA phase for 445 /// it, as RFC 3030 §3 requires. 446 body: ?protocol.Body = null, 447 /// `REQUIRETLS` 448 /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)): do not 449 /// let this message travel onward in the clear — bounce it instead. 450 /// Needs `Extensions.requiretls`. 451 /// 452 /// Refused on a session this client does not believe is encrypted, with 453 /// `error.InsecureTransport`, because asking for a guarantee over a 454 /// channel that has none is asking for nothing. That is the part this 455 /// library can check. The rest of RFC 8689 §4.1's preconditions are the 456 /// caller's and cannot be checked from here: the server's certificate 457 /// must have been validated by a trust chain or DANE — so not with 458 /// `Tls.Options.ca = .insecure` — and the MX itself must have been 459 /// vouched for by DNSSEC or MTA-STS, which this library does not resolve. 460 require_tls: bool = false, 461 /// `AUTH=` 462 /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 463 /// who originally submitted this message, for a relay carrying it on 464 /// behalf of somebody else. Needs the server to advertise AUTH. 465 /// 466 /// `.unknown` sends `<>`, which is what a relay should send when it 467 /// cannot vouch for the submitter — RFC 4954 asks for that rather than 468 /// leaving the parameter off, because a server receiving no parameter 469 /// learns nothing while one receiving `<>` learns that the peer 470 /// considered the question. The mailbox form is xtext-encoded, so any 471 /// bytes are safe to pass, and is rejected with `error.ArgumentTooLong` 472 /// past the 500 characters RFC 4954 makes room for. 473 /// 474 /// Being believed is another matter: the receiving server disregards 475 /// this unless this client has authenticated to it. 476 auth: ?protocol.Submitter = null, 477 /// DSN `RET=`: how much of the message a failure report should carry 478 /// back. Needs `Extensions.dsn`. 479 ret: ?protocol.Ret = null, 480 /// DSN `ENVID=`: an identifier quoted back in any report about this 481 /// message. Sent xtext-encoded, so any bytes are safe to pass, and 482 /// rejected with `error.ArgumentTooLong` if the encoded form exceeds the 483 /// 100 characters RFC 3461 allows. Needs `Extensions.dsn`. 484 envid: ?[]const u8 = null, 485}; 486 487/// Parameters for the RCPT command, which in this library means the DSN 488/// ones. Needs `Extensions.dsn`; see `MailOptions`. 489pub const RcptOptions = struct { 490 /// DSN `NOTIFY=`: when the sender wants to hear about this recipient. 491 /// Leave null to let the receiver apply its default. 492 notify: ?protocol.Notify = null, 493 /// DSN `ORCPT=`: the address the message was originally addressed to, 494 /// carried through aliasing so a report can name what the sender wrote. 495 /// The address is sent xtext-encoded; the `addr_type` is not, so it is 496 /// checked instead, and the whole parameter is capped at the 500 497 /// characters RFC 3461 allows. 498 orcpt: ?protocol.Orcpt = null, 499}; 500 501/// Starts a mail transaction. An empty `from` sends the null reverse-path 502/// (`MAIL FROM:<>`), used for bounces. 503/// 504/// Returns `error.UnsafeArgument` for an address that would break out of 505/// the command line; see `protocol.isSafeArgument`. 506pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void { 507 return c.mail(from, .{}); 508} 509 510/// `mailFrom` with ESMTP parameters. 511pub fn mail(c: *Client, from: []const u8, options: MailOptions) (Error || ArgumentError)!void { 512 try c.checkMail(from, options); 513 try c.writeMail(from, options); 514 try c.writer.flush(); 515 _ = try c.expectClass(2); 516 c.accepted_recipients = 0; 517 c.binary = options.body == .binary_mime; 518} 519 520/// Everything about a MAIL command that can be refused before it is 521/// written. Split out so that a pipelined group can be validated in full 522/// before any of it goes on the wire. 523fn checkMail(c: *Client, from: []const u8, options: MailOptions) ArgumentError!void { 524 if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 525 // A guarantee about a channel with no protection is not a guarantee. 526 if (options.require_tls and c.security != .encrypted) return error.InsecureTransport; 527 if (options.envid) |envid| { 528 if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len) 529 return error.ArgumentTooLong; 530 } 531 if (options.auth) |auth| switch (auth) { 532 .unknown => {}, 533 .mailbox => |mailbox| { 534 if (mailbox.len == 0) return error.UnsafeArgument; 535 if (protocol.xtextEncodedLen(mailbox) > protocol.Submitter.max_len) 536 return error.ArgumentTooLong; 537 }, 538 }; 539} 540 541/// Writes MAIL without flushing or reading its reply. 542fn writeMail(c: *Client, from: []const u8, options: MailOptions) Error!void { 543 try c.writer.print("MAIL FROM:<{s}>", .{from}); 544 if (options.require_tls) try c.writer.writeAll(" REQUIRETLS"); 545 if (options.auth) |auth| try c.writer.print(" AUTH={f}", .{auth}); 546 if (options.body) |body| try c.writer.print(" BODY={f}", .{body}); 547 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); 548 if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret}); 549 if (options.envid) |envid| { 550 try c.writer.writeAll(" ENVID="); 551 try protocol.writeXtext(c.writer, envid); 552 } 553 try c.writer.writeAll(protocol.crlf); 554} 555 556/// Adds a recipient to the current transaction. Returns 557/// `error.UnsafeArgument` for an address that would break out of the 558/// command line; see `protocol.isSafeArgument`. 559pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void { 560 return c.rcpt(to, .{}); 561} 562 563/// `rcptTo` with ESMTP parameters. 564pub fn rcpt(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!void { 565 const code = try c.rcptCode(to, options); 566 if (code / 100 != 2) return error.UnexpectedReply; 567} 568 569/// `rcpt`, but a refusal is the returned code rather than an error. The 570/// reply is in `last_reply` either way. 571fn rcptCode(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!u16 { 572 try c.checkRcpt(to, options); 573 try c.writeRcpt(to, options); 574 try c.writer.flush(); 575 const reply = try c.readReply(); 576 if (reply.isPositiveCompletion()) c.accepted_recipients += 1; 577 return reply.code; 578} 579 580/// Everything about a RCPT command that can be refused before it is 581/// written; see `checkMail`. 582fn checkRcpt(c: *Client, to: []const u8, options: RcptOptions) ArgumentError!void { 583 _ = c; 584 if (!protocol.isSafeArgument(to)) return error.UnsafeArgument; 585 if (options.orcpt) |orcpt| { 586 if (orcpt.addr_type.len == 0 or !protocol.isSafeArgument(orcpt.addr_type) or 587 std.mem.findScalar(u8, orcpt.addr_type, ';') != null) 588 return error.UnsafeArgument; 589 if (orcpt.addr_type.len + 1 + protocol.xtextEncodedLen(orcpt.address) > protocol.Orcpt.max_len) 590 return error.ArgumentTooLong; 591 } 592} 593 594/// Writes RCPT without flushing or reading its reply. 595fn writeRcpt(c: *Client, to: []const u8, options: RcptOptions) Error!void { 596 try c.writer.print("RCPT TO:<{s}>", .{to}); 597 if (options.notify) |notify| try c.writer.print(" NOTIFY={f}", .{notify}); 598 if (options.orcpt) |orcpt| try c.writer.print(" ORCPT={f}", .{orcpt}); 599 try c.writer.writeAll(protocol.crlf); 600} 601 602pub const EnvelopeOptions = struct { 603 /// Parameters for the MAIL command. 604 mail: MailOptions = .{}, 605 /// Parameters applied to every RCPT command. Per-recipient parameters 606 /// need `rcpt` called individually. 607 rcpt: RcptOptions = .{}, 608}; 609 610/// Sends MAIL FROM and one RCPT TO per recipient, then reads every reply, 611/// and returns how many recipients the server accepted. 612/// 613/// When the server advertised PIPELINING the commands go out as a single 614/// group and their replies are read together, which turns an envelope of 615/// *n* recipients from *n*+1 round trips into one. Otherwise each command 616/// waits for its own reply, and the result is the same either way. 617/// 618/// DATA is deliberately not part of the group, though RFC 2920 §3.1 allows 619/// it as the last command of one. Once a server has answered DATA with 354 620/// the transaction is committed, and a caller that wanted all-or-nothing 621/// delivery has no way back: the only ways out of the data phase are to 622/// send the message or to send an empty one to whichever recipients *were* 623/// accepted. Stopping the group before DATA keeps that decision with the 624/// caller, and costs one round trip out of the *n*+1 saved. 625/// 626/// `codes`, when given, must have room for `recipients.len` entries and 627/// receives each RCPT reply code in order. Codes rather than replies 628/// because the replies share one buffer: by the time the group has been 629/// read, only the last one's text still exists. 630/// 631/// A refused MAIL FROM is `error.UnexpectedReply`, with the reply in 632/// `last_reply` and the rest of the group drained. Refused *recipients* 633/// are not an error — with several of them the caller is the one who can 634/// say whether what remains is worth sending — so compare the returned 635/// count against `recipients.len`. 636pub fn envelope( 637 c: *Client, 638 from: []const u8, 639 recipients: []const []const u8, 640 codes: ?[]u16, 641 options: EnvelopeOptions, 642) (Error || ArgumentError)!usize { 643 if (codes) |slice| std.debug.assert(slice.len >= recipients.len); 644 if (!c.pipelining) { 645 try c.mail(from, options.mail); 646 var accepted: usize = 0; 647 for (recipients, 0..) |recipient, index| { 648 const code = try c.rcptCode(recipient, options.rcpt); 649 if (codes) |slice| slice[index] = code; 650 if (code / 100 == 2) accepted += 1; 651 } 652 return accepted; 653 } 654 655 // Everything is validated before anything is written: a group that 656 // turned out to be unsendable halfway through would leave the session 657 // holding a partial command. 658 try c.checkMail(from, options.mail); 659 for (recipients) |recipient| try c.checkRcpt(recipient, options.rcpt); 660 661 try c.writeMail(from, options.mail); 662 for (recipients) |recipient| try c.writeRcpt(recipient, options.rcpt); 663 try c.writer.flush(); 664 665 // RFC 2920 §3.1: every status in the group must be checked, and all of 666 // them must be read whatever the first one said, or the replies still 667 // queued would be mistaken for the answers to whatever comes next. 668 const mail_reply = try c.readReply(); 669 const mail_ok = mail_reply.isPositiveCompletion(); 670 if (mail_ok) { 671 c.accepted_recipients = 0; 672 c.binary = options.mail.body == .binary_mime; 673 } 674 675 var accepted: usize = 0; 676 for (0..recipients.len) |index| { 677 if (!mail_ok) { 678 // The MAIL reply is the one worth keeping, so the rest of the 679 // group is drained without disturbing it. 680 try c.discardReply(); 681 if (codes) |slice| slice[index] = 0; 682 continue; 683 } 684 const reply = try c.readReply(); 685 if (codes) |slice| slice[index] = reply.code; 686 if (reply.isPositiveCompletion()) { 687 accepted += 1; 688 c.accepted_recipients += 1; 689 } 690 } 691 if (!mail_ok) return error.UnexpectedReply; 692 return accepted; 693} 694 695/// Sends the message content for the current transaction (DATA). Line 696/// endings in `data` are normalized to CRLF and leading dots are stuffed. 697pub fn sendMessage(c: *Client, message_data: []const u8) Error!void { 698 var data_writer = try c.data(); 699 try data_writer.interface.writeAll(message_data); 700 try data_writer.end(); 701} 702 703/// Streams the message content for the current transaction from `message` 704/// until end of stream. Line endings are normalized to CRLF and leading 705/// dots stuffed; nothing is buffered beyond the transport writer, so lines 706/// and messages of any length work. 707pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void { 708 var data_writer = try c.data(); 709 while (true) { 710 const chunk = message.peekGreedy(1) catch |err| switch (err) { 711 error.EndOfStream => break, 712 error.ReadFailed => return error.ReadFailed, 713 }; 714 try data_writer.interface.writeAll(chunk); 715 message.toss(chunk.len); 716 } 717 try data_writer.end(); 718} 719 720/// Starts the DATA phase for streaming a message body: write the content 721/// through the returned writer's `interface`, then call `end`. Line endings 722/// are normalized to CRLF and leading dots stuffed as the data flows. 723pub fn data(c: *Client) Error!DataWriter { 724 if (c.binary) return error.BinaryRequiresChunking; 725 try c.send("DATA", .{}); 726 _ = try c.expect(354); 727 return .{ 728 .client = c, 729 .interface = .{ 730 .buffer = &.{}, 731 .vtable = &.{ .drain = DataWriter.drain }, 732 }, 733 }; 734} 735 736/// Streaming writer for a message body; obtained from `data`. The dot 737/// stuffing and CRLF normalization state lives here, so chunks may split 738/// lines (and even CRLF pairs) at any byte boundary. 739pub const DataWriter = struct { 740 client: *Client, 741 interface: Io.Writer, 742 at_line_start: bool = true, 743 /// A '\r' was seen but not yet emitted; whether it is a line ending 744 /// depends on the next byte. 745 pending_cr: bool = false, 746 747 /// Terminates the message (adding a final CRLF if the content did not 748 /// end with one, then ".\r\n") and reads the server's verdict. 749 /// 750 /// In LMTP that is one verdict per accepted recipient rather than one 751 /// for the message. All of them are read — leaving any unread would 752 /// desynchronize the session — and a non-2xx among them becomes 753 /// `error.RecipientRejected`, with that first refusal left in 754 /// `last_reply`: once one has been read, the rest of the group is 755 /// drained without disturbing it. Which *recipient* it belonged to is 756 /// only available from `endResults`, which is the whole reason for 757 /// speaking LMTP and the way to see every verdict. 758 pub fn end(dw: *DataWriter) Error!void { 759 var verdicts = try dw.endResults(); 760 const per_recipient = verdicts.remaining > 1; 761 while (try verdicts.next()) |reply| { 762 if (reply.isPositiveCompletion()) continue; 763 while (verdicts.remaining > 0) : (verdicts.remaining -= 1) 764 try dw.client.discardReply(); 765 // With one reply there was never any ambiguity to begin with. 766 return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; 767 } 768 } 769 770 /// Terminates the message and returns the verdicts to read: one in 771 /// SMTP, one per accepted recipient in LMTP, in the order the RCPT 772 /// commands were issued. Every one of them must be read before the 773 /// session is used again. 774 pub fn endResults(dw: *DataWriter) Error!Results { 775 try dw.interface.flush(); 776 const c = dw.client; 777 if (dw.pending_cr) { 778 // A trailing bare CR counts as a line ending, matching 779 // `protocol.writeStuffed`. 780 dw.pending_cr = false; 781 dw.at_line_start = true; 782 try c.writer.writeAll(protocol.crlf); 783 } 784 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf); 785 try c.writer.writeAll("." ++ protocol.crlf); 786 try c.writer.flush(); 787 return c.results(); 788 } 789 790 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { 791 const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w)); 792 try dw.writeChunk(w.buffered()); 793 w.end = 0; 794 if (chunks.len == 0) return 0; 795 var n: usize = 0; 796 for (chunks[0 .. chunks.len - 1]) |bytes| { 797 try dw.writeChunk(bytes); 798 n += bytes.len; 799 } 800 const pattern = chunks[chunks.len - 1]; 801 for (0..splat) |_| { 802 try dw.writeChunk(pattern); 803 n += pattern.len; 804 } 805 return n; 806 } 807 808 test end { 809 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 810 var out_buf: [64]u8 = undefined; 811 var writer: Io.Writer = .fixed(&out_buf); 812 var reply_buf: [64]u8 = undefined; 813 var client: Client = .init(&reader, &writer, &reply_buf); 814 815 var data_writer = try client.data(); 816 try data_writer.interface.writeAll("no trailing newline"); 817 try data_writer.end(); // adds the final CRLF, sends ".", reads 250 818 try std.testing.expectEqualStrings( 819 "DATA\r\nno trailing newline\r\n.\r\n", 820 writer.buffered(), 821 ); 822 } 823 824 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void { 825 const out = dw.client.writer; 826 var rest = bytes; 827 while (rest.len > 0) { 828 if (dw.pending_cr) { 829 dw.pending_cr = false; 830 if (rest[0] == '\n') { 831 try out.writeAll(protocol.crlf); 832 dw.at_line_start = true; 833 rest = rest[1..]; 834 continue; 835 } 836 // A bare CR mid-line passes through untouched. 837 try out.writeByte('\r'); 838 dw.at_line_start = false; 839 } 840 if (dw.at_line_start and rest[0] == '.') { 841 try out.writeAll(".."); 842 dw.at_line_start = false; 843 rest = rest[1..]; 844 continue; 845 } 846 const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse { 847 try out.writeAll(rest); 848 dw.at_line_start = false; 849 break; 850 }; 851 if (special > 0) { 852 try out.writeAll(rest[0..special]); 853 dw.at_line_start = false; 854 } 855 switch (rest[special]) { 856 '\r' => dw.pending_cr = true, 857 '\n' => { 858 try out.writeAll(protocol.crlf); 859 dw.at_line_start = true; 860 }, 861 else => unreachable, 862 } 863 rest = rest[special + 1 ..]; 864 } 865 } 866}; 867 868/// The verdicts a server sends at the end of a message: one in SMTP, one 869/// per accepted recipient in LMTP. Each `next` overwrites the client's 870/// reply buffer, so a reply must be used before the following call. 871pub const Results = struct { 872 client: *Client, 873 remaining: usize, 874 /// The index into the recipients accepted since the last MAIL that the 875 /// next reply belongs to. Meaningful in LMTP, where replies come back 876 /// in the order the RCPT commands were issued. 877 index: usize = 0, 878 879 pub fn next(r: *Results) Error!?Reply { 880 if (r.remaining == 0) return null; 881 r.remaining -= 1; 882 r.index += 1; 883 return try r.client.readReply(); 884 } 885}; 886 887/// The verdicts still to be read after a message has been terminated. Use 888/// `DataWriter.endResults`, which sends the terminator first; this is the 889/// reading half on its own, for a caller that framed the message itself. 890pub fn results(c: *Client) Results { 891 return .{ 892 .client = c, 893 .remaining = switch (c.mode) { 894 .smtp => 1, 895 .lmtp => c.accepted_recipients, 896 }, 897 }; 898} 899 900/// Like `mailFrom`, but requests the SMTPUTF8 extension 901/// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the 902/// envelope addresses and message headers may contain UTF-8. Use only when 903/// `Extensions.smtputf8` was advertised. 904pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void { 905 return c.mail(from, .{ .smtputf8 = true }); 906} 907 908/// Sends one BDAT chunk (the CHUNKING extension, 909/// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the 910/// server's reply. Use only when `Extensions.chunking` was advertised. The 911/// chunk is transmitted verbatim — no dot-stuffing and no line-ending 912/// normalization — so message content must already use CRLF line endings. 913/// Set `last` on the final chunk; `bdat("", true)` is a valid terminator. 914pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void { 915 if (last) { 916 try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len}); 917 } else { 918 try c.writer.print("BDAT {d}\r\n", .{chunk.len}); 919 } 920 try c.writer.writeAll(chunk); 921 try c.writer.flush(); 922 if (!last) { 923 _ = try c.expectClass(2); 924 return; 925 } 926 // RFC 2033 gives the LAST chunk the same per-recipient answer that the 927 // final dot of DATA gets, so it is read the same way. 928 var chunk_results = c.results(); 929 const per_recipient = chunk_results.remaining > 1; 930 while (try chunk_results.next()) |reply| { 931 if (reply.isPositiveCompletion()) continue; 932 while (chunk_results.remaining > 0) : (chunk_results.remaining -= 1) 933 try c.discardReply(); 934 return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; 935 } 936} 937 938/// Sends the message content for the current transaction as a single BDAT 939/// chunk. See `bdat` for the transmission caveats. 940pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void { 941 try c.bdat(message_data, true); 942} 943 944/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 945/// then DATA. Call after `greet` and `hello`. 946pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) (Error || ArgumentError)!void { 947 const accepted = try c.envelope(from, recipients, null, .{}); 948 if (accepted != recipients.len) { 949 // All or nothing, so nothing: the envelope is abandoned before DATA 950 // rather than delivering to the subset that was accepted. A caller 951 // who wants the subset calls `envelope` and decides for itself. 952 c.rset() catch {}; 953 return error.UnexpectedReply; 954 } 955 try c.sendMessage(message_data); 956} 957 958/// Aborts the current mail transaction. 959pub fn rset(c: *Client) Error!void { 960 try c.send("RSET", .{}); 961 _ = try c.expectClass(2); 962 c.accepted_recipients = 0; 963 c.binary = false; 964} 965 966pub fn noop(c: *Client) Error!void { 967 try c.send("NOOP", .{}); 968 _ = try c.expectClass(2); 969} 970 971/// Ends the session. The connection should be closed afterwards. 972pub fn quit(c: *Client) Error!void { 973 try c.send("QUIT", .{}); 974 _ = try c.expect(221); 975} 976 977fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 978 try c.writer.print(fmt ++ protocol.crlf, args); 979 try c.writer.flush(); 980} 981 982fn readReply(c: *Client) Error!Reply { 983 const reply = try Reply.read(c.reader, c.reply_buffer); 984 c.last_reply = reply; 985 return reply; 986} 987 988/// Reads one reply and throws it away, without touching `reply_buffer`. 989/// 990/// That is the point of it: the replies to a pipelined group all arrive 991/// before any of them can be acted on, and each one read into 992/// `reply_buffer` overwrites the last. Draining the rest of a group this 993/// way leaves the failing reply that was already read intact in 994/// `last_reply`, so an error can still say what went wrong. 995fn discardReply(c: *Client) Error!void { 996 while (true) { 997 const line = protocol.readLine(c.reader) catch |err| switch (err) { 998 error.LineTooLong => return error.ReplyTooLong, 999 error.ReadFailed, error.EndOfStream => |e| return e, 1000 }; 1001 if (line.len < 4) return if (line.len == 3) {} else error.InvalidReply; 1002 // A '-' in the fourth column continues the reply; a space ends it. 1003 switch (line[3]) { 1004 '-' => continue, 1005 ' ' => return, 1006 else => return error.InvalidReply, 1007 } 1008 } 1009} 1010 1011fn expect(c: *Client, code: u16) Error!Reply { 1012 const reply = try c.readReply(); 1013 if (reply.code != code) return error.UnexpectedReply; 1014 return reply; 1015} 1016 1017fn expectClass(c: *Client, class: u16) Error!Reply { 1018 const reply = try c.readReply(); 1019 if (reply.code / 100 != class) return error.UnexpectedReply; 1020 return reply; 1021} 1022 1023test sendMail { 1024 const responses = "220 mx.example.com ESMTP\r\n" ++ 1025 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 1026 "250 2.1.0 Ok\r\n" ++ 1027 "250 2.1.5 Ok\r\n" ++ 1028 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1029 "250 2.0.0 Ok\r\n" ++ 1030 "221 2.0.0 Bye\r\n"; 1031 var reader: Io.Reader = .fixed(responses); 1032 var out_buf: [1024]u8 = undefined; 1033 var writer: Io.Writer = .fixed(&out_buf); 1034 var reply_buf: [512]u8 = undefined; 1035 var client: Client = .init(&reader, &writer, &reply_buf); 1036 1037 _ = try client.greet(); 1038 const ext = try client.hello("client.example.org"); 1039 try std.testing.expect(ext.pipelining); 1040 try std.testing.expect(ext.eight_bit_mime); 1041 try std.testing.expect(!ext.starttls); 1042 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 1043 1044 try client.sendMail( 1045 "alice@example.com", 1046 &.{"bob@example.net"}, 1047 "Subject: hi\r\n\r\n.leading dot\r\n", 1048 ); 1049 try client.quit(); 1050 1051 try std.testing.expectEqualStrings( 1052 "EHLO client.example.org\r\n" ++ 1053 "MAIL FROM:<alice@example.com>\r\n" ++ 1054 "RCPT TO:<bob@example.net>\r\n" ++ 1055 "DATA\r\n" ++ 1056 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 1057 "QUIT\r\n", 1058 writer.buffered(), 1059 ); 1060} 1061 1062test "HELO fallback for non-ESMTP servers" { 1063 const responses = "220 old.example.com\r\n" ++ 1064 "502 command not implemented\r\n" ++ 1065 "250 old.example.com\r\n"; 1066 var reader: Io.Reader = .fixed(responses); 1067 var out_buf: [256]u8 = undefined; 1068 var writer: Io.Writer = .fixed(&out_buf); 1069 var reply_buf: [256]u8 = undefined; 1070 var client: Client = .init(&reader, &writer, &reply_buf); 1071 1072 _ = try client.greet(); 1073 const ext = try client.hello("client.example.org"); 1074 try std.testing.expectEqual(Extensions{}, ext); 1075 try std.testing.expectEqualStrings( 1076 "EHLO client.example.org\r\nHELO client.example.org\r\n", 1077 writer.buffered(), 1078 ); 1079} 1080 1081test "rejected recipient surfaces the reply" { 1082 const responses = "550 5.1.1 No such user\r\n"; 1083 var reader: Io.Reader = .fixed(responses); 1084 var out_buf: [256]u8 = undefined; 1085 var writer: Io.Writer = .fixed(&out_buf); 1086 var reply_buf: [256]u8 = undefined; 1087 var client: Client = .init(&reader, &writer, &reply_buf); 1088 1089 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 1090 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 1091 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 1092} 1093 1094test starttls { 1095 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 1096 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 1097 "220 2.0.0 Ready to start TLS\r\n"; 1098 var reader: Io.Reader = .fixed(plain_responses); 1099 var out_buf: [256]u8 = undefined; 1100 var writer: Io.Writer = .fixed(&out_buf); 1101 var reply_buf: [256]u8 = undefined; 1102 var client: Client = .init(&reader, &writer, &reply_buf); 1103 1104 _ = try client.greet(); 1105 const ext = try client.hello("client.example.org"); 1106 try std.testing.expect(ext.starttls); 1107 try client.starttls(); 1108 1109 // Simulate the post-handshake encrypted transport with fresh buffers; 1110 // the session must re-EHLO on it. 1111 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 1112 var tls_reader: Io.Reader = .fixed(tls_responses); 1113 var tls_out_buf: [256]u8 = undefined; 1114 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 1115 client.setTransport(&tls_reader, &tls_writer, .encrypted); 1116 1117 const tls_ext = try client.hello("client.example.org"); 1118 try std.testing.expect(!tls_ext.starttls); 1119 try std.testing.expect(tls_ext.eight_bit_mime); 1120 try std.testing.expectEqualStrings( 1121 "EHLO client.example.org\r\nSTARTTLS\r\n", 1122 writer.buffered(), 1123 ); 1124 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 1125} 1126 1127test "BODY=BINARYMIME commits the transaction to BDAT" { 1128 const responses = "250-mx.example.com\r\n250-CHUNKING\r\n250 BINARYMIME\r\n" ++ 1129 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.0.0 Ok\r\n"; 1130 var reader: Io.Reader = .fixed(responses); 1131 var out_buf: [512]u8 = undefined; 1132 var writer: Io.Writer = .fixed(&out_buf); 1133 var reply_buf: [256]u8 = undefined; 1134 var client: Client = .init(&reader, &writer, &reply_buf); 1135 1136 const ext = try client.hello("client.example.org"); 1137 try std.testing.expect(ext.binary_mime and ext.chunking); 1138 1139 try client.mail("alice@example.com", .{ .body = .binary_mime }); 1140 try client.rcptTo("bob@example.net"); 1141 // The server would answer DATA with 503; the client will not get that 1142 // far, because the round trip has nothing to discover. 1143 try std.testing.expectError(error.BinaryRequiresChunking, client.data()); 1144 1145 // BDAT is the way, and it sends the octets untouched: a bare CR, a NUL 1146 // and a lone dot all cross unchanged. 1147 try client.bdat("\x00\r.\r\n", true); 1148 try std.testing.expect(std.mem.endsWith( 1149 u8, 1150 writer.buffered(), 1151 "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1152 "RCPT TO:<bob@example.net>\r\n" ++ 1153 "BDAT 5 LAST\r\n\x00\r.\r\n", 1154 )); 1155} 1156 1157test "the binary commitment is lifted by RSET and by the next transaction" { 1158 const responses = "250 2.1.0 Ok\r\n250 2.0.0 Ok\r\n250 2.1.0 Ok\r\n354 go\r\n250 ok\r\n"; 1159 var reader: Io.Reader = .fixed(responses); 1160 var out_buf: [512]u8 = undefined; 1161 var writer: Io.Writer = .fixed(&out_buf); 1162 var reply_buf: [256]u8 = undefined; 1163 var client: Client = .init(&reader, &writer, &reply_buf); 1164 1165 try client.mail("alice@example.com", .{ .body = .binary_mime }); 1166 try std.testing.expect(client.binary); 1167 try client.rset(); 1168 try std.testing.expect(!client.binary); 1169 // And a plain MAIL leaves DATA available again. 1170 try client.mail("alice@example.com", .{}); 1171 try client.sendMessage("hi\r\n"); 1172} 1173 1174test "a pipelined envelope writes the whole group before reading a reply" { 1175 // The MAIL is refused. A client working one command at a time would 1176 // stop there; a pipelined one has already sent everything, and that is 1177 // what makes the difference observable from the wire alone. 1178 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ 1179 "550 5.1.8 Bad sender\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n"; 1180 var reader: Io.Reader = .fixed(responses); 1181 var out_buf: [512]u8 = undefined; 1182 var writer: Io.Writer = .fixed(&out_buf); 1183 var reply_buf: [256]u8 = undefined; 1184 var client: Client = .init(&reader, &writer, &reply_buf); 1185 1186 _ = try client.hello("client.example.org"); 1187 try std.testing.expect(client.pipelining); 1188 1189 const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" }; 1190 try std.testing.expectError( 1191 error.UnexpectedReply, 1192 client.envelope("alice@example.com", recipients, null, .{}), 1193 ); 1194 try std.testing.expectEqualStrings( 1195 "EHLO client.example.org\r\n" ++ 1196 "MAIL FROM:<alice@example.com>\r\n" ++ 1197 "RCPT TO:<bob@example.net>\r\n" ++ 1198 "RCPT TO:<carol@example.net>\r\n", 1199 writer.buffered(), 1200 ); 1201 // The MAIL reply is the one kept, even though two more were read after 1202 // it, and the group was drained so the stream is where it should be. 1203 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 1204 try std.testing.expectEqualStrings("5.1.8 Bad sender", client.last_reply.?.text); 1205 try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); 1206} 1207 1208test "without PIPELINING the commands wait for each other" { 1209 // Same refusal, no PIPELINING advertised: the RCPTs are never sent. 1210 const responses = "250-mx.example.com\r\n250 8BITMIME\r\n550 5.1.8 Bad sender\r\n"; 1211 var reader: Io.Reader = .fixed(responses); 1212 var out_buf: [512]u8 = undefined; 1213 var writer: Io.Writer = .fixed(&out_buf); 1214 var reply_buf: [256]u8 = undefined; 1215 var client: Client = .init(&reader, &writer, &reply_buf); 1216 1217 _ = try client.hello("client.example.org"); 1218 try std.testing.expect(!client.pipelining); 1219 1220 const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" }; 1221 try std.testing.expectError( 1222 error.UnexpectedReply, 1223 client.envelope("alice@example.com", recipients, null, .{}), 1224 ); 1225 try std.testing.expectEqualStrings( 1226 "EHLO client.example.org\r\nMAIL FROM:<alice@example.com>\r\n", 1227 writer.buffered(), 1228 ); 1229} 1230 1231test "envelope reports which recipients were refused" { 1232 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ 1233 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n250 2.1.5 Ok\r\n"; 1234 var reader: Io.Reader = .fixed(responses); 1235 var out_buf: [512]u8 = undefined; 1236 var writer: Io.Writer = .fixed(&out_buf); 1237 var reply_buf: [256]u8 = undefined; 1238 var client: Client = .init(&reader, &writer, &reply_buf); 1239 1240 _ = try client.hello("client.example.org"); 1241 const recipients: []const []const u8 = &.{ 1242 "bob@example.net", 1243 "nobody@example.net", 1244 "carol@example.net", 1245 }; 1246 var codes: [3]u16 = undefined; 1247 const accepted = try client.envelope("alice@example.com", recipients, &codes, .{}); 1248 1249 try std.testing.expectEqual(@as(usize, 2), accepted); 1250 try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes); 1251 // A refused recipient is not an error here, so the transaction is still 1252 // open and the client knows how many it may deliver to. 1253 try std.testing.expectEqual(@as(usize, 2), client.accepted_recipients); 1254} 1255 1256test "the same envelope works the same way without pipelining" { 1257 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n250 2.1.5 Ok\r\n"; 1258 var reader: Io.Reader = .fixed(responses); 1259 var out_buf: [512]u8 = undefined; 1260 var writer: Io.Writer = .fixed(&out_buf); 1261 var reply_buf: [256]u8 = undefined; 1262 var client: Client = .init(&reader, &writer, &reply_buf); 1263 1264 const recipients: []const []const u8 = &.{ 1265 "bob@example.net", 1266 "nobody@example.net", 1267 "carol@example.net", 1268 }; 1269 var codes: [3]u16 = undefined; 1270 const accepted = try client.envelope("alice@example.com", recipients, &codes, .{}); 1271 try std.testing.expectEqual(@as(usize, 2), accepted); 1272 try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes); 1273} 1274 1275test "sendMail abandons the transaction rather than deliver to some" { 1276 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ 1277 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n" ++ 1278 "250 2.0.0 Ok\r\n"; // the RSET 1279 var reader: Io.Reader = .fixed(responses); 1280 var out_buf: [512]u8 = undefined; 1281 var writer: Io.Writer = .fixed(&out_buf); 1282 var reply_buf: [256]u8 = undefined; 1283 var client: Client = .init(&reader, &writer, &reply_buf); 1284 1285 _ = try client.hello("client.example.org"); 1286 const recipients: []const []const u8 = &.{ "bob@example.net", "nobody@example.net" }; 1287 try std.testing.expectError( 1288 error.UnexpectedReply, 1289 client.sendMail("alice@example.com", recipients, "hi\r\n"), 1290 ); 1291 // DATA was never sent, so nothing reached the recipient that was 1292 // accepted, and the session was left clean for the next transaction. 1293 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "DATA") == null); 1294 try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "RSET\r\n")); 1295} 1296 1297test "LMTP greets with LHLO and reads one verdict per recipient" { 1298 const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ // LHLO 1299 "250 2.1.0 Ok\r\n" ++ // MAIL 1300 "250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ // two RCPTs 1301 "354 End data\r\n" ++ 1302 "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; // one per recipient 1303 var reader: Io.Reader = .fixed(responses); 1304 var out_buf: [512]u8 = undefined; 1305 var writer: Io.Writer = .fixed(&out_buf); 1306 var reply_buf: [256]u8 = undefined; 1307 var client: Client = .init(&reader, &writer, &reply_buf); 1308 client.mode = .lmtp; 1309 1310 _ = try client.hello("client.example.org"); 1311 try client.mailFrom("alice@example.com"); 1312 try client.rcptTo("good@example.net"); 1313 try client.rcptTo("bad@example.net"); 1314 1315 var data_writer = try client.data(); 1316 try data_writer.interface.writeAll("hi\r\n"); 1317 var verdicts = try data_writer.endResults(); 1318 1319 const first = (try verdicts.next()).?; 1320 try std.testing.expectEqual(@as(u16, 250), first.code); 1321 try std.testing.expectEqual(@as(usize, 1), verdicts.index); 1322 const second = (try verdicts.next()).?; 1323 try std.testing.expectEqual(@as(u16, 550), second.code); 1324 try std.testing.expectEqualStrings("5.2.1 Mailbox disabled", second.text); 1325 try std.testing.expectEqual(@as(?Reply, null), try verdicts.next()); 1326 1327 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "LHLO client.example.org\r\n")); 1328} 1329 1330test "end reports an LMTP rejection distinctly from an SMTP one" { 1331 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n354 End data\r\n" ++ 1332 "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; 1333 var reader: Io.Reader = .fixed(responses); 1334 var out_buf: [512]u8 = undefined; 1335 var writer: Io.Writer = .fixed(&out_buf); 1336 var reply_buf: [256]u8 = undefined; 1337 var client: Client = .init(&reader, &writer, &reply_buf); 1338 client.mode = .lmtp; 1339 1340 try client.mailFrom("alice@example.com"); 1341 try client.rcptTo("good@example.net"); 1342 try client.rcptTo("bad@example.net"); 1343 // Both verdicts are read even though the first already decided the 1344 // outcome, or the next command would be answered by a stale reply. 1345 try std.testing.expectError(error.RecipientRejected, client.sendMessage("hi\r\n")); 1346 1347 // The single-reply case keeps `error.UnexpectedReply`, where 1348 // `last_reply` can actually say what happened. 1349 var smtp_reader: Io.Reader = .fixed("354 End data\r\n550 5.7.1 Rejected\r\n"); 1350 var smtp_out: [256]u8 = undefined; 1351 var smtp_writer: Io.Writer = .fixed(&smtp_out); 1352 var smtp_reply_buf: [256]u8 = undefined; 1353 var smtp: Client = .init(&smtp_reader, &smtp_writer, &smtp_reply_buf); 1354 try std.testing.expectError(error.UnexpectedReply, smtp.sendMessage("hi\r\n")); 1355 try std.testing.expectEqualStrings("5.7.1 Rejected", smtp.last_reply.?.text); 1356} 1357 1358test "the recipient count resets with each new transaction" { 1359 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n" ++ // MAIL, RCPT 1360 "250 2.0.0 Ok\r\n" ++ // RSET 1361 "250 2.1.0 Ok\r\n"; // MAIL again 1362 var reader: Io.Reader = .fixed(responses); 1363 var out_buf: [512]u8 = undefined; 1364 var writer: Io.Writer = .fixed(&out_buf); 1365 var reply_buf: [256]u8 = undefined; 1366 var client: Client = .init(&reader, &writer, &reply_buf); 1367 client.mode = .lmtp; 1368 1369 try client.mailFrom("alice@example.com"); 1370 try client.rcptTo("bob@example.net"); 1371 try std.testing.expectEqual(@as(usize, 1), client.results().remaining); 1372 try client.rset(); 1373 try std.testing.expectEqual(@as(usize, 0), client.results().remaining); 1374 try client.mailFrom("alice@example.com"); 1375 try std.testing.expectEqual(@as(usize, 0), client.results().remaining); 1376} 1377 1378test "mail and rcpt carry the DSN parameters" { 1379 const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n"; 1380 var reader: Io.Reader = .fixed(responses); 1381 var out_buf: [256]u8 = undefined; 1382 var writer: Io.Writer = .fixed(&out_buf); 1383 var reply_buf: [64]u8 = undefined; 1384 var client: Client = .init(&reader, &writer, &reply_buf); 1385 1386 try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" }); 1387 try client.rcpt("bob@example.net", .{ 1388 .notify = .{ .on = .{ .failure = true, .delay = true } }, 1389 .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" }, 1390 }); 1391 try std.testing.expectEqualStrings( 1392 "MAIL FROM:<me@example.com> RET=HDRS ENVID=batch+207\r\n" ++ 1393 "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n", 1394 writer.buffered(), 1395 ); 1396} 1397 1398test "REQUIRETLS is refused on a session with nothing to guarantee" { 1399 var reader: Io.Reader = .fixed(""); 1400 var out_buf: [256]u8 = undefined; 1401 var writer: Io.Writer = .fixed(&out_buf); 1402 var reply_buf: [64]u8 = undefined; 1403 var client: Client = .init(&reader, &writer, &reply_buf); 1404 1405 // Asking for a guarantee over a channel that has none is asking for 1406 // nothing, so it is refused here rather than sent and relied upon. 1407 try std.testing.expectError( 1408 error.InsecureTransport, 1409 client.mail("a@example.com", .{ .require_tls = true }), 1410 ); 1411 try std.testing.expectEqualStrings("", writer.buffered()); 1412} 1413 1414test "REQUIRETLS goes out once the session is encrypted" { 1415 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250 REQUIRETLS\r\n250 2.1.0 Ok\r\n"); 1416 var out_buf: [512]u8 = undefined; 1417 var writer: Io.Writer = .fixed(&out_buf); 1418 var reply_buf: [256]u8 = undefined; 1419 var client: Client = .init(&reader, &writer, &reply_buf); 1420 client.security = .encrypted; 1421 1422 const extensions = try client.hello("client.example.org"); 1423 try std.testing.expect(extensions.requiretls); 1424 1425 try client.mail("a@example.com", .{ .require_tls = true }); 1426 try std.testing.expect(std.mem.endsWith( 1427 u8, 1428 writer.buffered(), 1429 "MAIL FROM:<a@example.com> REQUIRETLS\r\n", 1430 )); 1431} 1432 1433test "mail carries AUTH= for a relay speaking for somebody else" { 1434 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n250 2.1.0 Ok\r\n"); 1435 var out_buf: [512]u8 = undefined; 1436 var writer: Io.Writer = .fixed(&out_buf); 1437 var reply_buf: [64]u8 = undefined; 1438 var client: Client = .init(&reader, &writer, &reply_buf); 1439 1440 try client.mail("relay@example.com", .{ 1441 .auth = .{ .mailbox = "e=mc2@example.com" }, 1442 }); 1443 // The '=' is escaped, because an unescaped one would end the parameter. 1444 try std.testing.expectEqualStrings( 1445 "MAIL FROM:<relay@example.com> AUTH=e+3Dmc2@example.com\r\n", 1446 writer.buffered(), 1447 ); 1448 1449 // `<>` is what a relay sends when it cannot vouch for anybody, and RFC 1450 // 4954 asks for that rather than leaving the parameter off. 1451 var second_buf: [256]u8 = undefined; 1452 var second: Io.Writer = .fixed(&second_buf); 1453 client.setTransport(&reader, &second, .plaintext); 1454 try client.mail("relay@example.com", .{ .auth = .unknown }); 1455 try std.testing.expectEqualStrings( 1456 "MAIL FROM:<relay@example.com> AUTH=<>\r\n", 1457 second.buffered(), 1458 ); 1459} 1460 1461test "an AUTH= mailbox that will not fit is refused before it is sent" { 1462 var reader: Io.Reader = .fixed(""); 1463 var out_buf: [1024]u8 = undefined; 1464 var writer: Io.Writer = .fixed(&out_buf); 1465 var reply_buf: [64]u8 = undefined; 1466 var client: Client = .init(&reader, &writer, &reply_buf); 1467 1468 try std.testing.expectError(error.ArgumentTooLong, client.mail("a@b", .{ 1469 .auth = .{ .mailbox = "x" ** (protocol.Submitter.max_len + 1) }, 1470 })); 1471 // An empty mailbox is not `<>`; the caller meant one or the other. 1472 try std.testing.expectError(error.UnsafeArgument, client.mail("a@b", .{ 1473 .auth = .{ .mailbox = "" }, 1474 })); 1475 try std.testing.expectEqualStrings("", writer.buffered()); 1476} 1477 1478test "NOTIFY=NEVER is written on its own" { 1479 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 1480 var out_buf: [128]u8 = undefined; 1481 var writer: Io.Writer = .fixed(&out_buf); 1482 var reply_buf: [64]u8 = undefined; 1483 var client: Client = .init(&reader, &writer, &reply_buf); 1484 1485 try client.rcpt("bob@example.net", .{ .notify = .never }); 1486 try std.testing.expectEqualStrings( 1487 "RCPT TO:<bob@example.net> NOTIFY=NEVER\r\n", 1488 writer.buffered(), 1489 ); 1490} 1491 1492test "DSN parameter values that exceed their limits are refused" { 1493 var reader: Io.Reader = .fixed(""); 1494 var out_buf: [1024]u8 = undefined; 1495 var writer: Io.Writer = .fixed(&out_buf); 1496 var reply_buf: [64]u8 = undefined; 1497 var client: Client = .init(&reader, &writer, &reply_buf); 1498 1499 // 34 spaces encode to 102 characters, over the ENVID limit of 100, 1500 // though the value itself is well under it. 1501 const spaces = " " ** 34; 1502 try std.testing.expectError( 1503 error.ArgumentTooLong, 1504 client.mail("me@example.com", .{ .envid = spaces }), 1505 ); 1506 try std.testing.expectError(error.ArgumentTooLong, client.rcpt("bob@example.net", .{ 1507 .orcpt = .{ .addr_type = "rfc822", .address = "x" ** 500 }, 1508 })); 1509 // An addr-type is written literally, so it is checked rather than encoded. 1510 try std.testing.expectError(error.UnsafeArgument, client.rcpt("bob@example.net", .{ 1511 .orcpt = .{ .addr_type = "rfc822;evil", .address = "x@example.net" }, 1512 })); 1513 try std.testing.expectEqualStrings("", writer.buffered()); 1514} 1515 1516test "hello reports DSN support" { 1517 const responses = "250-mx.example.com\r\n250-DSN\r\n250 8BITMIME\r\n"; 1518 var reader: Io.Reader = .fixed(responses); 1519 var out_buf: [128]u8 = undefined; 1520 var writer: Io.Writer = .fixed(&out_buf); 1521 var reply_buf: [256]u8 = undefined; 1522 var client: Client = .init(&reader, &writer, &reply_buf); 1523 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1524 client.sasl_buffer = &sasl_buf; 1525 1526 const ext = try client.hello("client.example.org"); 1527 try std.testing.expect(ext.dsn); 1528} 1529 1530test authenticate { 1531 const responses = "250-mx.example.com\r\n250 AUTH PLAIN LOGIN\r\n" ++ 1532 "235 2.7.0 Accepted\r\n"; 1533 var reader: Io.Reader = .fixed(responses); 1534 var out_buf: [256]u8 = undefined; 1535 var writer: Io.Writer = .fixed(&out_buf); 1536 var reply_buf: [256]u8 = undefined; 1537 var client: Client = .init(&reader, &writer, &reply_buf); 1538 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1539 client.sasl_buffer = &sasl_buf; 1540 client.security = .encrypted; 1541 1542 const extensions = try client.hello("client.example.org"); 1543 var plain: sasl.Plain = .init("alice", "secret"); 1544 const mechanism = sasl.Client.selectFromList( 1545 &.{plain.client()}, 1546 extensions.auth, 1547 true, 1548 ).?; 1549 try client.authenticate(mechanism); 1550 1551 // base64("\x00alice\x00secret"), sent as the initial response in one 1552 // round trip rather than waiting to be asked. 1553 try std.testing.expect(std.mem.endsWith( 1554 u8, 1555 writer.buffered(), 1556 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n", 1557 )); 1558} 1559 1560test "a challenge-response mechanism runs through the 334s" { 1561 const responses = "250-mx.example.com\r\n250 AUTH CRAM-MD5\r\n" ++ 1562 // base64 of RFC 2195's challenge 1563 "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1564 "235 2.7.0 Accepted\r\n"; 1565 var reader: Io.Reader = .fixed(responses); 1566 var out_buf: [512]u8 = undefined; 1567 var writer: Io.Writer = .fixed(&out_buf); 1568 var reply_buf: [256]u8 = undefined; 1569 var client: Client = .init(&reader, &writer, &reply_buf); 1570 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1571 client.sasl_buffer = &sasl_buf; 1572 1573 const extensions = try client.hello("client.example.org"); 1574 var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); 1575 // CRAM-MD5 is not cleartext, so it is usable on this plaintext session. 1576 const mechanism = sasl.Client.selectFromList(&.{cram.client()}, extensions.auth, false).?; 1577 try client.authenticate(mechanism); 1578 1579 // No initial response, then the digest RFC 2195 publishes, base64'd. 1580 try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "AUTH CRAM-MD5\r\n") != null); 1581 try std.testing.expect(std.mem.endsWith( 1582 u8, 1583 writer.buffered(), 1584 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 1585 )); 1586} 1587 1588test "a mechanism that sends a credential in the clear is refused first" { 1589 var reader: Io.Reader = .fixed(""); 1590 var out_buf: [256]u8 = undefined; 1591 var writer: Io.Writer = .fixed(&out_buf); 1592 var reply_buf: [64]u8 = undefined; 1593 var client: Client = .init(&reader, &writer, &reply_buf); 1594 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1595 client.sasl_buffer = &sasl_buf; 1596 1597 var plain: sasl.Plain = .init("alice", "secret"); 1598 try std.testing.expectError( 1599 error.InsecureTransport, 1600 client.authenticate(plain.client()), 1601 ); 1602 // Nothing reached the wire, which is the point: the refusal happens 1603 // before the credential is written, not after the server rejects it. 1604 try std.testing.expectEqualStrings("", writer.buffered()); 1605 1606 client.allow_cleartext_auth = true; 1607 var accepting: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1608 client.setTransport(&accepting, &writer, .plaintext); 1609 try client.authenticate(plain.client()); 1610} 1611 1612test "a server accepting without finishing the exchange is not authenticated" { 1613 // A mechanism that has not proved what it set out to prove, which is 1614 // SCRAM's shape: `satisfied` stays false until the server's own proof 1615 // has been verified. 1616 const Unfinished = struct { 1617 fn name(_: *anyopaque) []const u8 { 1618 return "MUTUAL-TEST"; 1619 } 1620 fn initial(_: *anyopaque, out: *Io.Writer) sasl.Client.Error!sasl.Client.Initial { 1621 try out.writeAll("hello"); 1622 return .written; 1623 } 1624 fn respond(_: *anyopaque, _: []const u8, _: *Io.Writer) sasl.Client.Error!void {} 1625 fn satisfied(_: *anyopaque) bool { 1626 return false; 1627 } 1628 fn cleartext(_: *anyopaque) bool { 1629 return false; 1630 } 1631 const vtable: sasl.Client.VTable = .{ 1632 .name = name, 1633 .initial = initial, 1634 .respond = respond, 1635 .satisfied = satisfied, 1636 .cleartext = cleartext, 1637 }; 1638 }; 1639 var nothing: u8 = 0; 1640 const mechanism: sasl.Client = .{ .context = &nothing, .vtable = &Unfinished.vtable }; 1641 1642 var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1643 var out_buf: [256]u8 = undefined; 1644 var writer: Io.Writer = .fixed(&out_buf); 1645 var reply_buf: [64]u8 = undefined; 1646 var client: Client = .init(&reader, &writer, &reply_buf); 1647 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1648 client.sasl_buffer = &sasl_buf; 1649 1650 // The server said yes. The mechanism disagrees, and it is the one that 1651 // knows — this is the case nothing in this library could express before 1652 // the mechanisms moved out of it. 1653 try std.testing.expectError( 1654 error.ServerNotAuthenticated, 1655 client.authenticate(mechanism), 1656 ); 1657} 1658 1659test "a mechanism that fails mid-exchange cancels rather than stranding the session" { 1660 // PLAIN is never challenged, so a 334 makes it return BadChallenge. 1661 const responses = "334 c29tZXRoaW5n\r\n501 5.5.2 Cancelled\r\n"; 1662 var reader: Io.Reader = .fixed(responses); 1663 var out_buf: [256]u8 = undefined; 1664 var writer: Io.Writer = .fixed(&out_buf); 1665 var reply_buf: [64]u8 = undefined; 1666 var client: Client = .init(&reader, &writer, &reply_buf); 1667 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1668 client.sasl_buffer = &sasl_buf; 1669 client.security = .encrypted; 1670 1671 var plain: sasl.Plain = .init("alice", "secret"); 1672 try std.testing.expectError(error.BadChallenge, client.authenticate(plain.client())); 1673 // RFC 4954 §4's cancellation went out, so the server is not left waiting 1674 // for a line that was never coming. 1675 try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "*\r\n")); 1676 try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); 1677} 1678 1679test "authenticate needs a buffer, and says so rather than overrunning one" { 1680 var reader: Io.Reader = .fixed(""); 1681 var out_buf: [256]u8 = undefined; 1682 var writer: Io.Writer = .fixed(&out_buf); 1683 var reply_buf: [64]u8 = undefined; 1684 var client: Client = .init(&reader, &writer, &reply_buf); 1685 client.security = .encrypted; 1686 1687 var plain: sasl.Plain = .init("alice", "secret"); 1688 // No buffer at all: this is the default, and it is an error rather than 1689 // a hidden allocation or a stack array the caller cannot see. 1690 try std.testing.expectError( 1691 error.SaslBufferTooSmall, 1692 client.authenticate(plain.client()), 1693 ); 1694 1695 var tiny: [sasl_buffer_min - 1]u8 = undefined; 1696 client.sasl_buffer = &tiny; 1697 try std.testing.expectError( 1698 error.SaslBufferTooSmall, 1699 client.authenticate(plain.client()), 1700 ); 1701 try std.testing.expectEqualStrings("", writer.buffered()); 1702} 1703 1704test "the two halves of the buffer take turns rather than coexist" { 1705 // A challenge decodes into the coded half, the mechanism's answer is 1706 // written into the plain half, and the answer encodes back over the 1707 // challenge. The minimum buffer is enough to run a real exchange, which 1708 // is what this checks: at 896 bytes there are 512 coded and 384 plain. 1709 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1710 "235 2.7.0 Accepted\r\n"; 1711 var reader: Io.Reader = .fixed(responses); 1712 var out_buf: [512]u8 = undefined; 1713 var writer: Io.Writer = .fixed(&out_buf); 1714 var reply_buf: [256]u8 = undefined; 1715 var client: Client = .init(&reader, &writer, &reply_buf); 1716 var scratch: [sasl_buffer_min]u8 = undefined; 1717 client.sasl_buffer = &scratch; 1718 1719 var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); 1720 try client.authenticate(cram.client()); 1721 try std.testing.expect(std.mem.endsWith( 1722 u8, 1723 writer.buffered(), 1724 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 1725 )); 1726} 1727 1728test "a rejection surfaces as AuthenticationFailed with the reply" { 1729 var reader: Io.Reader = .fixed("535 5.7.8 Authentication credentials invalid\r\n"); 1730 var out_buf: [256]u8 = undefined; 1731 var writer: Io.Writer = .fixed(&out_buf); 1732 var reply_buf: [256]u8 = undefined; 1733 var client: Client = .init(&reader, &writer, &reply_buf); 1734 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1735 client.sasl_buffer = &sasl_buf; 1736 client.security = .encrypted; 1737 1738 var plain: sasl.Plain = .init("alice", "secret"); 1739 try std.testing.expectError( 1740 error.AuthenticationFailed, 1741 client.authenticate(plain.client()), 1742 ); 1743 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 1744} 1745 1746test "a rejection's enhanced status code says more than its reply code" { 1747 // Two different refusals behind the same 550: one about the address, 1748 // one about policy. The three-digit code cannot tell them apart and the 1749 // enhanced one can, which is the whole reason to read it. 1750 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n550 5.1.1 No such user\r\n"); 1751 var out_buf: [256]u8 = undefined; 1752 var writer: Io.Writer = .fixed(&out_buf); 1753 var reply_buf: [256]u8 = undefined; 1754 var client: Client = .init(&reader, &writer, &reply_buf); 1755 1756 try client.mailFrom("alice@example.com"); 1757 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.net")); 1758 1759 const reply = client.last_reply.?; 1760 const status = reply.enhanced().?; 1761 try std.testing.expect(status.agrees(reply.code)); 1762 try std.testing.expectEqual(protocol.Enhanced.Subject.addressing, status.subjectClass()); 1763 try std.testing.expectEqual(@as(u16, 1), status.detail); 1764 // And the part meant for a person, without the code in front of it. 1765 try std.testing.expectEqualStrings("No such user", reply.message()); 1766} 1767 1768test "a server that contradicts itself is detectable" { 1769 // 250 carrying a 5.x.x code. Nothing in RFC 3463 says what to do about 1770 // it, but a caller can at least see it rather than trusting either half. 1771 var reader: Io.Reader = .fixed("250 5.1.1 Ok?\r\n"); 1772 var out_buf: [128]u8 = undefined; 1773 var writer: Io.Writer = .fixed(&out_buf); 1774 var reply_buf: [128]u8 = undefined; 1775 var client: Client = .init(&reader, &writer, &reply_buf); 1776 1777 try client.mailFrom("alice@example.com"); // the 2xx is what `mail` checks 1778 const reply = client.last_reply.?; 1779 try std.testing.expect(!reply.enhanced().?.agrees(reply.code)); 1780} 1781 1782test "a multiline reply repeats the code on every line" { 1783 const responses = "250-mx.example.com\r\n250 SIZE 1000000\r\n" ++ 1784 "452-4.5.3 Too many recipients\r\n452 4.5.3 Try fewer\r\n"; 1785 var reader: Io.Reader = .fixed(responses); 1786 var out_buf: [256]u8 = undefined; 1787 var writer: Io.Writer = .fixed(&out_buf); 1788 var reply_buf: [256]u8 = undefined; 1789 var client: Client = .init(&reader, &writer, &reply_buf); 1790 1791 _ = try client.hello("client.example.org"); 1792 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("b@example.net")); 1793 1794 const reply = client.last_reply.?; 1795 // `message` strips the first line's code; the rest are reached through 1796 // `lines`, which is what the doc comment says to do. 1797 try std.testing.expectEqualStrings("Too many recipients\nTry fewer", blk: { 1798 var joined: [64]u8 = undefined; 1799 var out: Io.Writer = .fixed(&joined); 1800 var it = reply.lines(); 1801 var first = true; 1802 while (it.next()) |line| { 1803 if (!first) try out.writeByte('\n'); 1804 first = false; 1805 try out.writeAll(protocol.Enhanced.strip(line)); 1806 } 1807 break :blk out.buffered(); 1808 }); 1809} 1810 1811test "an address carrying CRLF cannot inject a command" { 1812 // Without the check this would put a second RCPT on the wire. 1813 const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net"; 1814 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 1815 var out_buf: [256]u8 = undefined; 1816 var writer: Io.Writer = .fixed(&out_buf); 1817 var reply_buf: [64]u8 = undefined; 1818 var client: Client = .init(&reader, &writer, &reply_buf); 1819 1820 try std.testing.expectError(error.UnsafeArgument, client.rcptTo(smuggled)); 1821 try std.testing.expectError(error.UnsafeArgument, client.mailFrom(smuggled)); 1822 try std.testing.expectError(error.UnsafeArgument, client.mailFromUtf8(smuggled)); 1823 try std.testing.expectError(error.UnsafeArgument, client.hello("host\r\nQUIT")); 1824 // Nothing reached the wire, so the session is still where it was. 1825 try std.testing.expectEqualStrings("", writer.buffered()); 1826} 1827 1828test hello { 1829 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 1830 var reader: Io.Reader = .fixed(responses); 1831 var out_buf: [256]u8 = undefined; 1832 var writer: Io.Writer = .fixed(&out_buf); 1833 var reply_buf: [256]u8 = undefined; 1834 var client: Client = .init(&reader, &writer, &reply_buf); 1835 1836 const ext = try client.hello("c.example"); 1837 try std.testing.expectEqualStrings("PLAIN LOGIN CRAM-MD5", ext.auth); 1838} 1839 1840test init { 1841 var reader: Io.Reader = .fixed(""); 1842 var out_buf: [16]u8 = undefined; 1843 var writer: Io.Writer = .fixed(&out_buf); 1844 var reply_buf: [128]u8 = undefined; 1845 const client: Client = .init(&reader, &writer, &reply_buf); 1846 try std.testing.expect(client.last_reply == null); 1847} 1848 1849test greet { 1850 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n"); 1851 var out_buf: [16]u8 = undefined; 1852 var writer: Io.Writer = .fixed(&out_buf); 1853 var reply_buf: [128]u8 = undefined; 1854 var client: Client = .init(&reader, &writer, &reply_buf); 1855 1856 const reply = try client.greet(); 1857 try std.testing.expectEqual(@as(u16, 220), reply.code); 1858 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text); 1859} 1860 1861test setTransport { 1862 var reader: Io.Reader = .fixed(""); 1863 var out_buf: [16]u8 = undefined; 1864 var writer: Io.Writer = .fixed(&out_buf); 1865 var reply_buf: [64]u8 = undefined; 1866 var client: Client = .init(&reader, &writer, &reply_buf); 1867 1868 // After a TLS handshake, point the session at the encrypted streams. 1869 var tls_reader: Io.Reader = .fixed(""); 1870 var tls_out_buf: [16]u8 = undefined; 1871 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 1872 client.setTransport(&tls_reader, &tls_writer, .encrypted); 1873 try std.testing.expectEqual(&tls_reader, client.reader); 1874 try std.testing.expectEqual(&tls_writer, client.writer); 1875 try std.testing.expectEqual(Security.encrypted, client.security); 1876} 1877 1878test mailFrom { 1879 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 1880 var out_buf: [64]u8 = undefined; 1881 var writer: Io.Writer = .fixed(&out_buf); 1882 var reply_buf: [64]u8 = undefined; 1883 var client: Client = .init(&reader, &writer, &reply_buf); 1884 1885 try client.mailFrom("alice@example.com"); 1886 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered()); 1887} 1888 1889test rcptTo { 1890 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 1891 var out_buf: [64]u8 = undefined; 1892 var writer: Io.Writer = .fixed(&out_buf); 1893 var reply_buf: [64]u8 = undefined; 1894 var client: Client = .init(&reader, &writer, &reply_buf); 1895 1896 try client.rcptTo("bob@example.net"); 1897 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered()); 1898} 1899 1900test sendMessage { 1901 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n"); 1902 var out_buf: [128]u8 = undefined; 1903 var writer: Io.Writer = .fixed(&out_buf); 1904 var reply_buf: [64]u8 = undefined; 1905 var client: Client = .init(&reader, &writer, &reply_buf); 1906 1907 try client.sendMessage("Subject: hi\n\nhello\n"); 1908 try std.testing.expectEqualStrings( 1909 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n", 1910 writer.buffered(), 1911 ); 1912} 1913 1914test rset { 1915 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 1916 var out_buf: [16]u8 = undefined; 1917 var writer: Io.Writer = .fixed(&out_buf); 1918 var reply_buf: [64]u8 = undefined; 1919 var client: Client = .init(&reader, &writer, &reply_buf); 1920 1921 try client.rset(); 1922 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered()); 1923} 1924 1925test noop { 1926 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 1927 var out_buf: [16]u8 = undefined; 1928 var writer: Io.Writer = .fixed(&out_buf); 1929 var reply_buf: [64]u8 = undefined; 1930 var client: Client = .init(&reader, &writer, &reply_buf); 1931 1932 try client.noop(); 1933 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered()); 1934} 1935 1936test quit { 1937 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n"); 1938 var out_buf: [16]u8 = undefined; 1939 var writer: Io.Writer = .fixed(&out_buf); 1940 var reply_buf: [64]u8 = undefined; 1941 var client: Client = .init(&reader, &writer, &reply_buf); 1942 1943 try client.quit(); 1944 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered()); 1945} 1946 1947test data { 1948 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 1949 var out_buf: [256]u8 = undefined; 1950 var writer: Io.Writer = .fixed(&out_buf); 1951 var reply_buf: [64]u8 = undefined; 1952 var client: Client = .init(&reader, &writer, &reply_buf); 1953 1954 // Chunks may split lines, CRLF pairs, and leading dots arbitrarily. 1955 var data_writer = try client.data(); 1956 try data_writer.interface.writeAll("Subject: chunked\n\nfirst"); 1957 try data_writer.interface.writeAll(" second\r"); 1958 try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r"); 1959 try data_writer.interface.writeAll("\n"); 1960 try data_writer.interface.writeAll(".x\nend"); 1961 try data_writer.end(); 1962 1963 try std.testing.expectEqualStrings( 1964 "DATA\r\n" ++ 1965 "Subject: chunked\r\n" ++ 1966 "\r\n" ++ 1967 "first second\r\n" ++ 1968 "..needs stuffing\r\n" ++ 1969 "split\r\n" ++ 1970 "..x\r\n" ++ 1971 "end\r\n" ++ 1972 ".\r\n", 1973 writer.buffered(), 1974 ); 1975} 1976 1977test sendMessageReader { 1978 var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 1979 var out_buf: [128]u8 = undefined; 1980 var writer: Io.Writer = .fixed(&out_buf); 1981 var reply_buf: [64]u8 = undefined; 1982 var client: Client = .init(&reader, &writer, &reply_buf); 1983 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1984 client.sasl_buffer = &sasl_buf; 1985 1986 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n"); 1987 try client.sendMessageReader(&message); 1988 try std.testing.expectEqualStrings( 1989 "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n", 1990 writer.buffered(), 1991 ); 1992} 1993 1994test "fuzz client against arbitrary server replies" { 1995 try std.testing.fuzz({}, fuzzClientReplies, .{}); 1996} 1997 1998fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void { 1999 _ = context; 2000 var input_buf: [1024]u8 = undefined; 2001 const input = input_buf[0..smith.value(u10)]; 2002 smith.bytes(input); 2003 2004 var reader: Io.Reader = .fixed(input); 2005 var out_buf: [4096]u8 = undefined; 2006 var writer: Io.Writer = .fixed(&out_buf); 2007 var reply_buf: [256]u8 = undefined; 2008 var client: Client = .init(&reader, &writer, &reply_buf); 2009 var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 2010 client.sasl_buffer = &sasl_buf; 2011 2012 // Whatever the "server" says, the client must fail cleanly, never crash. 2013 _ = client.greet() catch return; 2014 const extensions = client.hello("fuzz.example.org") catch return; 2015 var plain: sasl.Plain = .init("user", "password"); 2016 client.allow_cleartext_auth = true; 2017 if (sasl.Client.selectFromList(&.{plain.client()}, extensions.auth, true)) |mechanism| 2018 client.authenticate(mechanism) catch {}; 2019 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; 2020 client.quit() catch {}; 2021} 2022 2023test "fuzz DataWriter equivalence with writeStuffed" { 2024 try std.testing.fuzz({}, fuzzDataWriter, .{}); 2025} 2026 2027fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void { 2028 _ = context; 2029 var message_buf: [1024]u8 = undefined; 2030 const message = message_buf[0..smith.value(u10)]; 2031 smith.bytes(message); 2032 2033 // Reference implementation: slice-based stuffing. 2034 var expected_buf: [2100]u8 = undefined; 2035 var expected: Io.Writer = .fixed(&expected_buf); 2036 try protocol.writeStuffed(&expected, message); 2037 2038 // Streaming implementation, with fuzzer-chosen chunk boundaries. 2039 var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n"); 2040 var out_buf: [2200]u8 = undefined; 2041 var writer: Io.Writer = .fixed(&out_buf); 2042 var reply_buf: [64]u8 = undefined; 2043 var client: Client = .init(&responses, &writer, &reply_buf); 2044 2045 var data_writer = try client.data(); 2046 var rest: []const u8 = message; 2047 while (rest.len > 0) { 2048 const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len)); 2049 try data_writer.interface.writeAll(rest[0..n]); 2050 rest = rest[n..]; 2051 } 2052 try data_writer.end(); 2053 2054 const written = writer.buffered(); 2055 try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n")); 2056 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); 2057 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; 2058 try std.testing.expectEqualStrings(expected.buffered(), stuffed); 2059} 2060 2061test Extensions { 2062 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; 2063 try std.testing.expect(extensions.pipelining); 2064 try std.testing.expect(!extensions.starttls); 2065 try std.testing.expectEqualStrings("", extensions.auth); 2066 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 2067} 2068 2069test bdat { 2070 var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n"); 2071 var out_buf: [128]u8 = undefined; 2072 var writer: Io.Writer = .fixed(&out_buf); 2073 var reply_buf: [64]u8 = undefined; 2074 var client: Client = .init(&reader, &writer, &reply_buf); 2075 2076 try client.bdat("Subject: hi\r\n\r\n", false); 2077 try client.bdat("body\r\n", true); 2078 try std.testing.expectEqualStrings( 2079 "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n", 2080 writer.buffered(), 2081 ); 2082} 2083 2084test sendMessageChunked { 2085 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 2086 var out_buf: [128]u8 = undefined; 2087 var writer: Io.Writer = .fixed(&out_buf); 2088 var reply_buf: [64]u8 = undefined; 2089 var client: Client = .init(&reader, &writer, &reply_buf); 2090 2091 // Raw transmission: the leading dot is not stuffed. 2092 try client.sendMessageChunked(".raw\r\n"); 2093 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); 2094} 2095 2096test mailFromUtf8 { 2097 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 2098 var out_buf: [64]u8 = undefined; 2099 var writer: Io.Writer = .fixed(&out_buf); 2100 var reply_buf: [64]u8 = undefined; 2101 var client: Client = .init(&reader, &writer, &reply_buf); 2102 2103 try client.mailFromUtf8("böb@example.com"); 2104 try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered()); 2105}