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
23 kB 620 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! An SMTP client session over any `Io.Reader`/`Io.Writer` pair, which keeps 5//! it transport-agnostic: wrap a TCP stream for real use, or fixed buffers 6//! for testing. TLS can be layered in the same way once the transport 7//! supports it. 8//! 9//! Typical use: 10//! ``` 11//! var client: Client = .init(&stream_reader, &stream_writer, &reply_buf); 12//! _ = try client.greet(); 13//! _ = try client.hello("my-host.example.com"); 14//! try client.sendMail("me@example.com", &.{"you@example.net"}, message); 15//! try client.quit(); 16//! ``` 17 18const Client = @This(); 19 20const std = @import("std"); 21const Io = std.Io; 22const protocol = @import("protocol.zig"); 23const Reply = protocol.Reply; 24 25reader: *Io.Reader, 26writer: *Io.Writer, 27/// Backing storage for reply text; `last_reply.text` points into it. 28reply_buffer: []u8, 29/// The most recent reply read from the server. Useful for reporting the 30/// server's actual response after an `error.UnexpectedReply`. 31last_reply: ?Reply = null, 32 33pub const Error = error{ 34 WriteFailed, 35 ReadFailed, 36 EndOfStream, 37 LineTooLong, 38 InvalidReply, 39 ReplyTooLong, 40 /// The server answered with an unexpected code; see `last_reply`. 41 UnexpectedReply, 42}; 43 44/// Extensions advertised in the server's EHLO response. 45pub const Extensions = struct { 46 pipelining: bool = false, 47 eight_bit_mime: bool = false, 48 starttls: bool = false, 49 smtputf8: bool = false, 50 enhanced_status_codes: bool = false, 51 /// AUTH mechanisms advertised by the server. 52 auth: Auth = .{}, 53 /// Value of the SIZE extension, if advertised with a value. 54 max_size: ?u64 = null, 55 56 pub const Auth = struct { 57 plain: bool = false, 58 login: bool = false, 59 cram_md5: bool = false, 60 61 pub fn any(a: Auth) bool { 62 return a.plain or a.login or a.cram_md5; 63 } 64 65 fn parse(arg: []const u8) Auth { 66 var auth: Auth = .{}; 67 var it = std.mem.tokenizeScalar(u8, arg, ' '); 68 while (it.next()) |mechanism| { 69 if (ieql(mechanism, "PLAIN")) { 70 auth.plain = true; 71 } else if (ieql(mechanism, "LOGIN")) { 72 auth.login = true; 73 } else if (ieql(mechanism, "CRAM-MD5")) { 74 auth.cram_md5 = true; 75 } 76 } 77 return auth; 78 } 79 }; 80 81 fn parse(reply: Reply) Extensions { 82 var ext: Extensions = .{}; 83 var it = reply.lines(); 84 _ = it.next(); // The first line is the server's greeting, not a keyword. 85 while (it.next()) |line| { 86 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; 87 const kw = line[0..kw_end]; 88 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else ""; 89 if (ieql(kw, "PIPELINING")) { 90 ext.pipelining = true; 91 } else if (ieql(kw, "8BITMIME")) { 92 ext.eight_bit_mime = true; 93 } else if (ieql(kw, "STARTTLS")) { 94 ext.starttls = true; 95 } else if (ieql(kw, "SMTPUTF8")) { 96 ext.smtputf8 = true; 97 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 98 ext.enhanced_status_codes = true; 99 } else if (ieql(kw, "AUTH")) { 100 ext.auth = Auth.parse(arg); 101 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { 102 // Some legacy servers advertise "AUTH=PLAIN LOGIN". 103 var legacy_arg_buf: [128]u8 = undefined; 104 const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..]; 105 ext.auth = Auth.parse(joined); 106 } else if (ieql(kw, "SIZE")) { 107 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 108 } 109 } 110 return ext; 111 } 112 113 fn ieql(a: []const u8, b: []const u8) bool { 114 return std.ascii.eqlIgnoreCase(a, b); 115 } 116}; 117 118/// `reply_buffer` must be large enough for the largest expected reply text 119/// (the EHLO response is usually the largest); 512 bytes is plenty in 120/// practice. 121pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client { 122 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer }; 123} 124 125/// Reads the server's 220 greeting. Call once, right after connecting. 126pub fn greet(c: *Client) Error!Reply { 127 return c.expect(220); 128} 129 130/// Sends EHLO and returns the extensions the server advertised, falling back 131/// to plain HELO for servers that do not speak ESMTP. 132pub fn hello(c: *Client, client_name: []const u8) Error!Extensions { 133 try c.send("EHLO {s}", .{client_name}); 134 const reply = try c.readReply(); 135 if (reply.isPositiveCompletion()) return Extensions.parse(reply); 136 if (reply.code == 500 or reply.code == 502) { 137 try c.send("HELO {s}", .{client_name}); 138 _ = try c.expectClass(2); 139 return .{}; 140 } 141 return error.UnexpectedReply; 142} 143 144/// Sends STARTTLS (RFC 3207) and reads the server's 220 go-ahead. On 145/// success, perform a TLS handshake over the underlying stream (see `Tls`), 146/// switch to the encrypted transport with `setTransport`, and then call 147/// `hello` again — the server discards everything it learned before the 148/// handshake, including the EHLO state. 149pub fn starttls(c: *Client) Error!void { 150 try c.send("STARTTLS", .{}); 151 _ = try c.expect(220); 152} 153 154/// Replaces the session's transport, typically with a TLS reader/writer 155/// after `starttls`. 156pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void { 157 c.reader = reader; 158 c.writer = writer; 159} 160 161pub const AuthError = Error || error{ 162 CredentialsTooLong, 163 /// The server rejected the credentials; see `last_reply`. 164 AuthenticationFailed, 165 /// The server's CRAM-MD5 challenge was not valid base64. 166 InvalidChallenge, 167 /// The server advertised none of the supported mechanisms. 168 NoSupportedMechanism, 169}; 170 171/// Authenticates with the best mechanism the server advertised (PLAIN, 172/// then LOGIN, then CRAM-MD5). Note that PLAIN and LOGIN send credentials 173/// unprotected: use TLS on real networks. 174pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void { 175 if (extensions.auth.plain) return c.authPlain("", username, password); 176 if (extensions.auth.login) return c.authLogin(username, password); 177 if (extensions.auth.cram_md5) return c.authCramMd5(username, password); 178 return error.NoSupportedMechanism; 179} 180 181/// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless 182/// you need to act on behalf of another identity. Note that sending 183/// credentials over an unencrypted connection exposes them to the network. 184pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { 185 var plain_buf: [512]u8 = undefined; 186 var plain: Io.Writer = .fixed(&plain_buf); 187 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch 188 return error.CredentialsTooLong; 189 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; 190 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); 191 try c.send("AUTH PLAIN {s}", .{b64}); 192 try c.expectAuthSuccess(); 193} 194 195/// Authenticates with AUTH LOGIN, the legacy two-step username/password 196/// exchange still required by some servers. 197pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { 198 try c.send("AUTH LOGIN", .{}); 199 _ = try c.expect(334); // Username: prompt 200 try c.sendBase64(username); 201 _ = try c.expect(334); // Password: prompt 202 try c.sendBase64(password); 203 try c.expectAuthSuccess(); 204} 205 206/// Authenticates with AUTH CRAM-MD5 (RFC 2195): the password never crosses 207/// the wire, only an HMAC-MD5 of the server's challenge. 208pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { 209 try c.send("AUTH CRAM-MD5", .{}); 210 const reply = try c.expect(334); 211 212 var challenge_buf: [512]u8 = undefined; 213 const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch 214 return error.InvalidChallenge; 215 if (challenge_len > challenge_buf.len) return error.InvalidChallenge; 216 std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch 217 return error.InvalidChallenge; 218 219 var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined; 220 std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password); 221 const digest = std.fmt.bytesToHex(mac, .lower); 222 223 var response_buf: [384]u8 = undefined; 224 var response: Io.Writer = .fixed(&response_buf); 225 response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong; 226 try c.sendBase64(response.buffered()); 227 try c.expectAuthSuccess(); 228} 229 230/// Sends `bytes` base64-encoded as a bare continuation line. 231fn sendBase64(c: *Client, bytes: []const u8) AuthError!void { 232 var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined; 233 if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len) 234 return error.CredentialsTooLong; 235 const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes); 236 try c.send("{s}", .{b64}); 237} 238 239fn expectAuthSuccess(c: *Client) AuthError!void { 240 const reply = try c.readReply(); 241 if (reply.code != 235) return error.AuthenticationFailed; 242} 243 244/// Starts a mail transaction. An empty `from` sends the null reverse-path 245/// (`MAIL FROM:<>`), used for bounces. 246pub fn mailFrom(c: *Client, from: []const u8) Error!void { 247 try c.send("MAIL FROM:<{s}>", .{from}); 248 _ = try c.expectClass(2); 249} 250 251pub fn rcptTo(c: *Client, to: []const u8) Error!void { 252 try c.send("RCPT TO:<{s}>", .{to}); 253 _ = try c.expectClass(2); 254} 255 256/// Sends the message content for the current transaction (DATA). Line 257/// endings in `data` are normalized to CRLF and leading dots are stuffed. 258pub fn sendMessage(c: *Client, data: []const u8) Error!void { 259 try c.send("DATA", .{}); 260 _ = try c.expect(354); 261 try protocol.writeStuffed(c.writer, data); 262 try c.writer.writeAll("." ++ protocol.crlf); 263 try c.writer.flush(); 264 _ = try c.expectClass(2); 265} 266 267/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 268/// then DATA. Call after `greet` and `hello`. 269pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, data: []const u8) Error!void { 270 try c.mailFrom(from); 271 for (recipients) |recipient| try c.rcptTo(recipient); 272 try c.sendMessage(data); 273} 274 275/// Aborts the current mail transaction. 276pub fn rset(c: *Client) Error!void { 277 try c.send("RSET", .{}); 278 _ = try c.expectClass(2); 279} 280 281pub fn noop(c: *Client) Error!void { 282 try c.send("NOOP", .{}); 283 _ = try c.expectClass(2); 284} 285 286/// Ends the session. The connection should be closed afterwards. 287pub fn quit(c: *Client) Error!void { 288 try c.send("QUIT", .{}); 289 _ = try c.expect(221); 290} 291 292fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 293 try c.writer.print(fmt ++ protocol.crlf, args); 294 try c.writer.flush(); 295} 296 297fn readReply(c: *Client) Error!Reply { 298 const reply = try Reply.read(c.reader, c.reply_buffer); 299 c.last_reply = reply; 300 return reply; 301} 302 303fn expect(c: *Client, code: u16) Error!Reply { 304 const reply = try c.readReply(); 305 if (reply.code != code) return error.UnexpectedReply; 306 return reply; 307} 308 309fn expectClass(c: *Client, class: u16) Error!Reply { 310 const reply = try c.readReply(); 311 if (reply.code / 100 != class) return error.UnexpectedReply; 312 return reply; 313} 314 315test sendMail { 316 const responses = "220 mx.example.com ESMTP\r\n" ++ 317 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 318 "250 2.1.0 Ok\r\n" ++ 319 "250 2.1.5 Ok\r\n" ++ 320 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 321 "250 2.0.0 Ok\r\n" ++ 322 "221 2.0.0 Bye\r\n"; 323 var reader: Io.Reader = .fixed(responses); 324 var out_buf: [1024]u8 = undefined; 325 var writer: Io.Writer = .fixed(&out_buf); 326 var reply_buf: [512]u8 = undefined; 327 var client: Client = .init(&reader, &writer, &reply_buf); 328 329 _ = try client.greet(); 330 const ext = try client.hello("client.example.org"); 331 try std.testing.expect(ext.pipelining); 332 try std.testing.expect(ext.eight_bit_mime); 333 try std.testing.expect(!ext.starttls); 334 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 335 336 try client.sendMail( 337 "alice@example.com", 338 &.{"bob@example.net"}, 339 "Subject: hi\r\n\r\n.leading dot\r\n", 340 ); 341 try client.quit(); 342 343 try std.testing.expectEqualStrings( 344 "EHLO client.example.org\r\n" ++ 345 "MAIL FROM:<alice@example.com>\r\n" ++ 346 "RCPT TO:<bob@example.net>\r\n" ++ 347 "DATA\r\n" ++ 348 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 349 "QUIT\r\n", 350 writer.buffered(), 351 ); 352} 353 354test "HELO fallback for non-ESMTP servers" { 355 const responses = "220 old.example.com\r\n" ++ 356 "502 command not implemented\r\n" ++ 357 "250 old.example.com\r\n"; 358 var reader: Io.Reader = .fixed(responses); 359 var out_buf: [256]u8 = undefined; 360 var writer: Io.Writer = .fixed(&out_buf); 361 var reply_buf: [256]u8 = undefined; 362 var client: Client = .init(&reader, &writer, &reply_buf); 363 364 _ = try client.greet(); 365 const ext = try client.hello("client.example.org"); 366 try std.testing.expectEqual(Extensions{}, ext); 367 try std.testing.expectEqualStrings( 368 "EHLO client.example.org\r\nHELO client.example.org\r\n", 369 writer.buffered(), 370 ); 371} 372 373test "rejected recipient surfaces the reply" { 374 const responses = "550 5.1.1 No such user\r\n"; 375 var reader: Io.Reader = .fixed(responses); 376 var out_buf: [256]u8 = undefined; 377 var writer: Io.Writer = .fixed(&out_buf); 378 var reply_buf: [256]u8 = undefined; 379 var client: Client = .init(&reader, &writer, &reply_buf); 380 381 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 382 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 383 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 384} 385 386test starttls { 387 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 388 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 389 "220 2.0.0 Ready to start TLS\r\n"; 390 var reader: Io.Reader = .fixed(plain_responses); 391 var out_buf: [256]u8 = undefined; 392 var writer: Io.Writer = .fixed(&out_buf); 393 var reply_buf: [256]u8 = undefined; 394 var client: Client = .init(&reader, &writer, &reply_buf); 395 396 _ = try client.greet(); 397 const ext = try client.hello("client.example.org"); 398 try std.testing.expect(ext.starttls); 399 try client.starttls(); 400 401 // Simulate the post-handshake encrypted transport with fresh buffers; 402 // the session must re-EHLO on it. 403 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 404 var tls_reader: Io.Reader = .fixed(tls_responses); 405 var tls_out_buf: [256]u8 = undefined; 406 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 407 client.setTransport(&tls_reader, &tls_writer); 408 409 const tls_ext = try client.hello("client.example.org"); 410 try std.testing.expect(!tls_ext.starttls); 411 try std.testing.expect(tls_ext.eight_bit_mime); 412 try std.testing.expectEqualStrings( 413 "EHLO client.example.org\r\nSTARTTLS\r\n", 414 writer.buffered(), 415 ); 416 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 417} 418 419test authPlain { 420 const responses = "235 2.7.0 Accepted\r\n"; 421 var reader: Io.Reader = .fixed(responses); 422 var out_buf: [256]u8 = undefined; 423 var writer: Io.Writer = .fixed(&out_buf); 424 var reply_buf: [256]u8 = undefined; 425 var client: Client = .init(&reader, &writer, &reply_buf); 426 427 try client.authPlain("", "user", "pass"); 428 // base64("\x00user\x00pass") 429 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 430} 431 432test authLogin { 433 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n"; 434 var reader: Io.Reader = .fixed(responses); 435 var out_buf: [256]u8 = undefined; 436 var writer: Io.Writer = .fixed(&out_buf); 437 var reply_buf: [256]u8 = undefined; 438 var client: Client = .init(&reader, &writer, &reply_buf); 439 440 try client.authLogin("user", "pass"); 441 try std.testing.expectEqualStrings( 442 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n", 443 writer.buffered(), 444 ); 445} 446 447test authCramMd5 { 448 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", 449 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. 450 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 451 "235 2.7.0 Accepted\r\n"; 452 var reader: Io.Reader = .fixed(responses); 453 var out_buf: [256]u8 = undefined; 454 var writer: Io.Writer = .fixed(&out_buf); 455 var reply_buf: [256]u8 = undefined; 456 var client: Client = .init(&reader, &writer, &reply_buf); 457 458 try client.authCramMd5("tim", "tanstaaftanstaaf"); 459 try std.testing.expectEqualStrings( 460 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 461 writer.buffered(), 462 ); 463} 464 465test authenticate { 466 var out_buf: [256]u8 = undefined; 467 var reply_buf: [256]u8 = undefined; 468 { 469 // Only CRAM-MD5 advertised. 470 const responses = "334 YWJj\r\n235 ok\r\n"; 471 var reader: Io.Reader = .fixed(responses); 472 var writer: Io.Writer = .fixed(&out_buf); 473 var client: Client = .init(&reader, &writer, &reply_buf); 474 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p"); 475 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 476 } 477 { 478 // Nothing advertised. 479 var reader: Io.Reader = .fixed(""); 480 var writer: Io.Writer = .fixed(&out_buf); 481 var client: Client = .init(&reader, &writer, &reply_buf); 482 try std.testing.expectError( 483 error.NoSupportedMechanism, 484 client.authenticate(.{}, "u", "p"), 485 ); 486 } 487} 488 489test "rejected credentials surface AuthenticationFailed" { 490 const responses = "535 5.7.8 Authentication credentials invalid\r\n"; 491 var reader: Io.Reader = .fixed(responses); 492 var out_buf: [256]u8 = undefined; 493 var writer: Io.Writer = .fixed(&out_buf); 494 var reply_buf: [256]u8 = undefined; 495 var client: Client = .init(&reader, &writer, &reply_buf); 496 497 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p")); 498 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 499} 500 501test hello { 502 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 503 var reader: Io.Reader = .fixed(responses); 504 var out_buf: [256]u8 = undefined; 505 var writer: Io.Writer = .fixed(&out_buf); 506 var reply_buf: [256]u8 = undefined; 507 var client: Client = .init(&reader, &writer, &reply_buf); 508 509 const ext = try client.hello("c.example"); 510 try std.testing.expect(ext.auth.plain); 511 try std.testing.expect(ext.auth.login); 512 try std.testing.expect(ext.auth.cram_md5); 513 try std.testing.expect(ext.auth.any()); 514} 515 516test init { 517 var reader: Io.Reader = .fixed(""); 518 var out_buf: [16]u8 = undefined; 519 var writer: Io.Writer = .fixed(&out_buf); 520 var reply_buf: [128]u8 = undefined; 521 const client: Client = .init(&reader, &writer, &reply_buf); 522 try std.testing.expect(client.last_reply == null); 523} 524 525test greet { 526 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n"); 527 var out_buf: [16]u8 = undefined; 528 var writer: Io.Writer = .fixed(&out_buf); 529 var reply_buf: [128]u8 = undefined; 530 var client: Client = .init(&reader, &writer, &reply_buf); 531 532 const reply = try client.greet(); 533 try std.testing.expectEqual(@as(u16, 220), reply.code); 534 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text); 535} 536 537test setTransport { 538 var reader: Io.Reader = .fixed(""); 539 var out_buf: [16]u8 = undefined; 540 var writer: Io.Writer = .fixed(&out_buf); 541 var reply_buf: [64]u8 = undefined; 542 var client: Client = .init(&reader, &writer, &reply_buf); 543 544 // After a TLS handshake, point the session at the encrypted streams. 545 var tls_reader: Io.Reader = .fixed(""); 546 var tls_out_buf: [16]u8 = undefined; 547 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 548 client.setTransport(&tls_reader, &tls_writer); 549 try std.testing.expectEqual(&tls_reader, client.reader); 550 try std.testing.expectEqual(&tls_writer, client.writer); 551} 552 553test mailFrom { 554 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 555 var out_buf: [64]u8 = undefined; 556 var writer: Io.Writer = .fixed(&out_buf); 557 var reply_buf: [64]u8 = undefined; 558 var client: Client = .init(&reader, &writer, &reply_buf); 559 560 try client.mailFrom("alice@example.com"); 561 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered()); 562} 563 564test rcptTo { 565 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 566 var out_buf: [64]u8 = undefined; 567 var writer: Io.Writer = .fixed(&out_buf); 568 var reply_buf: [64]u8 = undefined; 569 var client: Client = .init(&reader, &writer, &reply_buf); 570 571 try client.rcptTo("bob@example.net"); 572 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered()); 573} 574 575test sendMessage { 576 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n"); 577 var out_buf: [128]u8 = undefined; 578 var writer: Io.Writer = .fixed(&out_buf); 579 var reply_buf: [64]u8 = undefined; 580 var client: Client = .init(&reader, &writer, &reply_buf); 581 582 try client.sendMessage("Subject: hi\n\nhello\n"); 583 try std.testing.expectEqualStrings( 584 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n", 585 writer.buffered(), 586 ); 587} 588 589test rset { 590 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 591 var out_buf: [16]u8 = undefined; 592 var writer: Io.Writer = .fixed(&out_buf); 593 var reply_buf: [64]u8 = undefined; 594 var client: Client = .init(&reader, &writer, &reply_buf); 595 596 try client.rset(); 597 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered()); 598} 599 600test noop { 601 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 602 var out_buf: [16]u8 = undefined; 603 var writer: Io.Writer = .fixed(&out_buf); 604 var reply_buf: [64]u8 = undefined; 605 var client: Client = .init(&reader, &writer, &reply_buf); 606 607 try client.noop(); 608 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered()); 609} 610 611test quit { 612 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n"); 613 var out_buf: [16]u8 = undefined; 614 var writer: Io.Writer = .fixed(&out_buf); 615 var reply_buf: [64]u8 = undefined; 616 var client: Client = .init(&reader, &writer, &reply_buf); 617 618 try client.quit(); 619 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered()); 620}