Self-hosted, headless mail server β SMTP, IMAP and modern email security through an API and CLI. Part of the Glyndor stack.
flowchart LR
client([SMTP client]) -->|25 / 587 / 465| tls[TLS / STARTTLS]
tls --> smtp[SMTP listener]
smtp --> session[Session + AUTH]
session --> auth[SPF / DKIM / DMARC]
auth -->|local| mailbox[(Account mailboxes)]
auth -->|relay, DKIM-signed| queue[(Outbound queue)]
queue -->|MX + STARTTLS| internet([Remote servers])
cli[CLI] --> config[Config / fail-closed validation]
config --> smtp
- π¨ SMTP server core β strict RFC 5321 session handling: HELO/EHLO, MAIL FROM (with
SIZE/BODY), RCPT TO, DATA, RSET, NOOP, QUIT - π TLS everywhere β STARTTLS (RFC 3207) on SMTP/submission, implicit TLS for
submissions; rustls, no OpenSSL; broken TLS material refuses to start instead of degrading - π‘οΈ Smuggling-immune by construction β bare CR, bare LF or NUL anywhere in the stream closes the connection; CRLF is enforced at the framing layer
- π« No relay, no ghosts β recipients outside the configured
domainsanswer550 5.7.1, unknown users in local domains answer550 5.1.1; with nothing configured everything is denied (fail closed) - β
Full authentication chain β SPF (RFC 7208) with
failrejection, DKIM verification (RFC 6376, rsa + ed25519), DMARC alignment and policy enforcement (RFC 7489); results recorded inAuthentication-Results - βοΈ DKIM signing β outbound mail signed with ed25519;
epistle dkim-keygengenerates the key and prints the DNS record - π Submission with AUTH β
AUTH PLAINover TLS only, argon2id password hashes, no user-enumeration oracle; authenticated users relay from their own addresses - π€ Outbound queue β MX resolution, opportunistic STARTTLS, per-domain delivery with retry/backoff semantics
- π¬ Local delivery β accepted mail lands once per recipient account under
data_dir/accounts/<name>/new/ - π Secure by default β listeners bind to localhost unless explicitly configured otherwise; configuration fails closed on any unknown key or invalid value
- πΎ Crash-safe writes β accepted messages are fsynced and atomically renamed into the mailbox before the server answers
250 - π§° Operator CLI β
epistle serve,epistle config-check,epistle dkim-keygen, meaningful exit codes
curl -fsSL https://apt.glyndor.net/install/epistle | sudo shThat is the whole install. It adds the signed Glyndor apt repository, installs
epistle from it, and sets the machine up to receive security fixes on its own.
Podman and podup come along with it β they are what run the mail stack.
Root is needed because it installs packages. It leaves nothing of its own behind: the download is removed, and so is anything it had to install just to check the archive key.
Upgrades are apt's job from then on. apt upgrade pulls new versions, and
the archive's signing key renews through the same channel, because it ships as a
package apt owns. If the machine had no automatic-upgrade setup, the installer
adds a conservative one: security updates apply on their own, and the server
never reboots itself. If it already had one, that configuration is left exactly
as it was.
epistle runs on Linux and ships as a .deb, so apt is the supported install. On
a system without apt, build from source below.
cargo build --release
cat > mail.toml <<'EOF'
hostname = "mail.example.org"
data_dir = "/var/lib/mail"
domains = ["example.org"]
[[accounts]]
name = "alice"
addresses = ["alice@example.org", "postmaster@example.org"]
[[listeners]]
kind = "smtp"
EOF
./target/release/epistle config-check --config mail.toml
./target/release/epistle serve --config mail.tomlThe SMTP listener binds to 127.0.0.1:25 by default β exposing it is an explicit decision:
[[listeners]]
kind = "smtp"
addr = "0.0.0.0"The config file must be owner-only β epistle refuses to load a file that is group- or world-accessible (chmod 600 mail.toml). Keep secrets out of the file itself: any ${VAR} is substituted from the process environment at load time, and a referenced variable that is unset fails the load (never a silent empty value). For example, source the database password from the environment instead of writing it on disk:
[database]
url = "postgres://mail:${MAIL_DB_PASSWORD}@db/mail"Substitution happens before the TOML is parsed, so a substituted value must not contain TOML metacharacters (", newlines); percent-encode such characters in a connection URL.
Binding the mail ports (25, 465, 587, 993, 143, 995, 80) needs root, but the server should not keep that privilege. With a [privileges] section, epistle drops to an unprivileged user once every listener is bound and before it serves a single connection, so a later compromise cannot act as root:
[privileges]
user = "glyndor-epistle"
group = "glyndor-epistle" # optional; defaults to the user's primary groupThe drop fails closed: if the user/group cannot be resolved, the process is not root, or the drop cannot be verified (including that root can no longer be regained), the server refuses to start. Omit the section to run as whoever launched the process (for example under a systemd User=).
The .deb creates glyndor-epistle for you, with /var/lib/glyndor/epistle at mode 0700, the subuid/subgid ranges rootless Podman needs, and systemd linger so the account's services come back after a reboot. It also installs /usr/lib/sysctl.d/30-glyndor-epistle.conf, which sets net.ipv4.ip_unprivileged_port_start = 25 so that the account can bind the SMTP port without root at all, which is what a rootless container needs. That floor is machine-wide rather than per-user: once it is applied, any local unprivileged user can bind ports from 25 up. On a dedicated mail host that is the trade worth making; on a shared host, read Port 25 without root first and override the value in /etc/sysctl.d if you would rather not take it. The package attempts each step and warns on stderr when one does not take, never failing the install; epistle init is what verifies the result and refuses to continue when something is missing.
- Protocols β SMTP (submission + relay), IMAP4rev2 (CONDSTORE/QRESYNC/OBJECTID/BINARY/IDLE), POP3, and JMAP (RFC 8620/8621).
- Authentication β SASL PLAIN/LOGIN/SCRAM-SHA-256/OAUTHBEARER with TOTP two-factor, all over TLS.
- Email security β SPF, DKIM (sign + verify, ed25519 + RSA), DMARC with aggregate reports, ARC, MTA-STS, DANE and TLS-RPT.
- Filtering β Sieve (tests, actions, variables, vacation) with remote script management over ManageSieve (RFC 5804), plus greylisting, DNSBL, Bayesian and reputation antispam.
- Operations β automatic TLS via ACME, a management API, outbound webhooks, Prometheus metrics, and a CLI (
serve,export/import,queue,accounts,account-add,dkim-keygen,token-hash).
- Configuration reference β the TOML file, every section and key, listener kinds, and a full example.
- CLI reference β every
epistlecommand, plus the outbound retry/suppression policy. - DNS setup β every record to publish (MX, SPF, DKIM, DMARC, MTA-STS, TLS-RPT, PTR, SRV) with examples.
- Security β the transport, authentication, anti-abuse and at-rest controls, and how to report a vulnerability.
- Threat model β assets, trust boundaries, each threat with its control and the test behind it, and the residual risks.
Beyond the unit and in-process protocol tests, an end-to-end matrix
(.github/workflows/e2e.yml) exercises the real
shipped binary as it runs in production:
- Full server, not a harness β
epistle serveis built--releaseand launched under systemd (systemd-run), exactly as an operator would run it fromdocs/epistle.service. - Real ports and TLS β it binds a
submissionslistener (implicit TLS) and animapslistener (implicit TLS) on real TCP ports with a self-signed certificate. - A real round trip β
tests/e2e.rsopens a TLS client to the submission port, authenticates (AUTH PLAIN), submits a message with a unique marker, then polls the IMAP port over TLS and asserts the message is delivered and fetchable. - A VM matrix β every step runs on each GitHub VM runner in the matrix
(
ubuntu-22.04,ubuntu-24.04); GitHub's hosted runners are virtual machines, so this is the VM coverage.
The test is gated on E2E_* environment variables and skips cleanly when they
are unset, so the default cargo test run is unaffected. To run it locally
against a server you have started, point it at the live ports and the server's
certificate:
cargo build --release
# Generate a TLS certificate (the client must trust it as both root and leaf,
# so it must not be CA-flagged), a config, and an account, then serve.
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \
-days 2 -subj "/CN=mail.epistle.test" \
-addext "subjectAltName=DNS:mail.epistle.test,IP:127.0.0.1" \
-addext "basicConstraints=critical,CA:FALSE"
cat > e2e.toml <<'EOF'
hostname = "mail.epistle.test"
data_dir = "/var/lib/epistle-e2e"
domains = ["epistle.test"]
[tls]
cert_file = "cert.pem"
key_file = "key.pem"
[[listeners]]
kind = "submissions"
addr = "127.0.0.1"
port = 4465
[[listeners]]
kind = "imaps"
addr = "127.0.0.1"
port = 4993
EOF
chmod 600 e2e.toml
printf 'a-strong-password' | ./target/release/epistle account-add \
--config e2e.toml --name tester --address tester@epistle.test
./target/release/epistle serve --config e2e.toml &
E2E_HOST=127.0.0.1 E2E_SUBMISSION_PORT=4465 E2E_IMAPS_PORT=4993 \
E2E_ACCOUNT=tester@epistle.test E2E_PASSWORD=a-strong-password \
E2E_CA_PEM=cert.pem cargo test --test e2eRemaining work β an LDAP directory backend, IMAP COMPRESS, and CalDAV/CardDAV groupware β is tracked in the issues.