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.

Add doctests for public API declarations

Rename existing demonstrative tests to identifier-named doctests so
autodoc attaches them to their declarations (readLine, Reply, Command,
writeStuffed, sendMail, hello, starttls, authenticate, authPlain,
authLogin, authCramMd5, run), and add new small scripted doctests for
the client functions that had none: init, greet, setTransport,
mailFrom, rcptTo, sendMessage, rset, noop, quit.

Server.run's doctest now constructs the session inline instead of
going through the runScript test helper, so the example shows the
actual API. Edge-case tests keep descriptive string names. Tls.zig
and Server.StartTls have no runnable doctests since a handshake needs
a live peer; their usage examples stay in doc comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx

+134 -28
+113 -7
src/Client.zig
··· 312 312 return reply; 313 313 } 314 314 315 - test "full transaction against a scripted server" { 315 + test sendMail { 316 316 const responses = "220 mx.example.com ESMTP\r\n" ++ 317 317 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 318 318 "250 2.1.0 Ok\r\n" ++ ··· 383 383 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 384 384 } 385 385 386 - test "starttls handshake handoff" { 386 + test starttls { 387 387 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 388 388 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 389 389 "220 2.0.0 Ready to start TLS\r\n"; ··· 416 416 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 417 417 } 418 418 419 - test "authPlain encodes credentials" { 419 + test authPlain { 420 420 const responses = "235 2.7.0 Accepted\r\n"; 421 421 var reader: Io.Reader = .fixed(responses); 422 422 var out_buf: [256]u8 = undefined; ··· 429 429 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 430 430 } 431 431 432 - test "authLogin exchange" { 432 + test authLogin { 433 433 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n"; 434 434 var reader: Io.Reader = .fixed(responses); 435 435 var out_buf: [256]u8 = undefined; ··· 444 444 ); 445 445 } 446 446 447 - test "authCramMd5 matches the RFC 2195 example" { 447 + test authCramMd5 { 448 448 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", 449 449 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. 450 450 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ ··· 462 462 ); 463 463 } 464 464 465 - test "authenticate picks an advertised mechanism" { 465 + test authenticate { 466 466 var out_buf: [256]u8 = undefined; 467 467 var reply_buf: [256]u8 = undefined; 468 468 { ··· 498 498 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 499 499 } 500 500 501 - test "EHLO advertises auth mechanisms" { 501 + test hello { 502 502 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 503 503 var reader: Io.Reader = .fixed(responses); 504 504 var out_buf: [256]u8 = undefined; ··· 511 511 try std.testing.expect(ext.auth.login); 512 512 try std.testing.expect(ext.auth.cram_md5); 513 513 try std.testing.expect(ext.auth.any()); 514 + } 515 + 516 + test 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 + 525 + test 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 + 537 + test 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 + 553 + test 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 + 564 + test 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 + 575 + test 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 + 589 + test 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 + 600 + test 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 + 611 + test 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()); 514 620 }
+17 -17
src/Server.zig
··· 523 523 return writer.buffered(); 524 524 } 525 525 526 - test "complete session" { 526 + test run { 527 527 var h: TestHandler = .{}; 528 528 defer h.deinit(); 529 529 530 + var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 531 + "MAIL FROM:<alice@example.com>\r\n" ++ 532 + "RCPT TO:<bob@example.net>\r\n" ++ 533 + "RCPT TO:<carol@example.net>\r\n" ++ 534 + "DATA\r\n" ++ 535 + "Subject: hi\r\n" ++ 536 + "\r\n" ++ 537 + "..stuffed line\r\n" ++ 538 + "body\r\n" ++ 539 + ".\r\n" ++ 540 + "QUIT\r\n"); 530 541 var out_buf: [1024]u8 = undefined; 531 - const output = try runScript( 532 - "EHLO client.example.org\r\n" ++ 533 - "MAIL FROM:<alice@example.com>\r\n" ++ 534 - "RCPT TO:<bob@example.net>\r\n" ++ 535 - "RCPT TO:<carol@example.net>\r\n" ++ 536 - "DATA\r\n" ++ 537 - "Subject: hi\r\n" ++ 538 - "\r\n" ++ 539 - "..stuffed line\r\n" ++ 540 - "body\r\n" ++ 541 - ".\r\n" ++ 542 - "QUIT\r\n", 543 - &out_buf, 544 - h.handler(), 545 - .{ .hostname = "mx.test" }, 546 - ); 542 + var writer: Io.Writer = .fixed(&out_buf); 543 + 544 + var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); 545 + try session.run(std.testing.allocator); 546 + const output = writer.buffered(); 547 547 548 548 try std.testing.expectEqualStrings("alice@example.com", h.from.items); 549 549 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items);
+4 -4
src/protocol.zig
··· 223 223 } 224 224 } 225 225 226 - test "readLine strips CRLF and LF" { 226 + test readLine { 227 227 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); 228 228 try std.testing.expectEqualStrings("first", try readLine(&reader)); 229 229 try std.testing.expectEqualStrings("second", try readLine(&reader)); ··· 231 231 try std.testing.expectError(error.EndOfStream, readLine(&reader)); 232 232 } 233 233 234 - test "Reply.read single line" { 234 + test Reply { 235 235 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 236 236 var buf: [128]u8 = undefined; 237 237 const reply = try Reply.read(&reader, &buf); ··· 269 269 } 270 270 } 271 271 272 - test "Command.parse" { 272 + test Command { 273 273 { 274 274 const cmd = try Command.parse("EHLO client.example.com"); 275 275 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); ··· 313 313 try std.testing.expectError(error.Syntax, Command.parse("HELO")); 314 314 } 315 315 316 - test "writeStuffed" { 316 + test writeStuffed { 317 317 var buf: [256]u8 = undefined; 318 318 { 319 319 var w: Io.Writer = .fixed(&buf);