Sign inSign up

s4l3h1/mailer

By s4l3h1

•Updated 6 days ago

Send-only SMTP relay with DKIM, SPF and DMARC

Image
0

1.1K

s4l3h1/mailer repository overview

⁠mailer

CI Image version Image size Go version Last commit Open issues

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.

  • Small and locked down — a 7.5 MB FROM scratch image, about 30 MB of RAM, read-only root filesystem, no capabilities, no shell.
  • Nothing to install on the server except docker and make.
  • Checks your setup for you — make doctor verifies SPF, DKIM, DMARC, reverse DNS, port 25 and blocklists before you send anything.
  • One domain per instance in this release.

It does not receive mail: no MX, no mailboxes, no IMAP. See what send-only costs you⁠.


⁠Before you start

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:

  1. Outbound port 25 must be open. Most cloud providers (AWS, GCP, Azure, DigitalOcean, Hetzner, …) block it by default, silently — connections just time out. Ask your provider to lift the block.
  2. You must be able to set a PTR (reverse DNS) record for the server's IP. This is done in your VPS provider's control panel, not your DNS zone. Many receivers reject mail from an IP without one.

⁠Install and run

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).

⁠Connect your applications

SettingValue
Hostmail.example.com
Port587
SecuritySTARTTLS (required)
AuthPLAIN — username app1, password from make user-add
Fromany 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:

  • The From: header must be at your configured domain; anything else is refused with 550. This keeps DMARC aligned and stops the relay being abused.
  • The envelope sender is always rewritten to [email protected], so SPF passes.

⁠Confirm your mail is trusted

Once it is running, send a message to each of these, in order:

  1. [email protected] — replies with a report. Expect SPF: pass, DKIM: pass, DMARC: pass.
  2. A fresh mail-tester.com⁠ address — aim for 10/10.
  3. A real Gmail account — open Show original. SPF, DKIM and DMARC must say PASS, and both mailed-by and signed-by must show your domain.

⁠Running in production

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)
usersSMTP 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.

⁠Commands

make setup DOMAIN=… [IP=…]Create .env, the DKIM key, TLS material and zone.txt. Never overwrites a key.
make zoneRe-render zone.txt after editing .env.
make doctorCheck 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-userList the logins and what each may send as.
make set-password USER=app1 [PASS=…]Replace a login's password.
make del-user USER=app1Remove a login.
make up / down / restartStart, stop, or recreate the service.
make logs / psFollow logs, show status.
make pullFetch the latest published image.
make helpEvery target.

⁠Configuration

All settings live in .env, created by make setup. .env.example⁠ documents each one. The ones you are most likely to touch:

SettingDefaultPurpose
MAILER_DOMAIN—The domain you send as.
MAILER_HELO_HOSTmail.<domain>Server hostname; must match your PTR record.
MAILER_PUBLIC_IP—Your IP, used for SPF and checks.
MAILER_MESSAGES_PER_HOUR0Global rate cap for warm-up. 0 = unlimited.
MAILER_MAX_RECIPIENTS100Recipients per message.
MAILER_MAX_MESSAGE_BYTES25 MiBMaximum message size.
MAILER_MAX_QUEUE_AGE120hHow long to keep retrying before giving up.
MAILER_UNSUBSCRIBEoffOne-click unsubscribe for bulk mail.
MAILER_LOG_FORMATjsonjson or text.

Logins are kept separately in data/users so they never appear in the environment, where docker inspect could read them.

⁠How it works

  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.

⁠What "not spam" actually requires

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 encodingthis server
Opportunistic TLS to recipient serversthis server
No open relay (auth mandatory, From: pinned)this server
Suppression of hard-bounced addressesthis server
Connection limits and warm-up rate controlthis server
EHLO name matching your PTRthis server + your VPS provider
SPF and DMARC recordsyou, from zone.txt
PTR / reverse DNSyou, at your VPS provider
Clean IP reputationyou, 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.

⁠What send-only costs you

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.

⁠Documentation

Tag summary

Content type

Image

Digest

sha256:dc95dbc70…

Size

3 MB

Last updated

6 days ago

docker pull s4l3h1/mailer