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