sendmail-sec is a Rust CLI that accepts authenticated SMTP submissions from localhost or private networks, encrypts the submitted mail with OpenPGP by default, and relays it to a remote SMTP server over mandatory TLS using Rustls. Operators can explicitly allow selected envelope recipients to receive an unencrypted message.
- Listens for SMTP on a configurable local address.
- Restricts clients to configured local/private CIDR ranges.
- Requires
AUTH PLAINfor inbound SMTP beforeMAIL/RCPT/DATA. - Resolves OpenPGP public keys for recipients from:
- operator-provided key files
- operator-provided key directories
- WKD
keys.openpgp.org
- Encrypts the message and wraps it as
multipart/encryptedPGP/MIME. - Supports exact, explicitly configured envelope-recipient exceptions that bypass OpenPGP encryption.
- Relays the encrypted message to a remote SMTP server using:
PLAINOAUTHBEARERXOAUTH2
- Refuses outbound SMTP delivery unless the connection is protected with TLS.
- Uses Rustls for all TLS connections, including SMTP and HTTPS key fetches.
- Inbound SMTP is plaintext by design and is expected to be exposed only on localhost or trusted private networks.
- The default OpenPGP
encryption_modeispgp_mime_body, which preserves common outer mail headers such asFrom,To,Cc,Date, andSubject, and encrypts the MIME body. - If you want the entire raw message encrypted instead, set
openpgp.encryption_modetofull_message. - Envelope recipients from SMTP are always used for remote relay delivery. Header recipients are also used for key lookup so that normal
To/Ccdelivery works, and Bcc-style envelope recipients can still be encrypted for. - Only SMTP envelope recipients can trigger an unencrypted-recipient exception. Sender-controlled
To,Cc, andBccheaders cannot downgrade encryption.
cargo build --releaseBuild a musl binary explicitly:
cargo build --release --target x86_64-unknown-linux-muslFor a local musl build outside Docker, install a musl cross toolchain that provides x86_64-linux-musl-gcc first.
Supported Linux release targets:
x86_64-unknown-linux-gnuaarch64-unknown-linux-gnuriscv64gc-unknown-linux-gnux86_64-unknown-linux-muslaarch64-unknown-linux-muslriscv64gc-unknown-linux-musl
Validate a config file without starting the listener:
./target/release/sendmail-sec --config /path/to/sendmail-sec.yaml --check-configFor a musl build, the binary path is:
./target/x86_64-unknown-linux-musl/release/sendmail-sec --config /path/to/sendmail-sec.yaml --check-configStart the service:
./target/release/sendmail-sec --config /path/to/sendmail-sec.yamlYAML and JSON are both supported. Example files:
Important fields:
listen.bind: local SMTP bind address, default0.0.0.0:2525listen.allowed_networks: CIDRs allowed to connectlisten.auth: inbound SMTPAUTH PLAINcredentialslisten.auth.password_file: file alternative tolisten.auth.passwordremote_smtp.tls_mode:starttlsorwrapperremote_smtp.auth.mechanism:plain,oauthbearer, orxoauth2remote_smtp.auth.password_file: file alternative topasswordforplainremote_smtp.auth.access_token_file: file alternative toaccess_tokenfor OAuthtls.extra_root_certificates: extra PEM roots for all outbound TLS connectionsopenpgp.local_key_files: mounted public key files or keyringsopenpgp.local_key_directories: directories scanned for public key filesopenpgp.encryption_mode:pgp_mime_bodyorfull_messageopenpgp.unencrypted_recipients: exact envelope addresses allowed to bypass OpenPGPlogging.log_recipient_addresses: allow recipient details in debug-level logs
OpenPGP encryption remains the default. To relay a message without OpenPGP for an exact SMTP envelope recipient, add an explicit exception:
openpgp:
unencrypted_recipients:
- address: archive@example.com
allow_split_delivery: falseAddresses must be bare ASCII local@domain values. Matching is
case-insensitive across the full address and does not apply wildcard, domain,
or plus-address canonicalization. Invalid or duplicate normalized entries stop
configuration loading. At least one OpenPGP key source is still required.
If every envelope recipient matches the list, the listener-normalized original
message is relayed without an OpenPGP wrapper. Inbound authentication, allowed
networks, remote SMTP authentication, and mandatory TLS remain enforced. The
message is plaintext at the message layer, however, and downstream delivery
after the configured SMTP relay is outside sendmail-sec's control. The raw
message headers are not rewritten, including any submitted Bcc header.
A message containing both encrypted and unencrypted recipients is rejected
with SMTP status 554 unless every matching exception sets
allow_split_delivery: true. An allowed split creates two outbound SMTP
transactions: the encrypted group is delivered first, followed by the
unencrypted group. Both deliveries are prepared before either is sent. The two
remote transactions cannot be atomic; if the second fails after the first is
accepted, the client receives 554, and retrying can duplicate the accepted
encrypted delivery.
Unencrypted-delivery warnings and partial-delivery errors include counts but not recipient values. To make normalized recipient details available for troubleshooting, both enable debug logging and explicitly opt in:
logging:
filter: debug
log_recipient_addresses: trueThe recipient logging flag also governs OpenPGP lookup and remote RCPT TO
details. Recipient addresses and address-bearing key lookup URLs are never
included in higher-level logs.
Credential values can remain inline for backward compatibility, or be read from files mounted by Docker Compose or another secret manager. Configure exactly one member of each applicable pair:
listen.auth.passwordorlisten.auth.password_fileremote_smtp.auth.passwordorremote_smtp.auth.password_fileforplainremote_smtp.auth.access_tokenorremote_smtp.auth.access_token_fileforoauthbearerandxoauth2
Relative secret paths are resolved against the directory containing the configuration file. Secret files must be regular UTF-8 files no larger than 64 KiB. One final LF or CRLF is removed so files produced by common line-oriented tools work as expected; all other content and whitespace is preserved. Missing, unreadable, invalid, oversized, or empty secret files stop configuration loading without logging the secret value.
Secrets are loaded once during startup, including --check-config. Restart the
process or recreate the container after rotating a secret.
Build the image:
docker build -t sendmail-sec .Build a multi-arch image with Buildx:
docker buildx build \
--platform linux/amd64,linux/arm64,linux/riscv64 \
-t sendmail-sec .Build a release-style Docker image tarball for one architecture:
scripts/build-docker-image-artifact.sh linux/amd64 amd64 /tmp/sendmail-sec-image
docker load --input /tmp/sendmail-sec-image/sendmail-sec-alpine-musl-amd64.tar
scripts/verify-docker-image.sh sendmail-sec:alpine-musl-amd64 amd64The Dockerfile builds amd64 and arm64 natively on their matching runners.
It cross-compiles riscv64 on the Buildx host with a pinned
cargo-zigbuild/Zig tool image, so the RISC-V build does not execute compiler
processes through QEMU. The final Alpine image is assembled without
target-architecture RUN instructions.
Run with a read-only root filesystem and no Linux capabilities:
docker run --rm \
--read-only \
--cap-drop=ALL \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 2525:2525 \
-v /path/to/sendmail-sec.yaml:/config/sendmail-sec.yaml:ro \
-v /path/to/public-keys:/config/keys:ro \
sendmail-sec \
--config /config/sendmail-sec.yamlUse the checked-in Docker Compose example with host secret files that are kept outside the repository:
install -d -m 0700 "$HOME/.local/share/sendmail-sec/secrets"
install -m 0444 /path/to/listen-password \
"$HOME/.local/share/sendmail-sec/secrets/listen-password"
install -m 0444 /path/to/remote-password \
"$HOME/.local/share/sendmail-sec/secrets/remote-password"
export SENDMAIL_SEC_LISTEN_PASSWORD_FILE="$HOME/.local/share/sendmail-sec/secrets/listen-password"
export SENDMAIL_SEC_REMOTE_PASSWORD_FILE="$HOME/.local/share/sendmail-sec/secrets/remote-password"
docker compose -f examples/compose.yaml up -dThe example grants only the required runtime secrets to the service. Compose
mounts them as read-only files named /run/secrets/listen_auth_password and
/run/secrets/remote_smtp_password. To use OAuth instead, select
oauthbearer or xoauth2, configure access_token_file, and grant a
corresponding token secret.
For Compose secrets sourced with file:, Docker Compose uses bind mounts and
does not apply the service secret's uid, gid, or mode settings. Protect
the source directory on the rootless Docker daemon host with mode 0700, and
make each secret file readable by the image's non-root app:app user (mode
0444 in the example). The exported paths must be visible to that daemon.
When Docker is accessed from a Dev Container or another Docker-outside-of-
Docker setup, both the Compose configuration and secret source paths are
daemon-side bind mounts; run Compose on the daemon host or use paths that exist
identically from the daemon's point of view. See Docker's
Compose secrets guide
and service secrets reference.
Run the Docker integration test:
scripts/docker-integration-test.shWrapper TLS is the default. Exercise the explicit STARTTLS path with:
REMOTE_TLS_MODE=starttls scripts/docker-integration-test.shThe integration test builds a temporary image, generates temporary OpenPGP and TLS material, copies test files into containers with docker cp, verifies encrypted, unencrypted, rejected-mixed, split, and partial-delivery SMTP behavior through either wrapper TLS or STARTTLS, and removes the containers, network, image tag, and temporary files before exiting.
- The process does not require write access beyond optional
/tmp. - Logs are written to stdout/stderr.
- Local key files are loaded at startup. Restart the container after changing mounted key material.
- Credential files are loaded at startup. Restart the container after rotating a mounted secret.
- The Docker image contains a static musl binary for
amd64,arm64, andriscv64. - The Rust crate entry point now lives under
sources/rather thansrc/.
- Docker releases publish to
ghcr.io/oxibelt/sendmail-secfrom the canonicalOxiBelt/sendmail-secrepository. - Strict tags are required:
2.1.2,1.1.0-beta.1, or1.1.0-build.4f43abcd.v-prefixed tags and unsupported prerelease names are rejected. - Stable releases publish
latest,<major>-alpine-musl, and per-architecture<major>-alpine-musl-<arch>aliases. Beta and build releases publish only versioned tags. - Release CI validates the tag, updates Cargo release metadata in the CI worktree, builds and structurally verifies per-architecture Docker image tar artifacts, runs an isolated QEMU smoke test against the completed
riscv64image, runs Trivy image scans, and only then pushes arch tags and multi-arch manifests. - The Docker workflow publishes Alpine musl images for
linux/amd64,linux/arm64, andlinux/riscv64.
Run DevOps validation locally after changing release automation:
pnpm install --frozen-lockfile
pnpm run lint
pnpm run typecheck
pnpm run test
pnpm run versioning:check