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 server-side ESMTP parameter handling (SIZE=, BODY=)

protocol.ParamIterator iterates the KEY=value parameters of MAIL and
RCPT commands (RFC 5321 4.1.2), reachable via PathArgs.paramIterator().

The server validates MAIL parameters before the mailFrom callback:
SIZE= (RFC 1870) over max_message_size is rejected early with 552 and
malformed values with 501; BODY=7BIT/8BITMIME (RFC 6152) are accepted
case-insensitively and other values get 555, as do unrecognized
keywords. A rejected parameter leaves the transaction unstarted. RCPT
parameters are all rejected with 555 since no RCPT extensions are
advertised.

Envelope gains declared_size and body (defaulted, so existing handlers
are unaffected), populated from accepted MAIL parameters and reset
with the rest of the transaction state.

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

+177 -4
+8 -4
README.md
··· 119 119 120 120 `run` serves one connection until QUIT or disconnect, enforcing command 121 121 sequencing, recipient and message-size limits, and un-stuffing message data. 122 - Listening, accepting, and concurrency are up to the caller. 122 + MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 123 + when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) 124 + are accepted, and unrecognized parameters get 555; the declared size and 125 + body type reach the handler via `Envelope`. Listening, accepting, and 126 + concurrency are up to the caller. 123 127 124 128 To advertise and accept STARTTLS (TLS 1.3, via 125 129 [ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key ··· 167 171 [ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 168 172 TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3 169 173 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and 170 - LOGIN on the server. Message bodies can be streamed on both sides. Not yet 171 - implemented: implicit TLS on the server side, and ESMTP parameter handling 172 - (SIZE=, BODY=) on the server side. 174 + LOGIN on the server. Message bodies can be streamed on both sides, and the 175 + server validates MAIL parameters (SIZE=, BODY=). Not yet implemented: 176 + implicit TLS on the server side. 173 177 174 178 ## Tests 175 179
+118
src/Server.zig
··· 72 72 /// Empty for the null reverse-path (`MAIL FROM:<>`). 73 73 from: []const u8, 74 74 recipients: []const []const u8, 75 + /// Value of the MAIL SIZE= parameter (RFC 1870), if the client 76 + /// declared one. Already validated against `Options.max_message_size`. 77 + declared_size: ?u64 = null, 78 + /// Value of the MAIL BODY= parameter (RFC 6152). 79 + body: Body = .unspecified, 80 + 81 + pub const Body = enum { unspecified, seven_bit, eight_bit_mime }; 75 82 }; 76 83 77 84 /// Callbacks invoked during a session. All slices passed to callbacks are ··· 125 132 var authenticated = false; 126 133 var from: ?[]const u8 = null; 127 134 var recipients: std.ArrayList([]const u8) = .empty; 135 + var declared_size: ?u64 = null; 136 + var body: Envelope.Body = .unspecified; 128 137 129 138 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 130 139 try s.writer.flush(); ··· 148 157 greeted = true; 149 158 from = null; 150 159 recipients = .empty; 160 + declared_size = null; 161 + body = .unspecified; 151 162 _ = arena_state.reset(.retain_capacity); 152 163 try s.reply(250, s.options.hostname); 153 164 }, ··· 155 166 greeted = true; 156 167 from = null; 157 168 recipients = .empty; 169 + declared_size = null; 170 + body = .unspecified; 158 171 _ = arena_state.reset(.retain_capacity); 159 172 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 160 173 if (s.options.starttls != null and !s.secured) ··· 177 190 try s.reply(503, "5.5.1 Nested MAIL command"); 178 191 continue; 179 192 } 193 + var mail_declared_size: ?u64 = null; 194 + var mail_body: Envelope.Body = .unspecified; 195 + var params_ok = true; 196 + var params = args.paramIterator(); 197 + while (params.next()) |param| { 198 + if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) { 199 + const size = std.fmt.parseInt(u64, param.value, 10) catch { 200 + try s.reply(501, "5.5.2 Invalid SIZE parameter"); 201 + params_ok = false; 202 + break; 203 + }; 204 + if (size > s.options.max_message_size) { 205 + try s.reply(552, "5.3.4 Message size exceeds fixed maximum"); 206 + params_ok = false; 207 + break; 208 + } 209 + mail_declared_size = size; 210 + } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) { 211 + if (std.ascii.eqlIgnoreCase(param.value, "7BIT")) { 212 + mail_body = .seven_bit; 213 + } else if (std.ascii.eqlIgnoreCase(param.value, "8BITMIME")) { 214 + mail_body = .eight_bit_mime; 215 + } else { 216 + try s.reply(555, "5.5.4 Unsupported BODY value"); 217 + params_ok = false; 218 + break; 219 + } 220 + } else { 221 + try s.reply(555, "5.5.4 Unrecognized parameter"); 222 + params_ok = false; 223 + break; 224 + } 225 + } 226 + if (!params_ok) continue; 180 227 if (s.handler.vtable.mailFrom) |callback| { 181 228 switch (callback(s.handler.context, args.path)) { 182 229 .accept => {}, ··· 187 234 } 188 235 } 189 236 from = try arena.dupe(u8, args.path); 237 + declared_size = mail_declared_size; 238 + body = mail_body; 190 239 try s.reply(250, "2.1.0 Ok"); 191 240 }, 192 241 .rcpt => |args| { 193 242 if (from == null) { 194 243 try s.reply(503, "5.5.1 Need MAIL command first"); 244 + continue; 245 + } 246 + if (args.params.len != 0) { 247 + try s.reply(555, "5.5.4 Unrecognized parameter"); 195 248 continue; 196 249 } 197 250 if (recipients.items.len >= s.options.max_recipients) { ··· 218 271 try s.receiveData(arena, .{ 219 272 .from = from.?, 220 273 .recipients = recipients.items, 274 + .declared_size = declared_size, 275 + .body = body, 221 276 }); 222 277 from = null; 223 278 recipients = .empty; 279 + declared_size = null; 280 + body = .unspecified; 224 281 _ = arena_state.reset(.retain_capacity); 225 282 }, 226 283 .rset => { 227 284 from = null; 228 285 recipients = .empty; 286 + declared_size = null; 287 + body = .unspecified; 229 288 _ = arena_state.reset(.retain_capacity); 230 289 try s.reply(250, "2.0.0 Ok"); 231 290 }, ··· 259 318 authenticated = false; 260 319 from = null; 261 320 recipients = .empty; 321 + declared_size = null; 322 + body = .unspecified; 262 323 _ = arena_state.reset(.retain_capacity); 263 324 }, 264 325 .quit => { ··· 545 606 data: std.ArrayList(u8) = .empty, 546 607 messages_accepted: usize = 0, 547 608 reject_recipient: ?[]const u8 = null, 609 + declared_size: ?u64 = null, 610 + body: Envelope.Body = .unspecified, 548 611 /// When set, enables the authenticate callback accepting user "alice" 549 612 /// with this password. 550 613 password: ?[]const u8 = null, ··· 593 656 } 594 657 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; 595 658 h.messages_accepted += 1; 659 + h.declared_size = envelope.declared_size; 660 + h.body = envelope.body; 596 661 return .accept; 597 662 } 598 663 }; ··· 937 1002 _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {}; 938 1003 939 1004 try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items); 1005 + } 1006 + 1007 + test "MAIL parameters SIZE and BODY are honored" { 1008 + var h: TestHandler = .{}; 1009 + defer h.deinit(); 1010 + 1011 + var out_buf: [1024]u8 = undefined; 1012 + const output = try runScript( 1013 + "EHLO client.example.org\r\n" ++ 1014 + "MAIL FROM:<alice@example.com> SIZE=42 BODY=8BITMIME\r\n" ++ 1015 + "RCPT TO:<bob@example.net>\r\n" ++ 1016 + "DATA\r\nsized body\r\n.\r\n" ++ 1017 + "QUIT\r\n", 1018 + &out_buf, 1019 + h.handler(), 1020 + .{ .max_message_size = 1024 }, 1021 + ); 1022 + 1023 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1024 + try std.testing.expectEqual(@as(?u64, 42), h.declared_size); 1025 + try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body); 1026 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 1027 + } 1028 + 1029 + test "invalid MAIL and RCPT parameters are rejected" { 1030 + var h: TestHandler = .{}; 1031 + defer h.deinit(); 1032 + 1033 + var out_buf: [2048]u8 = undefined; 1034 + const output = try runScript( 1035 + "EHLO client.example.org\r\n" ++ 1036 + "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552 1037 + "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503 1038 + "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501 1039 + "MAIL FROM:<a@example.com> BODY=BINARYMIME\r\n" ++ // 555 1040 + "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555 1041 + "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok 1042 + "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555 1043 + "RCPT TO:<b@example.net>\r\n" ++ 1044 + "DATA\r\nbody\r\n.\r\nQUIT\r\n", 1045 + &out_buf, 1046 + h.handler(), 1047 + .{ .max_message_size = 1024 }, 1048 + ); 1049 + 1050 + try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 1051 + try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 1052 + try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null); 1053 + try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null); 1054 + try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 1055 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1056 + try std.testing.expectEqual(Envelope.Body.seven_bit, h.body); 1057 + try std.testing.expectEqual(@as(?u64, null), h.declared_size); 940 1058 }
+51
src/protocol.zig
··· 131 131 path: []const u8, 132 132 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". 133 133 params: []const u8 = "", 134 + 135 + pub fn paramIterator(args: PathArgs) ParamIterator { 136 + return .init(args.params); 137 + } 134 138 }; 135 139 136 140 pub const ParseError = error{Syntax}; ··· 199 203 200 204 fn ieql(a: []const u8, b: []const u8) bool { 201 205 return std.ascii.eqlIgnoreCase(a, b); 206 + } 207 + }; 208 + 209 + /// Iterates the ESMTP parameters of a MAIL or RCPT command 210 + /// (RFC 5321 §4.1.2), e.g. "SIZE=1024 BODY=8BITMIME". 211 + pub const ParamIterator = struct { 212 + rest: []const u8, 213 + 214 + pub const Param = struct { 215 + keyword: []const u8, 216 + /// Empty when the parameter carries no value. 217 + value: []const u8 = "", 218 + }; 219 + 220 + pub fn init(params: []const u8) ParamIterator { 221 + return .{ .rest = params }; 222 + } 223 + 224 + pub fn next(it: *ParamIterator) ?Param { 225 + it.rest = std.mem.trimStart(u8, it.rest, " \t"); 226 + if (it.rest.len == 0) return null; 227 + const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len; 228 + const token = it.rest[0..end]; 229 + it.rest = it.rest[end..]; 230 + if (std.mem.indexOfScalar(u8, token, '=')) |eq| { 231 + return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] }; 232 + } 233 + return .{ .keyword = token }; 202 234 } 203 235 }; 204 236 ··· 382 414 const reply = Reply.read(&reader, &text_buf) catch break; 383 415 try std.testing.expect(reply.code >= 100 and reply.code <= 599); 384 416 } 417 + } 418 + 419 + test ParamIterator { 420 + const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG"); 421 + var it = command.mail.paramIterator(); 422 + 423 + const size = it.next().?; 424 + try std.testing.expectEqualStrings("SIZE", size.keyword); 425 + try std.testing.expectEqualStrings("1024", size.value); 426 + 427 + const body = it.next().?; 428 + try std.testing.expectEqualStrings("BODY", body.keyword); 429 + try std.testing.expectEqualStrings("8BITMIME", body.value); 430 + 431 + const flag = it.next().?; 432 + try std.testing.expectEqualStrings("FLAG", flag.keyword); 433 + try std.testing.expectEqualStrings("", flag.value); 434 + 435 + try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next()); 385 436 }