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
6.2 kB 171 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 33### Authentication 34 35`hello` reports the server's advertised mechanisms in `extensions.auth`; 36`authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use 37`authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send 38credentials unprotected, so use TLS on real networks. A 535 rejection 39surfaces as `error.AuthenticationFailed` with the reply in `last_reply`. 40 41```zig 42const extensions = try client.hello("my-host.example.com"); 43try client.authenticate(extensions, "user", "password"); 44``` 45 46### TLS 47 48`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 49verifies against the system trust store by default (a caller-managed CA 50bundle and an insecure mode are also available). The stream reader/writer 51handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 52`init` must run at the value's final address (the connection holds interior 53pointers). The standard library's TLS client is deliberately not used: it 54requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 55record, which servers like Exim disable. 56 57Implicit TLS (port 465) — handshake first, then speak SMTP: 58 59```zig 60var tls: zsmtp.Tls = undefined; 61try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 62 .host = "smtp.example.com", 63}); 64defer tls.deinit(gpa); 65var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 66// ... greet, hello, sendMail ... 67try client.quit(); 68try tls.end(); // close_notify, before closing the socket 69``` 70 71STARTTLS (port 587) — upgrade mid-session, then EHLO again: 72 73```zig 74_ = try client.greet(); 75_ = try client.hello("my-host.example.com"); // check .starttls in the result 76try client.starttls(); 77var tls: zsmtp.Tls = undefined; 78try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 79 .host = "smtp.example.com", 80}); 81client.setTransport(tls.reader(), tls.writer()); 82_ = try client.hello("my-host.example.com"); // server state was reset 83``` 84 85## Server 86 87```zig 88var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 89 .context = &my_state, 90 .vtable = &.{ 91 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 92 .rcptTo = onRcptTo, // optional; accept/reject each recipient 93 .message = onMessage, // required; receives envelope + message data 94 }, 95}, .{ .hostname = "mx.example.com" }); 96try session.run(gpa); 97``` 98 99With an `authenticate` callback the session advertises and accepts AUTH 100PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 101with 530 until the client has authenticated. 102 103`run` serves one connection until QUIT or disconnect, enforcing command 104sequencing, recipient and message-size limits, and un-stuffing message data. 105Listening, accepting, and concurrency are up to the caller. 106 107To advertise and accept STARTTLS (TLS 1.3, via 108[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 109pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 110`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 111 112```zig 113var auth: zsmtp.tls.config.CertKeyPair = 114 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 115defer auth.deinit(gpa); 116 117var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 118 .hostname = "mx.example.com", 119 .starttls = .{ .io = io, .auth = &auth }, 120}); 121try session.run(gpa); 122``` 123 124On STARTTLS the session answers 220, performs the server handshake, swaps 125its transport to the encrypted connection, and resets state per RFC 3207 (the 126client must EHLO again). 127 128## Demo CLI 129 130```sh 131zig build 132 133# Debug server that prints received messages to stdout 134# (with a cert/key pair it advertises and accepts STARTTLS): 135./zig-out/bin/zsmtp serve 2525 136./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 137 138# Send a message read from stdin: 139printf 'Subject: hi\r\n\r\nhello\r\n' | \ 140 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 141 142# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 143zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 144zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 145``` 146 147## Status 148 149TLS is supported on both sides via 150[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 151TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3 152only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and 153LOGIN on the server. Not yet implemented: implicit TLS on the server side, 154streaming (non-slice) message bodies, and ESMTP parameter handling (SIZE=, 155BODY=) on the server side. 156 157## Tests 158 159```sh 160zig build test 161``` 162 163Interoperability against third-party implementations is covered by a NixOS 164VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 165and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 166delivers to the zsmtp server over plaintext and STARTTLS. 167 168```sh 169nix build .#zsmtp # build the package 170nix build .#checks.x86_64-linux.interop # run the VM interop test 171```