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