Files
jcoffey-dev d8c84aae70 Document installing from the registry
Covers the published amd64/arm64 image with Compose and with plain
docker run, when to build your own instead, and fetching
egress-lockdown.sh on its own.
2026-09-24 12:49:10 -07:00

4.3 KiB

mailsink

An SMTP server for test and development networks. Applications on other machines send mail to it as they would to a real server. It accepts mail for the domains you allow and discards it.

  • Nothing is kept. Message bodies are read off the socket and discarded as they arrive. They are never held in memory as a whole or written to disk, and the container's filesystem is read-only.
  • Nothing is relayed. The server contains no code that opens an outbound connection, and the runtime image has only its one static binary: no MTA, no shell, no network tools. Mail to a domain outside the allowlist gets 550 5.7.1 at RCPT TO.
  • Only local clients. Connections from addresses outside ALLOWED_NETWORKS get 554 and are closed.
  • No logging. The server writes nothing while it runs, and the compose file sets Docker's logging driver to none.

Configure

All settings are build arguments at the top of the Dockerfile, with comments explaining each one. They are compiled into the binary, so the running container reads no environment variables or config files. To change a setting, edit the Dockerfile and rebuild. A bad value fails the build.

Setting Default
ALLOWED_DOMAINS example.test,*.example.test (* accepts any domain)
ALLOWED_NETWORKS the RFC 1918 ranges and fc00::/7
SMTP_HOSTNAME mailsink.test
SMTP_PORT 25
MAX_MESSAGE_SIZE 26214400 (25 MiB)
MAX_RECIPIENTS 100
MAX_CONNECTIONS 100

Install

From the registry

Images for amd64 and arm64 are published at registry.coffeylabs.org/jcoffey-dev/mailsink. No login is needed to pull them. The tag latest follows main, and each build is also tagged with its short commit hash.

The published image uses the default settings above. If those fit your test network, use it as it is. Otherwise build your own (see below), since the settings are compiled in and can't be changed when the container starts.

With Compose, take compose.yaml from this repository and replace the build: . and image: lines with:

    image: registry.coffeylabs.org/jcoffey-dev/mailsink:latest

Then:

docker compose up -d
sudo ./egress-lockdown.sh      # optional second layer; see below

Without Compose:

docker network create -o com.docker.network.bridge.name=br-mailsink mailsink
docker run -d --name mailsink --restart unless-stopped \
  --network mailsink -p 25:25 \
  --read-only --cap-drop ALL --security-opt no-new-privileges:true \
  --log-driver none \
  registry.coffeylabs.org/jcoffey-dev/mailsink:latest

egress-lockdown.sh finds the container by its bridge name, br-mailsink, so keep that name if you use the script. To fetch just the script:

curl -fsSLO https://git.coffeylabs.org/jcoffey-dev/mailsink/raw/branch/main/egress-lockdown.sh
chmod +x egress-lockdown.sh

Build your own

Clone the repository, change the settings at the top of the Dockerfile, then:

docker compose up -d --build
sudo ./egress-lockdown.sh      # optional second layer; see below

The Dockerfile cross-compiles, so one build covers amd64 and arm64 without emulation:

docker buildx build --platform linux/amd64,linux/arm64 -t mailsink .

Use

Point applications at the Docker host on port 25. Any username and password are accepted, so apps configured for authenticated SMTP work unchanged. There is no TLS: clients must allow a plaintext connection.

ALLOWED_NETWORKS is checked against the client's source address. Clients on the network reach the container through Docker's NAT, which keeps their real address. A connection from the Docker host itself goes through Docker's userland proxy, so it appears to come from the bridge gateway (in 172.16.0.0/12). If you narrow ALLOWED_NETWORKS, tests run on the host itself will be refused.

Blocking egress at the firewall

Docker can't publish a port from an internal network, so the container sits on a normal bridge (br-mailsink). egress-lockdown.sh adds iptables rules that drop every new connection starting from that bridge, both out to the network and to the Docker host itself. Inbound SMTP and its replies still pass. Run it as root after the container is up, and again after a reboot. --remove takes the rules out. The script assumes Docker's iptables firewall backend.

License

GPL-3.0-or-later. See LICENSE.