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 / README.md
7.0 kB 188 lines
1<!-- 2SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 3SPDX-License-Identifier: MIT 4--> 5 6# zsmtp 7 8An SMTP client and server library for Zig (RFC 5321). 9 10Both the client and the server run over plain `std.Io.Reader`/`std.Io.Writer` 11pairs, so they are transport-agnostic: wrap a TCP stream for real use, or 12fixed in-memory buffers in tests. Requires Zig 0.16. 13 14## Client 15 16```zig 17const zsmtp = @import("zsmtp"); 18 19var reply_buf: [1024]u8 = undefined; 20var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 21 22_ = try client.greet(); // read the 220 greeting 23_ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions 24try client.sendMail("me@example.com", &.{"you@example.net"}, message); 25try client.quit(); 26``` 27 28Line endings in the message are normalized to CRLF and leading dots are 29stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds 30the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also 31available individually. 32 33Message bodies can also be streamed instead of passed as a slice — from any 34reader via `sendMessageReader(&reader)`, or push-style via `data()`, which 35returns a writer that dot-stuffs and normalizes line endings as content 36flows through it: 37 38```zig 39var data_writer = try client.data(); 40try data_writer.interface.print("Subject: report {d}\r\n\r\n", .{id}); 41// ... stream as much as needed ... 42try data_writer.end(); // terminates the message, reads the verdict 43``` 44 45### Authentication 46 47`hello` reports the server's advertised mechanisms in `extensions.auth`; 48`authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use 49`authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send 50credentials unprotected, so use TLS on real networks. A 535 rejection 51surfaces as `error.AuthenticationFailed` with the reply in `last_reply`. 52 53```zig 54const extensions = try client.hello("my-host.example.com"); 55try client.authenticate(extensions, "user", "password"); 56``` 57 58### TLS 59 60`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 61verifies against the system trust store by default (a caller-managed CA 62bundle and an insecure mode are also available). The stream reader/writer 63handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 64`init` must run at the value's final address (the connection holds interior 65pointers). The standard library's TLS client is deliberately not used: it 66requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 67record, which servers like Exim disable. 68 69Implicit TLS (port 465) — handshake first, then speak SMTP: 70 71```zig 72var tls: zsmtp.Tls = undefined; 73try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 74 .host = "smtp.example.com", 75}); 76defer tls.deinit(gpa); 77var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 78// ... greet, hello, sendMail ... 79try client.quit(); 80try tls.end(); // close_notify, before closing the socket 81``` 82 83STARTTLS (port 587) — upgrade mid-session, then EHLO again: 84 85```zig 86_ = try client.greet(); 87_ = try client.hello("my-host.example.com"); // check .starttls in the result 88try client.starttls(); 89var tls: zsmtp.Tls = undefined; 90try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 91 .host = "smtp.example.com", 92}); 93client.setTransport(tls.reader(), tls.writer()); 94_ = try client.hello("my-host.example.com"); // server state was reset 95``` 96 97## Server 98 99```zig 100var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 101 .context = &my_state, 102 .vtable = &.{ 103 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 104 .rcptTo = onRcptTo, // optional; accept/reject each recipient 105 .message = onMessage, // required; receives envelope + message data 106 }, 107}, .{ .hostname = "mx.example.com" }); 108try session.run(gpa); 109``` 110 111With an `authenticate` callback the session advertises and accepts AUTH 112PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 113with 530 until the client has authenticated. 114 115Instead of `message` (which collects the whole body in memory, bounded by 116`max_message_size`), a handler can set `messageReader` to stream it: the 117callback receives an `Io.Reader` yielding the unstuffed message content, 118and anything left unread is drained by the session. 119 120`run` serves one connection until QUIT or disconnect, enforcing command 121sequencing, recipient and message-size limits, and un-stuffing message data. 122Listening, accepting, and concurrency are up to the caller. 123 124To advertise and accept STARTTLS (TLS 1.3, via 125[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 126pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 127`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 128 129```zig 130var auth: zsmtp.tls.config.CertKeyPair = 131 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 132defer auth.deinit(gpa); 133 134var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 135 .hostname = "mx.example.com", 136 .starttls = .{ .io = io, .auth = &auth }, 137}); 138try session.run(gpa); 139``` 140 141On STARTTLS the session answers 220, performs the server handshake, swaps 142its transport to the encrypted connection, and resets state per RFC 3207 (the 143client must EHLO again). 144 145## Demo CLI 146 147```sh 148zig build 149 150# Debug server that prints received messages to stdout 151# (with a cert/key pair it advertises and accepts STARTTLS): 152./zig-out/bin/zsmtp serve 2525 153./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 154 155# Send a message read from stdin: 156printf 'Subject: hi\r\n\r\nhello\r\n' | \ 157 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 158 159# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 160zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 161zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 162``` 163 164## Status 165 166TLS is supported on both sides via 167[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 168TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3 169only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and 170LOGIN on the server. Message bodies can be streamed on both sides. Not yet 171implemented: implicit TLS on the server side, and ESMTP parameter handling 172(SIZE=, BODY=) on the server side. 173 174## Tests 175 176```sh 177zig build test 178``` 179 180Interoperability against third-party implementations is covered by a NixOS 181VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 182and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 183delivers to the zsmtp server over plaintext and STARTTLS. 184 185```sh 186nix build .#zsmtp # build the package 187nix build .#checks.x86_64-linux.interop # run the VM interop test 188```