An SMTP client and server library for Zig implementing RFC 5321.
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! Shared SMTP protocol primitives ([RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321)):
5//! line reading, reply parsing,
6//! command parsing, and message data dot-stuffing. Used by both the client
7//! and server layers, and usable directly for custom protocol handling.
8
9const std = @import("std");
10const Io = std.Io;
11
12pub const crlf = "\r\n";
13
14/// Bytes that may never appear in a command argument.
15///
16/// CR and LF end the command line, so a value carrying either one lets
17/// whatever follows it be read by the server as further SMTP commands — an
18/// address of `a@b>\r\nRCPT TO:<victim@c` turns one recipient into two.
19/// NUL is here because it separates the three fields of an SASL PLAIN
20/// response, where a value carrying one silently shifts the boundary
21/// between authorization identity, username and password.
22pub const forbidden_in_argument = "\r\n\x00";
23
24/// Whether `text` is safe to write into a command line as an argument.
25///
26/// This is a framing check, not address validation: it says that `text`
27/// cannot end the line early, not that it is a well-formed mailbox. The
28/// full RFC 5321 path grammar is deliberately not enforced, because plenty
29/// of addresses in real use do not satisfy it and a client that refused to
30/// carry them would be the wrong tool. Callers building commands from
31/// untrusted input should check here and reject what fails.
32pub fn isSafeArgument(text: []const u8) bool {
33 return std.mem.findAny(u8, text, forbidden_in_argument) == null;
34}
35
36test isSafeArgument {
37 try std.testing.expect(isSafeArgument("alice@example.com"));
38 try std.testing.expect(isSafeArgument("\"odd name\"@example.com"));
39 try std.testing.expect(!isSafeArgument("a@b>\r\nRCPT TO:<victim@c"));
40 try std.testing.expect(!isSafeArgument("a@b\nMAIL FROM:<c@d>"));
41 try std.testing.expect(!isSafeArgument("alice\x00root"));
42}
43
44pub const ReadLineError = error{
45 ReadFailed,
46 EndOfStream,
47 /// The line did not fit in the reader's buffer.
48 LineTooLong,
49};
50
51/// Reads one CRLF- (or bare LF-) terminated line, returning it without the
52/// line ending. The returned slice points into the reader's buffer and is
53/// invalidated by the next read.
54pub fn readLine(reader: *Io.Reader) ReadLineError![]u8 {
55 const line = reader.takeSentinel('\n') catch |err| switch (err) {
56 error.StreamTooLong => return error.LineTooLong,
57 error.ReadFailed, error.EndOfStream => |e| return e,
58 };
59 if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1];
60 return line;
61}
62
63/// A server reply: a 3-digit code and one or more lines of text.
64pub const Reply = struct {
65 code: u16,
66 /// Text of all reply lines joined with '\n', with codes and separators
67 /// stripped. Points into the buffer passed to `read`.
68 text: []const u8,
69
70 pub const ReadError = ReadLineError || error{
71 InvalidReply,
72 /// The reply text did not fit in the provided buffer.
73 ReplyTooLong,
74 };
75
76 /// Reads one (possibly multiline) reply. The text is copied into `buffer`
77 /// and the returned reply's `text` field points into it.
78 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply {
79 var text: Io.Writer = .fixed(buffer);
80 var code: ?u16 = null;
81 var first = true;
82 while (true) {
83 const line = try readLine(reader);
84 if (line.len < 3) return error.InvalidReply;
85 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch
86 return error.InvalidReply;
87 if (line_code < 100 or line_code > 599) return error.InvalidReply;
88 if (code) |prev| {
89 // All lines of a multiline reply must carry the same code.
90 if (prev != line_code) return error.InvalidReply;
91 } else {
92 code = line_code;
93 }
94 var last = true;
95 var line_text: []const u8 = "";
96 if (line.len > 3) {
97 switch (line[3]) {
98 ' ' => {},
99 '-' => last = false,
100 else => return error.InvalidReply,
101 }
102 line_text = line[4..];
103 }
104 if (!first) text.writeByte('\n') catch return error.ReplyTooLong;
105 text.writeAll(line_text) catch return error.ReplyTooLong;
106 first = false;
107 if (last) break;
108 }
109 return .{ .code = code.?, .text = text.buffered() };
110 }
111
112 /// Iterates over the individual text lines of the reply.
113 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) {
114 return std.mem.splitScalar(u8, r.text, '\n');
115 }
116
117 // Reply classes per RFC 5321 §4.2.1
118 // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1).
119 pub fn isPositiveCompletion(r: Reply) bool {
120 return r.code >= 200 and r.code < 300;
121 }
122 pub fn isPositiveIntermediate(r: Reply) bool {
123 return r.code >= 300 and r.code < 400;
124 }
125 pub fn isTransientFailure(r: Reply) bool {
126 return r.code >= 400 and r.code < 500;
127 }
128 pub fn isPermanentFailure(r: Reply) bool {
129 return r.code >= 500 and r.code < 600;
130 }
131
132 test read {
133 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n");
134 var buffer: [64]u8 = undefined;
135 const reply = try read(&reader, &buffer);
136 try std.testing.expectEqual(@as(u16, 250), reply.code);
137 try std.testing.expectEqualStrings("first\nsecond", reply.text);
138 }
139
140 test lines {
141 const reply: Reply = .{ .code = 250, .text = "one\ntwo" };
142 var it = reply.lines();
143 try std.testing.expectEqualStrings("one", it.next().?);
144 try std.testing.expectEqualStrings("two", it.next().?);
145 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
146 }
147
148 test isPositiveCompletion {
149 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion());
150 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion());
151 }
152
153 test isPositiveIntermediate {
154 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate());
155 }
156
157 test isTransientFailure {
158 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure());
159 }
160
161 test isPermanentFailure {
162 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure());
163 }
164};
165
166/// A parsed client command, as seen by a server.
167pub const Command = union(enum) {
168 helo: []const u8,
169 ehlo: []const u8,
170 /// LHLO, the LMTP greeting
171 /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)), which
172 /// has the same semantics as EHLO. An LMTP server takes this one and
173 /// refuses HELO and EHLO; an SMTP server does the reverse.
174 lhlo: []const u8,
175 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`).
176 mail: PathArgs,
177 /// RCPT TO.
178 rcpt: PathArgs,
179 data,
180 rset,
181 noop,
182 quit,
183 vrfy: []const u8,
184 help,
185 starttls,
186 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)).
187 auth: AuthArgs,
188 /// BDAT, the CHUNKING extension
189 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The
190 /// command line is followed by exactly `size` raw octets.
191 bdat: BdatArgs,
192 /// Unrecognized command verb; the payload is the full line.
193 unknown: []const u8,
194
195 pub const BdatArgs = struct {
196 size: u64,
197 /// True for the final chunk of the message ("BDAT n LAST").
198 last: bool = false,
199 };
200
201 pub const AuthArgs = struct {
202 mechanism: []const u8,
203 /// Raw base64 initial response, if the client sent one ("=" denotes
204 /// an empty initial response).
205 initial: []const u8 = "",
206 };
207
208 pub const PathArgs = struct {
209 /// The mailbox, with angle brackets and any obsolete source route
210 /// stripped.
211 path: []const u8,
212 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024".
213 params: []const u8 = "",
214
215 pub fn paramIterator(args: PathArgs) ParamIterator {
216 return .init(args.params);
217 }
218
219 test paramIterator {
220 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" };
221 var it = args.paramIterator();
222 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
223 }
224 };
225
226 pub const ParseError = error{Syntax};
227
228 /// Parses one command line (without its line ending). Returned slices
229 /// point into `line`.
230 pub fn parse(line: []const u8) ParseError!Command {
231 const trimmed = std.mem.trim(u8, line, " \t");
232 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len;
233 const verb = trimmed[0..verb_end];
234 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t");
235
236 if (ieql(verb, "HELO")) {
237 if (rest.len == 0) return error.Syntax;
238 return .{ .helo = rest };
239 }
240 if (ieql(verb, "EHLO")) {
241 if (rest.len == 0) return error.Syntax;
242 return .{ .ehlo = rest };
243 }
244 if (ieql(verb, "LHLO")) {
245 if (rest.len == 0) return error.Syntax;
246 return .{ .lhlo = rest };
247 }
248 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") };
249 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") };
250 if (ieql(verb, "DATA")) return .data;
251 if (ieql(verb, "RSET")) return .rset;
252 if (ieql(verb, "NOOP")) return .noop;
253 if (ieql(verb, "QUIT")) return .quit;
254 if (ieql(verb, "VRFY")) return .{ .vrfy = rest };
255 if (ieql(verb, "HELP")) return .help;
256 if (ieql(verb, "STARTTLS")) return .starttls;
257 if (ieql(verb, "BDAT")) {
258 var it = std.mem.tokenizeAny(u8, rest, " \t");
259 const size_token = it.next() orelse return error.Syntax;
260 const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax;
261 var last = false;
262 if (it.next()) |token| {
263 if (!ieql(token, "LAST")) return error.Syntax;
264 last = true;
265 }
266 if (it.next() != null) return error.Syntax;
267 return .{ .bdat = .{ .size = size, .last = last } };
268 }
269 if (ieql(verb, "AUTH")) {
270 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len;
271 if (mech_end == 0) return error.Syntax;
272 return .{ .auth = .{
273 .mechanism = rest[0..mech_end],
274 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"),
275 } };
276 }
277 return .{ .unknown = line };
278 }
279
280 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs {
281 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword))
282 return error.Syntax;
283 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t");
284 if (after.len == 0 or after[0] != '<') {
285 // Lenient: accept a bare address ending at whitespace.
286 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len;
287 if (end == 0) return error.Syntax;
288 return .{
289 .path = after[0..end],
290 .params = std.mem.trimStart(u8, after[end..], " \t"),
291 };
292 }
293 // The closing bracket must be found outside any quoted local-part:
294 // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with
295 // backslash escapes).
296 const close = close: {
297 var in_quotes = false;
298 var i: usize = 1;
299 while (i < after.len) : (i += 1) {
300 const byte = after[i];
301 if (in_quotes) {
302 if (byte == '\\') {
303 i += 1;
304 } else if (byte == '"') {
305 in_quotes = false;
306 }
307 } else if (byte == '"') {
308 in_quotes = true;
309 } else if (byte == '>') {
310 break :close i;
311 }
312 }
313 return error.Syntax;
314 };
315 var path = after[1..close];
316 // Strip an obsolete source route: <@relay1,@relay2:user@host>.
317 if (path.len > 0 and path[0] == '@') {
318 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax;
319 path = path[colon + 1 ..];
320 }
321 return .{
322 .path = path,
323 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"),
324 };
325 }
326
327 fn ieql(a: []const u8, b: []const u8) bool {
328 return std.ascii.eqlIgnoreCase(a, b);
329 }
330
331 test parse {
332 const command = try parse("RCPT TO:<bob@example.net>");
333 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path);
334 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>"));
335 }
336};
337
338/// The `RET` parameter of an extended MAIL command
339/// ([RFC 3461 §4.3](https://datatracker.ietf.org/doc/html/rfc3461#section-4.3)):
340/// how much of the message a failed DSN should carry back. Absent, the
341/// choice is the reporting MTA's.
342/// The `BODY` parameter of an extended MAIL command: what kind of content
343/// the message carries, and so what the receiver has to be able to take.
344pub const Body = enum {
345 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). Lines of
346 /// at most 998 characters from the ASCII repertoire.
347 seven_bit,
348 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). The same
349 /// line structure, with the high bit allowed.
350 eight_bit_mime,
351 /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). Arbitrary
352 /// octets with no line structure at all, which is why it can only be
353 /// carried by BDAT: DATA has no way to frame content that may hold the
354 /// terminator itself.
355 binary_mime,
356
357 pub const ParseError = error{Syntax};
358
359 pub fn parse(value: []const u8) ParseError!Body {
360 if (std.ascii.eqlIgnoreCase(value, "7BIT")) return .seven_bit;
361 if (std.ascii.eqlIgnoreCase(value, "8BITMIME")) return .eight_bit_mime;
362 if (std.ascii.eqlIgnoreCase(value, "BINARYMIME")) return .binary_mime;
363 return error.Syntax;
364 }
365
366 /// Writes the value as it appears on the wire.
367 pub fn format(b: Body, writer: *Io.Writer) Io.Writer.Error!void {
368 try writer.writeAll(switch (b) {
369 .seven_bit => "7BIT",
370 .eight_bit_mime => "8BITMIME",
371 .binary_mime => "BINARYMIME",
372 });
373 }
374
375 test parse {
376 try std.testing.expectEqual(Body.binary_mime, try parse("binarymime"));
377 try std.testing.expectEqual(Body.seven_bit, try parse("7BIT"));
378 try std.testing.expectError(error.Syntax, parse("BINARY"));
379 }
380};
381
382/// The `AUTH` parameter of an extended MAIL command
383/// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)):
384/// who originally submitted this message, carried forward by a relay that
385/// authenticated them.
386///
387/// It is an assertion, not a proof — the peer is claiming this on its own
388/// authority — which is why RFC 4954 requires a server to disregard it and
389/// behave as though `<>` had been sent whenever the client is unauthenticated
390/// or insufficiently trusted.
391pub const Submitter = union(enum) {
392 /// Sent as `<>`: the two characters that mean "I do not know", which a
393 /// client should send rather than omitting the parameter when it is
394 /// relaying something it cannot vouch for.
395 unknown,
396 /// The mailbox asserted, xtext-decoded. A bare address with no angle
397 /// brackets, which is what RFC 5321's `Mailbox` production is.
398 mailbox: []const u8,
399
400 /// RFC 4954 §5 extends the MAIL command line by 500 characters to make
401 /// room for this, which is the only ceiling it gives.
402 pub const max_len = 500;
403
404 pub const ParseError = error{Syntax};
405
406 /// Parses the parameter value, decoding the mailbox into `buffer`.
407 pub fn parse(buffer: []u8, value: []const u8) ParseError!Submitter {
408 if (value.len == 0 or value.len > max_len) return error.Syntax;
409 const decoded = xtextDecode(buffer, value) catch return error.Syntax;
410 if (std.mem.eql(u8, decoded, "<>")) return .unknown;
411 // Anything else must be a mailbox, and an empty one is not.
412 if (decoded.len == 0) return error.Syntax;
413 return .{ .mailbox = decoded };
414 }
415
416 /// Writes the parameter value as it appears on the wire, xtext-encoding
417 /// the mailbox.
418 pub fn format(s: Submitter, writer: *Io.Writer) Io.Writer.Error!void {
419 switch (s) {
420 .unknown => try writer.writeAll("<>"),
421 .mailbox => |mailbox| try writeXtext(writer, mailbox),
422 }
423 }
424
425 test parse {
426 var buffer: [64]u8 = undefined;
427 try std.testing.expectEqual(Submitter.unknown, try parse(&buffer, "<>"));
428 const who = try parse(&buffer, "e+3Dmc2@example.com");
429 try std.testing.expectEqualStrings("e=mc2@example.com", who.mailbox);
430 try std.testing.expectError(error.Syntax, parse(&buffer, ""));
431 try std.testing.expectError(error.Syntax, parse(&buffer, "not xtext!"));
432 }
433};
434
435/// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which
436/// is a limit on the xtext-encoded form and not on what went into it.
437pub const max_envid_len = 100;
438
439pub const Ret = enum {
440 /// Return the entire message.
441 full,
442 /// Return the headers only.
443 hdrs,
444
445 pub const ParseError = error{Syntax};
446
447 pub fn parse(value: []const u8) ParseError!Ret {
448 if (std.ascii.eqlIgnoreCase(value, "FULL")) return .full;
449 if (std.ascii.eqlIgnoreCase(value, "HDRS")) return .hdrs;
450 return error.Syntax;
451 }
452
453 /// Writes the value as it appears on the wire.
454 pub fn format(r: Ret, writer: *Io.Writer) Io.Writer.Error!void {
455 try writer.writeAll(switch (r) {
456 .full => "FULL",
457 .hdrs => "HDRS",
458 });
459 }
460
461 test parse {
462 try std.testing.expectEqual(Ret.hdrs, try parse("hdrs"));
463 try std.testing.expectError(error.Syntax, parse("PARTIAL"));
464 }
465};
466
467/// The `NOTIFY` parameter of an extended RCPT command
468/// ([RFC 3461 §4.1](https://datatracker.ietf.org/doc/html/rfc3461#section-4.1)):
469/// the conditions under which the sender wants to hear about this
470/// recipient. Absent, RFC 3461 lets a server read it as either
471/// `FAILURE` or `FAILURE,DELAY` — which is why "not specified" is an
472/// absent `?Notify` here and not a value of it.
473pub const Notify = union(enum) {
474 /// `NOTIFY=NEVER`: no DSN for this recipient under any circumstance.
475 /// RFC 3461 requires the keyword to appear on its own, and parsing
476 /// rejects it in a list.
477 never,
478 /// One or more of `SUCCESS`, `FAILURE` and `DELAY`.
479 on: Conditions,
480
481 pub const Conditions = struct {
482 success: bool = false,
483 failure: bool = false,
484 delay: bool = false,
485 };
486
487 pub const ParseError = error{Syntax};
488
489 pub fn parse(value: []const u8) ParseError!Notify {
490 if (std.ascii.eqlIgnoreCase(value, "NEVER")) return .never;
491 var conditions: Conditions = .{};
492 var it = std.mem.splitScalar(u8, value, ',');
493 var any = false;
494 while (it.next()) |keyword| {
495 if (std.ascii.eqlIgnoreCase(keyword, "SUCCESS")) {
496 conditions.success = true;
497 } else if (std.ascii.eqlIgnoreCase(keyword, "FAILURE")) {
498 conditions.failure = true;
499 } else if (std.ascii.eqlIgnoreCase(keyword, "DELAY")) {
500 conditions.delay = true;
501 } else return error.Syntax; // Including NEVER: it may not be listed.
502 any = true;
503 }
504 if (!any) return error.Syntax;
505 return .{ .on = conditions };
506 }
507
508 /// Writes the value as it appears on the wire.
509 pub fn format(n: Notify, writer: *Io.Writer) Io.Writer.Error!void {
510 switch (n) {
511 .never => try writer.writeAll("NEVER"),
512 .on => |conditions| {
513 var written = false;
514 inline for (.{
515 .{ conditions.success, "SUCCESS" },
516 .{ conditions.failure, "FAILURE" },
517 .{ conditions.delay, "DELAY" },
518 }) |pair| {
519 if (pair[0]) {
520 if (written) try writer.writeByte(',');
521 try writer.writeAll(pair[1]);
522 written = true;
523 }
524 }
525 // An empty condition set has no legal spelling; NEVER is
526 // what "tell me nothing" is written as.
527 if (!written) try writer.writeAll("NEVER");
528 },
529 }
530 }
531
532 test parse {
533 try std.testing.expectEqual(Notify.never, try parse("NEVER"));
534 const both = try parse("SUCCESS,delay");
535 try std.testing.expect(both.on.success and both.on.delay and !both.on.failure);
536 try std.testing.expectError(error.Syntax, parse("NEVER,SUCCESS"));
537 try std.testing.expectError(error.Syntax, parse(""));
538 try std.testing.expectError(error.Syntax, parse("SUCCESS,MAYBE"));
539 }
540};
541
542/// The `ORCPT` parameter of an extended RCPT command
543/// ([RFC 3461 §4.2](https://datatracker.ietf.org/doc/html/rfc3461#section-4.2)):
544/// the address the message was originally addressed to, carried unchanged
545/// through aliasing and forwarding so that a DSN can name what the sender
546/// actually wrote.
547pub const Orcpt = struct {
548 /// The address type, an atom — `rfc822` in all but the unusual cases.
549 addr_type: []const u8,
550 /// The original recipient, xtext-decoded.
551 address: []const u8,
552
553 /// RFC 3461 §4.2 caps the whole parameter value at 500 characters.
554 pub const max_len = 500;
555
556 pub const ParseError = error{Syntax};
557
558 /// Parses `addr-type ";" xtext`, decoding the address into `buffer`.
559 /// The returned `addr_type` points into `value` and `address` points
560 /// into `buffer`, so the two have different lifetimes; a caller keeping
561 /// the result past either one copies both.
562 pub fn parse(buffer: []u8, value: []const u8) ParseError!Orcpt {
563 const semicolon = std.mem.findScalar(u8, value, ';') orelse return error.Syntax;
564 const addr_type = value[0..semicolon];
565 if (addr_type.len == 0) return error.Syntax;
566 for (addr_type) |byte| if (!isAtomByte(byte)) return error.Syntax;
567 return .{
568 .addr_type = addr_type,
569 .address = xtextDecode(buffer, value[semicolon + 1 ..]) catch return error.Syntax,
570 };
571 }
572
573 /// Writes the parameter value as it appears on the wire, xtext-encoding
574 /// the address.
575 pub fn format(o: Orcpt, writer: *Io.Writer) Io.Writer.Error!void {
576 try writer.writeAll(o.addr_type);
577 try writer.writeByte(';');
578 try writeXtext(writer, o.address);
579 }
580
581 /// RFC 5321 `atom` less the specials, which is what an addr-type may be.
582 fn isAtomByte(byte: u8) bool {
583 return switch (byte) {
584 'A'...'Z', 'a'...'z', '0'...'9' => true,
585 '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?' => true,
586 '^', '_', '`', '{', '|', '}', '~' => true,
587 else => false,
588 };
589 }
590
591 test parse {
592 var buffer: [64]u8 = undefined;
593 const orcpt = try parse(&buffer, "rfc822;bob+2Bx@example.net");
594 try std.testing.expectEqualStrings("rfc822", orcpt.addr_type);
595 try std.testing.expectEqualStrings("bob+x@example.net", orcpt.address);
596 try std.testing.expectError(error.Syntax, parse(&buffer, "bob@example.net"));
597 try std.testing.expectError(error.Syntax, parse(&buffer, ";bob@example.net"));
598 }
599};
600
601/// Whether `byte` may appear in an xtext unencoded
602/// ([RFC 3461 §4](https://datatracker.ietf.org/doc/html/rfc3461#section-4)):
603/// printable US-ASCII other than `+`, which introduces an escape, and `=`,
604/// which separates an ESMTP keyword from its value.
605pub fn isXchar(byte: u8) bool {
606 return byte >= '!' and byte <= '~' and byte != '+' and byte != '=';
607}
608
609/// Writes `text` xtext-encoded: anything that is not an `xchar` becomes
610/// `+` and two upper-case hex digits. Every byte therefore survives,
611/// including the ones that would otherwise end the command line, so an
612/// xtext-encoded parameter is safe to write from untrusted input.
613///
614/// RFC 3461 asks that the value before encoding be printable US-ASCII.
615/// That is the caller's to observe; encoding anything else here produces
616/// valid xtext regardless rather than a corrupt command.
617pub fn writeXtext(writer: *Io.Writer, text: []const u8) Io.Writer.Error!void {
618 for (text) |byte| {
619 if (isXchar(byte)) {
620 try writer.writeByte(byte);
621 } else {
622 try writer.print("+{X:0>2}", .{byte});
623 }
624 }
625}
626
627/// The length `writeXtext` will produce for `text`, for checking a value
628/// against the length limits RFC 3461 puts on the encoded form.
629pub fn xtextEncodedLen(text: []const u8) usize {
630 var len: usize = 0;
631 for (text) |byte| len += if (isXchar(byte)) 1 else 3;
632 return len;
633}
634
635pub const XtextError = error{
636 /// Not valid xtext: a `+` not followed by two hex digits, or a raw byte
637 /// that the encoder was required to escape.
638 BadXtext,
639 NoSpaceLeft,
640};
641
642/// Decodes xtext into `buffer`, returning the decoded bytes. Decoding is
643/// strict: a byte an encoder was obliged to escape is rejected rather than
644/// passed through, since accepting it would let two different encodings
645/// mean the same thing.
646pub fn xtextDecode(buffer: []u8, text: []const u8) XtextError![]u8 {
647 var out: usize = 0;
648 var i: usize = 0;
649 while (i < text.len) {
650 const byte = text[i];
651 if (byte == '+') {
652 if (i + 2 >= text.len) return error.BadXtext;
653 const hex = text[i + 1 ..][0..2];
654 // Checked before parsing because `parseInt` also accepts a sign
655 // and underscore separators, which hex digits are not. Lower
656 // case is accepted on the way in even though RFC 3461 requires
657 // upper case on the way out.
658 for (hex) |digit| if (!std.ascii.isHex(digit)) return error.BadXtext;
659 const value = std.fmt.parseInt(u8, hex, 16) catch return error.BadXtext;
660 if (out >= buffer.len) return error.NoSpaceLeft;
661 buffer[out] = value;
662 out += 1;
663 i += 3;
664 } else {
665 if (!isXchar(byte)) return error.BadXtext;
666 if (out >= buffer.len) return error.NoSpaceLeft;
667 buffer[out] = byte;
668 out += 1;
669 i += 1;
670 }
671 }
672 return buffer[0..out];
673}
674
675test xtextDecode {
676 var buffer: [64]u8 = undefined;
677 try std.testing.expectEqualStrings(
678 "a+b=c",
679 try xtextDecode(&buffer, "a+2Bb+3Dc"),
680 );
681 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+2"));
682 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+ZZb"));
683 // A raw '=' or ' ' is what the encoder had to escape.
684 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a=b"));
685 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a b"));
686}
687
688test writeXtext {
689 var out_buf: [64]u8 = undefined;
690 var writer: Io.Writer = .fixed(&out_buf);
691 try writeXtext(&writer, "id+1=2 \r\n");
692 try std.testing.expectEqualStrings("id+2B1+3D2+20+0D+0A", writer.buffered());
693 try std.testing.expectEqual(writer.buffered().len, xtextEncodedLen("id+1=2 \r\n"));
694
695 // Every byte survives the round trip.
696 var raw: [256]u8 = undefined;
697 for (&raw, 0..) |*byte, i| byte.* = @intCast(i);
698 var round_buf: [1024]u8 = undefined;
699 var round: Io.Writer = .fixed(&round_buf);
700 try writeXtext(&round, &raw);
701 var decoded_buf: [256]u8 = undefined;
702 try std.testing.expectEqualSlices(u8, &raw, try xtextDecode(&decoded_buf, round.buffered()));
703}
704
705/// Iterates the ESMTP parameters of a MAIL or RCPT command
706/// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)),
707/// e.g. "SIZE=1024 BODY=8BITMIME".
708pub const ParamIterator = struct {
709 rest: []const u8,
710
711 pub const Param = struct {
712 keyword: []const u8,
713 /// Empty when the parameter carries no value.
714 value: []const u8 = "",
715 };
716
717 pub fn init(params: []const u8) ParamIterator {
718 return .{ .rest = params };
719 }
720
721 pub fn next(it: *ParamIterator) ?Param {
722 it.rest = std.mem.trimStart(u8, it.rest, " \t");
723 if (it.rest.len == 0) return null;
724 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len;
725 const token = it.rest[0..end];
726 it.rest = it.rest[end..];
727 if (std.mem.indexOfScalar(u8, token, '=')) |eq| {
728 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] };
729 }
730 return .{ .keyword = token };
731 }
732
733 test init {
734 var it: ParamIterator = .init("SIZE=42");
735 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
736 }
737
738 test next {
739 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM");
740 const body = it.next().?;
741 try std.testing.expectEqualStrings("BODY", body.keyword);
742 try std.testing.expectEqualStrings("8BITMIME", body.value);
743 const custom = it.next().?;
744 try std.testing.expectEqualStrings("CUSTOM", custom.keyword);
745 try std.testing.expectEqualStrings("", custom.value);
746 try std.testing.expectEqual(@as(?Param, null), it.next());
747 }
748};
749
750/// Writes `data` as SMTP message content: line endings are normalized to CRLF
751/// and lines beginning with '.' are dot-stuffed
752/// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not
753/// write the terminating ".\r\n".
754pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void {
755 var rest = data;
756 while (rest.len > 0) {
757 var line: []const u8 = undefined;
758 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| {
759 line = rest[0..i];
760 rest = rest[i + 1 ..];
761 } else {
762 line = rest;
763 rest = rest[rest.len..];
764 }
765 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
766 if (line.len > 0 and line[0] == '.') try writer.writeByte('.');
767 try writer.writeAll(line);
768 try writer.writeAll(crlf);
769 }
770}
771
772test readLine {
773 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n");
774 try std.testing.expectEqualStrings("first", try readLine(&reader));
775 try std.testing.expectEqualStrings("second", try readLine(&reader));
776 try std.testing.expectEqualStrings("third", try readLine(&reader));
777 try std.testing.expectError(error.EndOfStream, readLine(&reader));
778}
779
780test Reply {
781 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
782 var buf: [128]u8 = undefined;
783 const reply = try Reply.read(&reader, &buf);
784 try std.testing.expectEqual(@as(u16, 250), reply.code);
785 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text);
786 try std.testing.expect(reply.isPositiveCompletion());
787}
788
789test "Reply.read multiline" {
790 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n");
791 var buf: [128]u8 = undefined;
792 const reply = try Reply.read(&reader, &buf);
793 try std.testing.expectEqual(@as(u16, 250), reply.code);
794 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text);
795 var it = reply.lines();
796 try std.testing.expectEqualStrings("mx.example.com", it.next().?);
797 try std.testing.expectEqualStrings("PIPELINING", it.next().?);
798 try std.testing.expectEqualStrings("SIZE 1000", it.next().?);
799 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
800}
801
802test "Reply.read rejects malformed replies" {
803 var buf: [128]u8 = undefined;
804 {
805 var reader: Io.Reader = .fixed("2x0 hello\r\n");
806 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
807 }
808 {
809 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n");
810 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
811 }
812 {
813 var reader: Io.Reader = .fixed("42\r\n");
814 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
815 }
816}
817
818test Command {
819 {
820 const cmd = try Command.parse("EHLO client.example.com");
821 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo);
822 }
823 {
824 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024");
825 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path);
826 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params);
827 }
828 {
829 // Null reverse-path and a space after the colon.
830 const cmd = try Command.parse("MAIL FROM: <>");
831 try std.testing.expectEqualStrings("", cmd.mail.path);
832 }
833 {
834 // Obsolete source route is stripped.
835 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>");
836 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path);
837 }
838 {
839 // Quoted local-parts (from postfix's address corpora) may contain
840 // spaces and even '>' or escaped quotes.
841 const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9");
842 try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path);
843 try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params);
844 }
845 {
846 const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>");
847 try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path);
848 }
849 {
850 const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>");
851 try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path);
852 }
853 try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>"));
854 {
855 const cmd = try Command.parse("QUIT");
856 try std.testing.expectEqual(Command.quit, cmd);
857 }
858 {
859 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw==");
860 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism);
861 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial);
862 }
863 {
864 const cmd = try Command.parse("auth login");
865 try std.testing.expectEqualStrings("login", cmd.auth.mechanism);
866 try std.testing.expectEqualStrings("", cmd.auth.initial);
867 }
868 {
869 const cmd = try Command.parse("MADE UP");
870 try std.testing.expectEqualStrings("MADE UP", cmd.unknown);
871 }
872 {
873 const cmd = try Command.parse("BDAT 1024");
874 try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size);
875 try std.testing.expect(!cmd.bdat.last);
876 }
877 {
878 const cmd = try Command.parse("bdat 0 last");
879 try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size);
880 try std.testing.expect(cmd.bdat.last);
881 }
882 try std.testing.expectError(error.Syntax, Command.parse("BDAT"));
883 try std.testing.expectError(error.Syntax, Command.parse("BDAT nan"));
884 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST"));
885 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra"));
886 try std.testing.expectError(error.Syntax, Command.parse("AUTH"));
887 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>"));
888 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:"));
889 try std.testing.expectError(error.Syntax, Command.parse("HELO"));
890}
891
892test writeStuffed {
893 var buf: [256]u8 = undefined;
894 {
895 var w: Io.Writer = .fixed(&buf);
896 try writeStuffed(&w, "line one\r\n.starts with dot\r\n");
897 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered());
898 }
899 {
900 // LF-only input is normalized, missing final newline is added.
901 var w: Io.Writer = .fixed(&buf);
902 try writeStuffed(&w, "a\nb");
903 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered());
904 }
905 {
906 // A lone "." line must not become a terminator.
907 var w: Io.Writer = .fixed(&buf);
908 try writeStuffed(&w, ".\n");
909 try std.testing.expectEqualStrings("..\r\n", w.buffered());
910 }
911 {
912 var w: Io.Writer = .fixed(&buf);
913 try writeStuffed(&w, "");
914 try std.testing.expectEqualStrings("", w.buffered());
915 }
916}
917
918test "fuzz Command.parse" {
919 try std.testing.fuzz({}, fuzzCommandParse, .{});
920}
921
922fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void {
923 _ = context;
924 var line_buf: [512]u8 = undefined;
925 const line = line_buf[0..smith.value(u9)];
926 smith.bytes(line);
927
928 const command = Command.parse(line) catch return;
929 // Payload slices must always lie within the parsed line.
930 switch (command) {
931 .helo, .ehlo, .lhlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len),
932 .mail, .rcpt => |args| {
933 try std.testing.expect(args.path.len <= line.len);
934 try std.testing.expect(args.params.len <= line.len);
935 },
936 .auth => |args| {
937 try std.testing.expect(args.mechanism.len <= line.len);
938 try std.testing.expect(args.initial.len <= line.len);
939 },
940 .data, .rset, .noop, .quit, .help, .starttls, .bdat => {},
941 }
942}
943
944test "fuzz Reply.read" {
945 try std.testing.fuzz({}, fuzzReplyRead, .{});
946}
947
948fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void {
949 _ = context;
950 var input_buf: [1024]u8 = undefined;
951 const input = input_buf[0..smith.value(u10)];
952 smith.bytes(input);
953
954 var reader: Io.Reader = .fixed(input);
955 var text_buf: [128]u8 = undefined;
956 // Each successful read consumes at least one line, so this terminates.
957 while (true) {
958 const reply = Reply.read(&reader, &text_buf) catch break;
959 try std.testing.expect(reply.code >= 100 and reply.code <= 599);
960 }
961}
962
963test ParamIterator {
964 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG");
965 var it = command.mail.paramIterator();
966
967 const size = it.next().?;
968 try std.testing.expectEqualStrings("SIZE", size.keyword);
969 try std.testing.expectEqualStrings("1024", size.value);
970
971 const body = it.next().?;
972 try std.testing.expectEqualStrings("BODY", body.keyword);
973 try std.testing.expectEqualStrings("8BITMIME", body.value);
974
975 const flag = it.next().?;
976 try std.testing.expectEqualStrings("FLAG", flag.keyword);
977 try std.testing.expectEqualStrings("", flag.value);
978
979 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next());
980}
981
982test crlf {
983 try std.testing.expectEqualStrings("\r\n", crlf);
984}
985
986test "RFC 5321 mailbox forms from the is_email corpus round-trip" {
987 // Parses Dominic Sayers' is_email test suite (tests.xml and
988 // tests-original.xml, embedded from the lazy `isemail` dependency by
989 // `zig build test -Disemail-corpus`) and checks that every address
990 // valid at the RFC 5321 layer passes through the lenient path parser
991 // byte-for-byte, params intact.
992 if (comptime @import("build_options").isemail_corpus) {
993 const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++
994 @embedFile("isemail_tests_original_xml");
995 var checked: usize = 0;
996 var rest: []const u8 = corpus;
997 while (std.mem.indexOf(u8, rest, "<test ")) |start_index| {
998 const end_index = std.mem.indexOfPos(u8, rest, start_index, "</test>") orelse break;
999 const block = rest[start_index..end_index];
1000 rest = rest[end_index + "</test>".len ..];
1001
1002 const category = xmlElementText(block, "category") orelse continue;
1003 if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and
1004 !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue;
1005 const raw = xmlElementText(block, "address") orelse continue;
1006 var address_buf: [256]u8 = undefined;
1007 const address = try xmlUnescape(&address_buf, raw);
1008
1009 var line_buf: [300]u8 = undefined;
1010 const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address});
1011 const command = try Command.parse(line);
1012 try std.testing.expectEqualStrings(address, command.mail.path);
1013 try std.testing.expectEqualStrings("SIZE=1", command.mail.params);
1014 checked += 1;
1015 }
1016 // The two files carry 125 RFC 5321-valid cases between them; fail
1017 // loudly if the extraction ever silently rots.
1018 try std.testing.expect(checked >= 120);
1019 } else return error.SkipZigTest;
1020}
1021
1022fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 {
1023 const open = "<" ++ tag ++ ">";
1024 const close = "</" ++ tag ++ ">";
1025 const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len;
1026 const end = std.mem.indexOfPos(u8, block, start, close) orelse return null;
1027 return block[start..end];
1028}
1029
1030fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 {
1031 var out: usize = 0;
1032 var i: usize = 0;
1033 while (i < input.len) {
1034 if (input[i] != '&') {
1035 buffer[out] = input[i];
1036 out += 1;
1037 i += 1;
1038 continue;
1039 }
1040 const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity;
1041 const entity = input[i + 1 .. semi];
1042 i = semi + 1;
1043 if (std.mem.eql(u8, entity, "amp")) {
1044 buffer[out] = '&';
1045 out += 1;
1046 } else if (std.mem.eql(u8, entity, "lt")) {
1047 buffer[out] = '<';
1048 out += 1;
1049 } else if (std.mem.eql(u8, entity, "gt")) {
1050 buffer[out] = '>';
1051 out += 1;
1052 } else if (std.mem.eql(u8, entity, "quot")) {
1053 buffer[out] = '"';
1054 out += 1;
1055 } else if (std.mem.eql(u8, entity, "apos")) {
1056 buffer[out] = '\'';
1057 out += 1;
1058 } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) {
1059 const codepoint = try std.fmt.parseInt(u21, entity[2..], 16);
1060 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
1061 } else if (std.mem.startsWith(u8, entity, "#")) {
1062 const codepoint = try std.fmt.parseInt(u21, entity[1..], 10);
1063 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
1064 } else return error.BadEntity;
1065 }
1066 return buffer[0..out];
1067}