Source on GitHub ·
Image on Docker Hub (s4l3h1/mailer,
amd64 and arm64)
A send-only SMTP relay for your own applications that DKIM-signs everything it sends and is built to reach the inbox, not the spam folder.
Your apps hand it mail over authenticated SMTP on port 587. It signs each message, queues it on disk and delivers it directly to each recipient's mail server from your own IP — retrying temporary failures, suppressing addresses that hard-bounce and pacing itself so big providers don't throttle you.
Use it when you want transactional mail (sign-ups, password resets, alerts, receipts) to come from your own domain and server instead of a paid sending service.
FROM scratch image, about 30 MB of RAM,
read-only root filesystem, no capabilities, no shell.docker and make.make doctor verifies SPF, DKIM, DMARC, reverse
DNS, port 25 and blocklists before you send anything.It does not receive mail: no MX, no mailboxes, no IMAP. See what send-only costs you.
You need a Linux server with a public IPv4 address, Docker (with the compose
plugin), make, git, and a domain whose DNS you control.
Two things will stop this working, and neither can be fixed in software. Check them before you set anything up:
Everything below runs the published image from Docker Hub — nothing is compiled on your server.
1. Get the files
git clone https://github.com/salehi/mailer.git
cd mailer
Only the Makefile, docker-compose.yml and .env.example are used at runtime.
2. Generate keys, config and DNS records
make setup DOMAIN=example.com IP=203.0.113.10
This creates .env (your configuration), a DKIM private key and TLS certificates
under data/, and zone.txt with the DNS records to publish. It is safe to run
again; it never overwrites an existing key.
3. Publish DNS
Add every record in zone.txt at your DNS provider: the A record for
mail.example.com, SPF, DKIM and DMARC. The DKIM key is long; zone.txt gives it
both split into strings and as one line, so use whichever your provider's form
accepts. Details: docs/DNS.md.
4. Set reverse DNS
At your VPS provider, set the PTR record for your IP to mail.example.com (the
MAILER_HELO_HOST in .env).
5. Create a login for each application
make user-add USER=app1 # prints the password once — copy it now
6. Check everything
make doctor
Every check must pass before you send real mail. Sending with broken authentication damages a new IP's reputation fast, and that is hard to undo. DNS changes can take a while to propagate; re-run until it is green.
7. Start it
make up
make logs
The service restarts automatically with Docker (restart: unless-stopped).
| Setting | Value |
|---|---|
| Host | mail.example.com |
| Port | 587 |
| Security | STARTTLS (required) |
| Auth | PLAIN — username app1, password from make user-add |
| From | any address @example.com |
The server uses a self-signed certificate. Give your application
data/tls/ca.crt as a trusted CA, or disable certificate verification if both
ends are yours. This only protects the hop from your app to the relay; mail leaving
the relay uses the recipient's certificate.
For example, in Python:
import smtplib, ssl
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "[email protected]"
msg["To"] = "[email protected]"
msg["Subject"] = "Hello"
msg.set_content("It works.")
ctx = ssl.create_default_context(cafile="ca.crt")
with smtplib.SMTP("mail.example.com", 587) as s:
s.starttls(context=ctx)
s.login("app1", "the-password")
s.send_message(msg)
Two rules are enforced:
From: header must be at your configured domain; anything else is refused
with 550. This keeps DMARC aligned and stops the relay being abused.[email protected], so SPF passes.Once it is running, send a message to each of these, in order:
[email protected] — replies with a report. Expect
SPF: pass, DKIM: pass, DMARC: pass.mailed-by and signed-by must show your domain.Warm up a new IP. A fresh IP has no reputation; going from zero to thousands
of messages a day looks like a hacked server. Cap the rate in .env and raise it
over a couple of weeks — docs/WARMUP.md has a schedule.
MAILER_MESSAGES_PER_HOUR=10 # 0 = unlimited
Then make restart to apply changes to .env.
Firewall. Inbound, expose only port 587, and ideally only to your applications' addresses. Outbound, allow port 25 and DNS.
Manage logins without restarting — changes apply on the next login:
make list-user # who can send, and as which domain
make set-password USER=app1 # rotate; prints the new password once
make del-user USER=app1 # revoke
Passwords are stored only as bcrypt hashes, so a lost one cannot be recovered — set a new one.
Monitor reputation. Register your domain with
Google Postmaster Tools and Microsoft
SNDS. Both verify by DNS
TXT record, so no inbox is needed. Re-run make doctor now and then; it also
checks blocklists.
Read the logs. make logs shows structured JSON (set
MAILER_LOG_FORMAT=text for a human-friendly format). Each accepted message gets a
queue id; grep for it to follow one message from submission to delivery.
Bulk or marketing mail. Gmail and Yahoo require one-click unsubscribe above
5,000 messages a day. Set MAILER_UNSUBSCRIBE and MAILER_UNSUBSCRIBE_BASE_URL
in .env, and add the listener port (8080 by default) to ports: in
docker-compose.yml. Purely transactional mail doesn't need it.
Back up data/, .env and zone.txt. At minimum keep data/dkim/ — losing
the DKIM key means generating a new one and republishing DNS.
In data/ | What it is |
|---|---|
dkim/ | DKIM private key |
tls/ | submission certificates (regenerable, but apps must re-trust them) |
users | SMTP logins |
queue.db, spool/ | mail not yet delivered |
Upgrade:
git pull # picks up Makefile/compose changes
make pull # fetch the latest image
make restart
Images are tagged latest (the main branch) and sha-<commit>. To pin a
version, set image: in docker-compose.yml and APP_IMAGE for the make
commands to the same tag.
make setup DOMAIN=… [IP=…] | Create .env, the DKIM key, TLS material and zone.txt. Never overwrites a key. |
make zone | Re-render zone.txt after editing .env. |
make doctor | Check SPF, DKIM, DMARC, forward and reverse DNS, port 25 and blocklists. |
make user-add USER=app1 [PASS=…] | Add a login. Prints the password once. |
make list-user | List the logins and what each may send as. |
make set-password USER=app1 [PASS=…] | Replace a login's password. |
make del-user USER=app1 | Remove a login. |
make up / down / restart | Start, stop, or recreate the service. |
make logs / ps | Follow logs, show status. |
make pull | Fetch the latest published image. |
make help | Every target. |
All settings live in .env, created by make setup. .env.example
documents each one. The ones you are most likely to touch:
| Setting | Default | Purpose |
|---|---|---|
MAILER_DOMAIN | — | The domain you send as. |
MAILER_HELO_HOST | mail.<domain> | Server hostname; must match your PTR record. |
MAILER_PUBLIC_IP | — | Your IP, used for SPF and checks. |
MAILER_MESSAGES_PER_HOUR | 0 | Global rate cap for warm-up. 0 = unlimited. |
MAILER_MAX_RECIPIENTS | 100 | Recipients per message. |
MAILER_MAX_MESSAGE_BYTES | 25 MiB | Maximum message size. |
MAILER_MAX_QUEUE_AGE | 120h | How long to keep retrying before giving up. |
MAILER_UNSUBSCRIBE | off | One-click unsubscribe for bulk mail. |
MAILER_LOG_FORMAT | json | json or text. |
Logins are kept separately in data/users so they never appear in the environment,
where docker inspect could read them.
your apps the internet
│ ▲
│ SMTP :587 │ SMTP :25
│ STARTTLS + AUTH PLAIN │ STARTTLS
▼ │
┌──────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ submission │─────▶│ queue │─────▶│ delivery │
│ │ │ │ │ │
│ • auth │ │ spool/*.eml │ │ • MX lookup │
│ • From lock │ │ ← bodies │ │ • group by domain │
│ • normalize │ │ queue.db (bbolt) │ │ • per-domain limit │
│ • DKIM sign │ │ ← state+index │ │ • 4xx retry │
└──────────────┘ └──────────────────┘ │ • 5xx → suppress │
└────────────────────┘
Messages are signed once, at submission, and queued durably on disk, so a restart
loses nothing. Delivery is at-least-once: after a crash at the wrong moment a
message may be sent twice, but it is never lost. Temporary failures (4xx) are
retried with exponential backoff; permanent ones (5xx) put the address on a
suppression list so it is never mailed again.
The server handles the parts that are code. The rest is DNS and infrastructure,
which is why make doctor exists.
| Handled by | |
|---|---|
DKIM signature, aligned with From: | this server |
| RFC-correct headers, CRLF, transfer encoding | this server |
| Opportunistic TLS to recipient servers | this server |
No open relay (auth mandatory, From: pinned) | this server |
| Suppression of hard-bounced addresses | this server |
| Connection limits and warm-up rate control | this server |
| EHLO name matching your PTR | this server + your VPS provider |
| SPF and DMARC records | you, from zone.txt |
| PTR / reverse DNS | you, at your VPS provider |
| Clean IP reputation | you, over time — see docs/WARMUP.md |
Since 2024–2025, Google, Yahoo and Microsoft require SPF and DKIM and aligned DMARC from bulk senders, and reject failures outright rather than filing them as spam.
Some delivery failures are reported during the SMTP conversation
(550 no such user). Those the relay sees, and the address is suppressed
automatically. Gmail, Yahoo and Outlook all work this way, so the common case is
covered.
Other servers — corporate Exchange especially — accept the message and later mail a bounce notice back. Receiving that would need a mailbox, which this release deliberately doesn't run, so those bounces go unseen.
So in the logs, sent means accepted by the receiving server, not read by a
person.
Content type
Image
Digest
sha256:dc95dbc70…
Size
3 MB
Last updated
6 days ago
docker pull s4l3h1/mailer