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.

Complete doctest coverage of the public API

Add identifier-named doctests for every remaining public function and
substantive public type: crlf, Reply.read/lines and the four reply
class predicates, Command.parse, PathArgs.paramIterator,
ParamIterator.init/next, Extensions and Extensions.Auth.any,
DataWriter.end, and Server's init, Options, Decision, Envelope, and
Handler. Nested declarations get their tests inside the container so
autodoc attaches them to the member.

Left without doctests, deliberately: Tls.zig and Server.TlsOptions
(need a live TLS peer; examples stay in doc comments), plain error
sets, and pure data shapes already demonstrated by their containers.

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

+140
+29
src/Client.zig
··· 62 62 return a.plain or a.login or a.cram_md5; 63 63 } 64 64 65 + test any { 66 + try std.testing.expect((Auth{ .login = true }).any()); 67 + try std.testing.expect(!(Auth{}).any()); 68 + } 69 + 65 70 fn parse(arg: []const u8) Auth { 66 71 var auth: Auth = .{}; 67 72 var it = std.mem.tokenizeScalar(u8, arg, ' '); ··· 338 343 n += pattern.len; 339 344 } 340 345 return n; 346 + } 347 + 348 + test end { 349 + var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); 350 + var out_buf: [64]u8 = undefined; 351 + var writer: Io.Writer = .fixed(&out_buf); 352 + var reply_buf: [64]u8 = undefined; 353 + var client: Client = .init(&reader, &writer, &reply_buf); 354 + 355 + var data_writer = try client.data(); 356 + try data_writer.interface.writeAll("no trailing newline"); 357 + try data_writer.end(); // adds the final CRLF, sends ".", reads 250 358 + try std.testing.expectEqualStrings( 359 + "DATA\r\nno trailing newline\r\n.\r\n", 360 + writer.buffered(), 361 + ); 341 362 } 342 363 343 364 fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void { ··· 844 865 try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); 845 866 const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; 846 867 try std.testing.expectEqualStrings(expected.buffered(), stuffed); 868 + } 869 + 870 + test Extensions { 871 + const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; 872 + try std.testing.expect(extensions.pipelining); 873 + try std.testing.expect(!extensions.starttls); 874 + try std.testing.expect(!extensions.auth.any()); 875 + try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 847 876 }
+46
src/Server.zig
··· 1082 1082 try std.testing.expectEqual(Envelope.Body.seven_bit, h.body); 1083 1083 try std.testing.expectEqual(@as(?u64, null), h.declared_size); 1084 1084 } 1085 + 1086 + test init { 1087 + var reader: Io.Reader = .fixed(""); 1088 + var out_buf: [16]u8 = undefined; 1089 + var writer: Io.Writer = .fixed(&out_buf); 1090 + var h: TestHandler = .{}; 1091 + const session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); 1092 + try std.testing.expectEqualStrings("mx.test", session.options.hostname); 1093 + try std.testing.expect(!session.secured); 1094 + } 1095 + 1096 + test Options { 1097 + const options: Options = .{}; 1098 + try std.testing.expectEqualStrings("localhost", options.hostname); 1099 + try std.testing.expect(options.tls == null); 1100 + try std.testing.expect(!options.require_auth); 1101 + } 1102 + 1103 + test Decision { 1104 + const ok: Decision = .accept; 1105 + try std.testing.expectEqual(Decision.accept, ok); 1106 + 1107 + const no: Decision = .{ .reject = .{ .code = 451, .text = "4.3.0 Try again later" } }; 1108 + try std.testing.expectEqual(@as(u16, 451), no.reject.code); 1109 + } 1110 + 1111 + test Envelope { 1112 + const envelope: Envelope = .{ .from = "", .recipients = &.{"a@example.com"} }; 1113 + try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len); 1114 + try std.testing.expectEqual(@as(?u64, null), envelope.declared_size); 1115 + try std.testing.expectEqual(Envelope.Body.unspecified, envelope.body); 1116 + } 1117 + 1118 + test Handler { 1119 + const Callbacks = struct { 1120 + fn onMessage(context: ?*anyopaque, envelope: Envelope, message_data: []const u8) Decision { 1121 + _ = context; 1122 + _ = envelope; 1123 + _ = message_data; 1124 + return .accept; 1125 + } 1126 + }; 1127 + const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } }; 1128 + const envelope: Envelope = .{ .from = "", .recipients = &.{} }; 1129 + try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, "")); 1130 + }
+65
src/protocol.zig
··· 96 96 pub fn isPermanentFailure(r: Reply) bool { 97 97 return r.code >= 500 and r.code < 600; 98 98 } 99 + 100 + test read { 101 + var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n"); 102 + var buffer: [64]u8 = undefined; 103 + const reply = try read(&reader, &buffer); 104 + try std.testing.expectEqual(@as(u16, 250), reply.code); 105 + try std.testing.expectEqualStrings("first\nsecond", reply.text); 106 + } 107 + 108 + test lines { 109 + const reply: Reply = .{ .code = 250, .text = "one\ntwo" }; 110 + var it = reply.lines(); 111 + try std.testing.expectEqualStrings("one", it.next().?); 112 + try std.testing.expectEqualStrings("two", it.next().?); 113 + try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 114 + } 115 + 116 + test isPositiveCompletion { 117 + try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion()); 118 + try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion()); 119 + } 120 + 121 + test isPositiveIntermediate { 122 + try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate()); 123 + } 124 + 125 + test isTransientFailure { 126 + try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure()); 127 + } 128 + 129 + test isPermanentFailure { 130 + try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure()); 131 + } 99 132 }; 100 133 101 134 /// A parsed client command, as seen by a server. ··· 134 167 135 168 pub fn paramIterator(args: PathArgs) ParamIterator { 136 169 return .init(args.params); 170 + } 171 + 172 + test paramIterator { 173 + const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" }; 174 + var it = args.paramIterator(); 175 + try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 137 176 } 138 177 }; 139 178 ··· 204 243 fn ieql(a: []const u8, b: []const u8) bool { 205 244 return std.ascii.eqlIgnoreCase(a, b); 206 245 } 246 + 247 + test parse { 248 + const command = try parse("RCPT TO:<bob@example.net>"); 249 + try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path); 250 + try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>")); 251 + } 207 252 }; 208 253 209 254 /// Iterates the ESMTP parameters of a MAIL or RCPT command ··· 231 276 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] }; 232 277 } 233 278 return .{ .keyword = token }; 279 + } 280 + 281 + test init { 282 + var it: ParamIterator = .init("SIZE=42"); 283 + try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 284 + } 285 + 286 + test next { 287 + var it: ParamIterator = .init("BODY=8BITMIME CUSTOM"); 288 + const body = it.next().?; 289 + try std.testing.expectEqualStrings("BODY", body.keyword); 290 + try std.testing.expectEqualStrings("8BITMIME", body.value); 291 + const custom = it.next().?; 292 + try std.testing.expectEqualStrings("CUSTOM", custom.keyword); 293 + try std.testing.expectEqualStrings("", custom.value); 294 + try std.testing.expectEqual(@as(?Param, null), it.next()); 234 295 } 235 296 }; 236 297 ··· 433 494 try std.testing.expectEqualStrings("", flag.value); 434 495 435 496 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next()); 497 + } 498 + 499 + test crlf { 500 + try std.testing.expectEqualStrings("\r\n", crlf); 436 501 }