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
12 kB 330 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: bool = false, 52 /// Value of the SIZE extension, if advertised with a value. 53 max_size: ?u64 = null, 54 55 fn parse(reply: Reply) Extensions { 56 var ext: Extensions = .{}; 57 var it = reply.lines(); 58 _ = it.next(); // The first line is the server's greeting, not a keyword. 59 while (it.next()) |line| { 60 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; 61 const kw = line[0..kw_end]; 62 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else ""; 63 if (ieql(kw, "PIPELINING")) { 64 ext.pipelining = true; 65 } else if (ieql(kw, "8BITMIME")) { 66 ext.eight_bit_mime = true; 67 } else if (ieql(kw, "STARTTLS")) { 68 ext.starttls = true; 69 } else if (ieql(kw, "SMTPUTF8")) { 70 ext.smtputf8 = true; 71 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 72 ext.enhanced_status_codes = true; 73 } else if (ieql(kw, "AUTH")) { 74 ext.auth = true; 75 } else if (ieql(kw, "SIZE")) { 76 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 77 } 78 } 79 return ext; 80 } 81 82 fn ieql(a: []const u8, b: []const u8) bool { 83 return std.ascii.eqlIgnoreCase(a, b); 84 } 85}; 86 87/// `reply_buffer` must be large enough for the largest expected reply text 88/// (the EHLO response is usually the largest); 512 bytes is plenty in 89/// practice. 90pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client { 91 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer }; 92} 93 94/// Reads the server's 220 greeting. Call once, right after connecting. 95pub fn greet(c: *Client) Error!Reply { 96 return c.expect(220); 97} 98 99/// Sends EHLO and returns the extensions the server advertised, falling back 100/// to plain HELO for servers that do not speak ESMTP. 101pub fn hello(c: *Client, client_name: []const u8) Error!Extensions { 102 try c.send("EHLO {s}", .{client_name}); 103 const reply = try c.readReply(); 104 if (reply.isPositiveCompletion()) return Extensions.parse(reply); 105 if (reply.code == 500 or reply.code == 502) { 106 try c.send("HELO {s}", .{client_name}); 107 _ = try c.expectClass(2); 108 return .{}; 109 } 110 return error.UnexpectedReply; 111} 112 113/// Sends STARTTLS (RFC 3207) and reads the server's 220 go-ahead. On 114/// success, perform a TLS handshake over the underlying stream (see `Tls`), 115/// switch to the encrypted transport with `setTransport`, and then call 116/// `hello` again — the server discards everything it learned before the 117/// handshake, including the EHLO state. 118pub fn starttls(c: *Client) Error!void { 119 try c.send("STARTTLS", .{}); 120 _ = try c.expect(220); 121} 122 123/// Replaces the session's transport, typically with a TLS reader/writer 124/// after `starttls`. 125pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void { 126 c.reader = reader; 127 c.writer = writer; 128} 129 130/// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless 131/// you need to act on behalf of another identity. Note that sending 132/// credentials over an unencrypted connection exposes them to the network. 133pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) (Error || error{CredentialsTooLong})!void { 134 var plain_buf: [512]u8 = undefined; 135 var plain: Io.Writer = .fixed(&plain_buf); 136 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch 137 return error.CredentialsTooLong; 138 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; 139 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); 140 try c.send("AUTH PLAIN {s}", .{b64}); 141 _ = try c.expect(235); 142} 143 144/// Starts a mail transaction. An empty `from` sends the null reverse-path 145/// (`MAIL FROM:<>`), used for bounces. 146pub fn mailFrom(c: *Client, from: []const u8) Error!void { 147 try c.send("MAIL FROM:<{s}>", .{from}); 148 _ = try c.expectClass(2); 149} 150 151pub fn rcptTo(c: *Client, to: []const u8) Error!void { 152 try c.send("RCPT TO:<{s}>", .{to}); 153 _ = try c.expectClass(2); 154} 155 156/// Sends the message content for the current transaction (DATA). Line 157/// endings in `data` are normalized to CRLF and leading dots are stuffed. 158pub fn sendMessage(c: *Client, data: []const u8) Error!void { 159 try c.send("DATA", .{}); 160 _ = try c.expect(354); 161 try protocol.writeStuffed(c.writer, data); 162 try c.writer.writeAll("." ++ protocol.crlf); 163 try c.writer.flush(); 164 _ = try c.expectClass(2); 165} 166 167/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 168/// then DATA. Call after `greet` and `hello`. 169pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, data: []const u8) Error!void { 170 try c.mailFrom(from); 171 for (recipients) |recipient| try c.rcptTo(recipient); 172 try c.sendMessage(data); 173} 174 175/// Aborts the current mail transaction. 176pub fn rset(c: *Client) Error!void { 177 try c.send("RSET", .{}); 178 _ = try c.expectClass(2); 179} 180 181pub fn noop(c: *Client) Error!void { 182 try c.send("NOOP", .{}); 183 _ = try c.expectClass(2); 184} 185 186/// Ends the session. The connection should be closed afterwards. 187pub fn quit(c: *Client) Error!void { 188 try c.send("QUIT", .{}); 189 _ = try c.expect(221); 190} 191 192fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 193 try c.writer.print(fmt ++ protocol.crlf, args); 194 try c.writer.flush(); 195} 196 197fn readReply(c: *Client) Error!Reply { 198 const reply = try Reply.read(c.reader, c.reply_buffer); 199 c.last_reply = reply; 200 return reply; 201} 202 203fn expect(c: *Client, code: u16) Error!Reply { 204 const reply = try c.readReply(); 205 if (reply.code != code) return error.UnexpectedReply; 206 return reply; 207} 208 209fn expectClass(c: *Client, class: u16) Error!Reply { 210 const reply = try c.readReply(); 211 if (reply.code / 100 != class) return error.UnexpectedReply; 212 return reply; 213} 214 215test "full transaction against a scripted server" { 216 const responses = "220 mx.example.com ESMTP\r\n" ++ 217 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 218 "250 2.1.0 Ok\r\n" ++ 219 "250 2.1.5 Ok\r\n" ++ 220 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 221 "250 2.0.0 Ok\r\n" ++ 222 "221 2.0.0 Bye\r\n"; 223 var reader: Io.Reader = .fixed(responses); 224 var out_buf: [1024]u8 = undefined; 225 var writer: Io.Writer = .fixed(&out_buf); 226 var reply_buf: [512]u8 = undefined; 227 var client: Client = .init(&reader, &writer, &reply_buf); 228 229 _ = try client.greet(); 230 const ext = try client.hello("client.example.org"); 231 try std.testing.expect(ext.pipelining); 232 try std.testing.expect(ext.eight_bit_mime); 233 try std.testing.expect(!ext.starttls); 234 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 235 236 try client.sendMail( 237 "alice@example.com", 238 &.{"bob@example.net"}, 239 "Subject: hi\r\n\r\n.leading dot\r\n", 240 ); 241 try client.quit(); 242 243 try std.testing.expectEqualStrings( 244 "EHLO client.example.org\r\n" ++ 245 "MAIL FROM:<alice@example.com>\r\n" ++ 246 "RCPT TO:<bob@example.net>\r\n" ++ 247 "DATA\r\n" ++ 248 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 249 "QUIT\r\n", 250 writer.buffered(), 251 ); 252} 253 254test "HELO fallback for non-ESMTP servers" { 255 const responses = "220 old.example.com\r\n" ++ 256 "502 command not implemented\r\n" ++ 257 "250 old.example.com\r\n"; 258 var reader: Io.Reader = .fixed(responses); 259 var out_buf: [256]u8 = undefined; 260 var writer: Io.Writer = .fixed(&out_buf); 261 var reply_buf: [256]u8 = undefined; 262 var client: Client = .init(&reader, &writer, &reply_buf); 263 264 _ = try client.greet(); 265 const ext = try client.hello("client.example.org"); 266 try std.testing.expectEqual(Extensions{}, ext); 267 try std.testing.expectEqualStrings( 268 "EHLO client.example.org\r\nHELO client.example.org\r\n", 269 writer.buffered(), 270 ); 271} 272 273test "rejected recipient surfaces the reply" { 274 const responses = "550 5.1.1 No such user\r\n"; 275 var reader: Io.Reader = .fixed(responses); 276 var out_buf: [256]u8 = undefined; 277 var writer: Io.Writer = .fixed(&out_buf); 278 var reply_buf: [256]u8 = undefined; 279 var client: Client = .init(&reader, &writer, &reply_buf); 280 281 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 282 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 283 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 284} 285 286test "starttls handshake handoff" { 287 const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 288 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 289 "220 2.0.0 Ready to start TLS\r\n"; 290 var reader: Io.Reader = .fixed(plain_responses); 291 var out_buf: [256]u8 = undefined; 292 var writer: Io.Writer = .fixed(&out_buf); 293 var reply_buf: [256]u8 = undefined; 294 var client: Client = .init(&reader, &writer, &reply_buf); 295 296 _ = try client.greet(); 297 const ext = try client.hello("client.example.org"); 298 try std.testing.expect(ext.starttls); 299 try client.starttls(); 300 301 // Simulate the post-handshake encrypted transport with fresh buffers; 302 // the session must re-EHLO on it. 303 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 304 var tls_reader: Io.Reader = .fixed(tls_responses); 305 var tls_out_buf: [256]u8 = undefined; 306 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 307 client.setTransport(&tls_reader, &tls_writer); 308 309 const tls_ext = try client.hello("client.example.org"); 310 try std.testing.expect(!tls_ext.starttls); 311 try std.testing.expect(tls_ext.eight_bit_mime); 312 try std.testing.expectEqualStrings( 313 "EHLO client.example.org\r\nSTARTTLS\r\n", 314 writer.buffered(), 315 ); 316 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 317} 318 319test "authPlain encodes credentials" { 320 const responses = "235 2.7.0 Accepted\r\n"; 321 var reader: Io.Reader = .fixed(responses); 322 var out_buf: [256]u8 = undefined; 323 var writer: Io.Writer = .fixed(&out_buf); 324 var reply_buf: [256]u8 = undefined; 325 var client: Client = .init(&reader, &writer, &reply_buf); 326 327 try client.authPlain("", "user", "pass"); 328 // base64("\x00user\x00pass") 329 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 330}