Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

smtp

Self-hosted mail server. Sends from your code, receives over IMAP. Postfix

  • Dovecot + Rspamd in a single Docker container, ~200 MB of RAM idle.

Don't use this for cold outreach — your IP gets nuked the moment you try. Use it for the transactional stuff that shouldn't be costing you $20/month per project: contact-form replies, password resets, invoices.

What's in here

.
├── compose.yaml
├── mailserver.env
├── .env.example
├── send.py
└── templates/
    ├── default.html
    └── default.txt

Requirements

  • VPS with a static public IPv4 and root SSH.
  • Domain whose DNS you control. Cloudflare works, read the gotcha first.
  • Outbound port 25 unblocked. Many cheap VPS tiers block it. Test before doing anything else (see Pre-flight). No software workaround.
  • Ability to set rDNS / PTR for your IP, usually a checkbox in the VPS panel. Without this, Gmail rejects almost everything you send.
  • ~30 minutes for the first run.

Tested on Ubuntu 24.04. Anything systemd + Docker should work.

Pre-flight

From the VPS, before doing anything else:

nc -vz gmail-smtp-in.l.google.com 25   # outbound 25 (it must work)
df -h /                                # want >5 GB free
free -h                                # 1 GB is good

If port 25 is blocked: ask the provider to unblock, switch providers, or use a smarthost like Mailgun's relay. Pick one before continuing.

Setup

1. DNS

Replace example.com with your domain and 1.2.3.4 with the VPS IP.

Type Name Value Notes
A mail 1.2.3.4 If on Cloudflare: DNS only (grey cloud). The HTTP proxy doesn't speak SMTP.
MX @ mail.example.com priority 10 DNS only on Cloudflare.
TXT @ v=spf1 mx ~all
TXT _dmarc v=DMARC1; p=none; rua=mailto:postmaster@example.com Start p=none, tighten later.

DKIM comes after step 6 — the server generates the keypair.

2. rDNS

In your VPS panel, set the PTR for the IPv4 (and IPv6 if you have one) to mail.example.com. Single biggest factor for not landing in Gmail spam.

3. System

sudo hostnamectl set-hostname mail.example.com
echo "1.2.3.4 mail.example.com mail" | sudo tee -a /etc/hosts
curl -fsSL https://get.docker.com | sudo sh

If your distro uses cloud-init's manage_etc_hosts: true, also:

echo "manage_etc_hosts: false" | sudo tee /etc/cloud/cloud.cfg.d/99-disable-hosts-management.cfg

4. TLS

sudo apt install -y certbot
sudo certbot certonly --standalone -d mail.example.com \
  --agree-tos --email postmaster@example.com --key-type ecdsa

--standalone needs port 80 free. If nginx already runs on :80, use certbot --nginx -d mail.example.com — it won't touch your other sites.

Renewal is automatic via the certbot timer. Add a deploy hook so the mailserver picks up renewed certs without restarting:

sudo tee /etc/letsencrypt/renewal-hooks/deploy/mailserver.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
[[ "${RENEWED_LINEAGE:-}" == /etc/letsencrypt/live/mail.example.com ]] || exit 0
docker ps --format '{{.Names}}' | grep -qx mailserver || exit 0
docker exec mailserver postfix reload || true
docker exec mailserver dovecot reload || true
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mailserver.sh

5. Config + first boot

git clone <this-repo> smtp && cd smtp
cp .env.example .env
$EDITOR .env       # set MAIL_DOMAIN, MAIL_HOSTNAME, SMTP_* values
docker compose up -d

The container will complain there are no mailboxes and shut itself off after two minutes. Beat the timer:

docker exec -it mailserver setup email add no-reply@example.com
docker exec -it mailserver setup alias add postmaster@example.com no-reply@example.com

Container goes healthy once a mailbox exists.

6. DKIM

docker exec mailserver setup config dkim domain example.com keytype rsa keysize 2048 selector mail

It prints a TXT record like:

v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEF...QIDAQAB

Add it at mail._domainkey.example.com. Confirm:

dig +short mail._domainkey.example.com TXT @1.1.1.1

7. Send a test

python3 send.py you@gmail.com -s "first send"

In Gmail, open the message → "Show original". Want all three:

  • SPF: PASS
  • DKIM: PASS with d=example.com
  • DMARC: PASS

For a graded report, send to a one-time address from mail-tester.com. 9+/10 is the bar.

Sending mail

python3 send.py recipient@example.com
python3 send.py recipient@example.com -s "Welcome"
python3 send.py recipient@example.com --html my.html --text my.txt

send.py reads .env for SMTP creds and templates/default.{html,txt} for the body. There's no template engine — edit the files, or use --html / --text to point at different ones per call. Bring your own Jinja2 / mako / f-string layer if you need substitution.

From any other language:

import smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "no-reply@example.com"
msg["To"] = "user@gmail.com"
msg["Subject"] = "Welcome"
msg.set_content("plain text fallback")
msg.add_alternative("<h1>HTML</h1>", subtype="html")

with smtplib.SMTP("mail.example.com", 587) as s:
    s.starttls()
    s.login("no-reply@example.com", "yourpassword")
    s.send_message(msg)

STARTTLS on :587, login with the mailbox + password. Same in any language.

Receiving mail

Setting Value
Server mail.example.com
Port 993
Security SSL/TLS
Username full email address
Password what you set with setup email add

Works with Thunderbird, Apple Mail, K-9, anything.

From Python:

import imaplib

m = imaplib.IMAP4_SSL("mail.example.com", 993)
m.login("no-reply@example.com", "yourpassword")
m.select("INBOX")
typ, data = m.search(None, "UNSEEN")
for num in data[0].split():
    typ, msg = m.fetch(num, "(RFC822)")
    print(msg[0][1].decode("utf-8", errors="replace"))
m.logout()

Day-to-day

docker exec -it mailserver setup email add user@example.com
docker exec -it mailserver setup alias add admin@example.com user@example.com
docker exec mailserver setup email list
docker exec mailserver tail -f /var/log/mail/mail.log
docker compose pull && docker compose up -d

Troubleshooting

Mail goes to spam in Gmail. Open "Show original" — if SPF/DKIM/DMARC all pass, the IP is just new. No fix beyond consistent low-volume sending over weeks. Don't pay for a "warmup service".

Connection refused on :25. Either Cloudflare is proxying the mail A record (must be DNS-only), or the provider blocks outbound 25. Test from the VPS: nc -vz gmail-smtp-in.l.google.com 25.

Mail stuck in queue. docker exec mailserver postqueue -p shows what and why. Usually a recipient domain blocking your IP — run through mail-tester to see what they object to.

DKIM fails. Diff what setup config dkim printed against dig +short mail._domainkey.example.com TXT @1.1.1.1. Cloudflare splits long TXT into 255-byte chunks — fine, the resolver reassembles.

User doesn't exist listing mailboxes. The Maildir is created on first delivery, not on setup email add. Send one message and the directory appears.

Container restart-loops. docker logs mailserver | tail -50. Most common cause: no mailboxes exist; the container shuts down after 2 min. Run setup email add.

What this isn't

No bounce-handling API, no delivery webhooks, no per-recipient analytics. If you need that, Postmark or AWS SES.

Single VPS, single container. The box dies, your mail dies. Back up docker-data/dms/.

ClamAV is off by default to save RAM (ENABLE_CLAMAV=1 in mailserver.env to enable).

About

Self-hosted mail server you can clone, point a domain at, and use. Postfix + Dovecot + Rspamd in Docker, with a Python sender.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages