Saturday, 8 August 2026

Sending emails with Maddy and Django

Self-hosting transactional email: maddy + Django

How we set up outbound-only email for myhostname.com, the mistakes that broke it, and how to reproduce this on a new server.

Why maddy

We needed a way for the Django app to send transactional email (notifications, password resets, etc.) from myhostname.com. The options were: pay for a transactional email provider (Postmark, SES, ...), stand up Postfix + Dovecot + OpenDKIM (three separate daemons, three separate config languages), or use maddy — a single static Go binary that does SMTP submission, outbound delivery, DKIM signing, SPF/DMARC checking, and IMAP, all from one scfg-format config file.

For a "the app on this box sends its own mail" use case, maddy is a lot less to operate than Postfix+Dovecot+OpenDKIM, and there's no per-message cost or vendor lock-in like a SaaS provider. The tradeoff is you own deliverability (IP reputation, DNS records, renewal automation) yourself — this doc covers all of that.

The setup we ended up with

  • Reuses the existing Let's Encrypt cert for myhostname.com (the same one nginx serves the web app with), via a renewal deploy hook — see below.

Installation

maddy ships as a single static binary with no package repo to add. Grab the latest release, extract, and install like any other systemd-managed daemon:

# on the target server
apt-get install -y zstd   # release tarball is .tar.zst

cd /tmp
curl -sL -o maddy.tar.zst \
  "https://github.com/foxcpp/maddy/releases/download/v0.9.5/maddy-0.9.5-x86_64-linux-musl.tar.zst"
tar --zstd -xf maddy.tar.zst
cd maddy-0.9.5-x86_64-linux-musl

groupadd -r maddy
useradd -r -g maddy -d /var/lib/maddy -s /usr/sbin/nologin maddy

install -m755 maddy /usr/local/bin/maddy
ln -sf /usr/local/bin/maddy /usr/local/bin/maddyctl

mkdir -p /etc/maddy /var/lib/maddy /run/maddy
chown maddy:maddy /var/lib/maddy /run/maddy

cp systemd/maddy.service /etc/systemd/system/maddy.service
systemctl daemon-reload

The bundled systemd/maddy.service is worth reading — it's a genuinely well-hardened unit (ProtectSystem=strict, NoNewPrivileges, RestrictAddressFamilies, capability-scoped CAP_NET_BIND_SERVICE instead of running as root, etc.). Use it as-is rather than writing your own.

The mistake that started this whole exercise

The very first install on this domain was done with:

sudo /home/dv/.local/bin/uvx --with certbot-dns-cloudflare certbot certonly ...

uvx runs a tool from a throwaway, ephemeral virtualenv — nothing persists on disk as an installed package, so nothing was ever there for a cron job or systemd timer to invoke later. The certificate silently expired months later with zero renewal automation in place, because there was no certbot binary to schedule in the first place. Lesson: if something needs to run again unattended (a renewal, a cron job), install it properly (uv pip install --python /some/venv/bin/python ... into a persistent venv, or a real package) — don't reach for uvx/npx-style ephemeral runners for anything long-lived.

We fixed that earlier by creating a proper persistent venv at /opt/certbot/venv and a certbot-renew.timer (see the certbot section of this repo's ops history) — and reused that same persistent-install lesson here for maddy itself.

Config

maddy's config format (scfg) reads like a pipeline: define storage/auth backends, then wire up listeners (smtp, submission, imap) that route messages through a msgpipeline. The default "small-scale deployment" config that ships with every release is a solid starting point — we changed three things from it:

1. Everything bound to loopback, not 0.0.0.0:

smtp tcp://127.0.0.1:25 { ... }
submission tls://127.0.0.1:465 tcp://127.0.0.1:587 { ... }
imap tls://127.0.0.1:993 tcp://127.0.0.1:143 { ... }

Since the only client is the Django app on the same box, there's no reason to expose any of this. Less surface area, no firewall rules needed, no risk of becoming an open relay.

2. Base variables point at the real domain and cert paths:

$(hostname) = myhostname.com
$(primary_domain) = myhostname.com
$(local_domains) = $(primary_domain)

tls file /etc/maddy/certs/$(hostname)/fullchain.pem /etc/maddy/certs/$(hostname)/privkey.pem

3. target.remote binds outbound connections to this server's IP:

target.remote outbound_delivery {
    local_ip 82.146.36.81
    ...
}

Useful if the box has multiple IPs and you want outbound mail to consistently originate from the one with the clean rDNS/reputation.

Everything else — the auth.pass_table + storage.imapsql local backends, the msgpipeline routing, the DKIM-signing modify block on the outbound path, DMARC/SPF checking on the inbound path — is unchanged from the stock config. Full file: scripts/maddy.conf in this repo.

Why certs live in /etc/maddy/certs/, not /etc/letsencrypt/live/ directly

maddy's systemd unit runs it as an unprivileged maddy user under ProtectSystem=strict. Let's Encrypt's privkey.pem is normally root-only (mode 600, root:root) — maddy can't read it directly, and loosening permissions on the live LE directory is a bad idea since other things (nginx) may depend on it staying that way.

The fix is a certbot renewal deploy hook that copies the cert to a maddy-owned location and restarts the service whenever it renews:

# /etc/letsencrypt/renewal-hooks/deploy/maddy-cert.sh
#!/bin/sh
if [ "$RENEWED_LINEAGE" = "/etc/letsencrypt/live/myhostname.com" ]; then
    mkdir -p /etc/maddy/certs/myhostname.com
    cp "$RENEWED_LINEAGE/fullchain.pem" /etc/maddy/certs/myhostname.com/fullchain.pem
    cp "$RENEWED_LINEAGE/privkey.pem" /etc/maddy/certs/myhostname.com/privkey.pem
    chown maddy:maddy /etc/maddy/certs/myhostname.com/*.pem
    chmod 640 /etc/maddy/certs/myhostname.com/*.pem
    systemctl restart maddy 2>/dev/null || true
fi

Deploy hooks only fire on renewal, so you also need to seed the cert once by hand before the first systemctl start maddy (just run the cp/chown/chmod lines manually).

DKIM, SPF, DMARC, MX

maddy generates its own DKIM keypair the first time the dkim modifier runs (which, in the stock config, is at config-load time, not lazily on first send) — no need to pre-generate one with openssl:

$ systemctl start maddy
$ journalctl -u maddy | grep dkim
modify.dkim: generating a new rsa2048 keypair...
modify.dkim: generated a new rsa2048 keypair, private key is in dkim_keys/myhostname.com_default.key,
TXT record with public key is in dkim_keys/myhostname.com_default.dns,
put its contents into TXT record for default._domainkey.myhostname.com to make signing and verification work

$ cat /var/lib/maddy/dkim_keys/myhostname.com_default.dns
v=DKIM1; k=rsa; p=MIIBIjANBgkq...

That file's contents are exactly the value to publish. We added four records total, all scoped to the myhostname.com subdomain (never touching the apex appkraft.ru zone, which already has its own MX/SPF for Yandex):

type name value
MX myhostname.com myhostname.com (priority 10)
TXT myhostname.com v=spf1 ip4:82.146.36.81 -all
TXT default._domainkey.myhostname.com (the maddy-generated DKIM value above)
TXT _dmarc.myhostname.com v=DMARC1; p=quarantine; rua=mailto:postmaster@myhostname.com; adkim=s; aspf=s

The MX record matters even though we don't expose inbound port 25 to the internet: some receiving/verifying mail servers do an MX-presence check on the sender's domain as a basic anti-spoofing signal (maddy's own inbound check { require_mx_record ... } does exactly this to other domains), so it's worth having even in a send-only setup. Bounces addressed back to us just won't be deliverable over SMTP — acceptable for a low-volume transactional sender, worth revisiting if you ever need real bounce handling.

We used Cloudflare's API directly (the same API token already on the box for certbot-dns-cloudflare, scoped to dns_records:edit + zone:read) rather than the dashboard, so the whole DNS setup was scriptable and auditable:

curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  --data '{"type":"TXT","name":"myhostname.com","content":"v=spf1 ip4:82.146.36.81 -all","ttl":3600}'

Accounts

# credentials the app authenticates with over submission
printf '%s\n%s\n' "$PASSWORD" "$PASSWORD" | maddy creds password noreply@myhostname.com
maddy imap-acct create noreply@myhostname.com

# postmaster@ is required by RFC 5321 §4.5.1 for abuse/bounce contact,
# and our config routes DSNs there — give it a real mailbox too
maddy imap-acct create postmaster@myhostname.com

Gotcha: maddy creds create -p PASSWORD / maddy creds password -p PASSWORD looked like they should set the password non-interactively via the flag, but over a non-TTY SSH session the -p value wasn't reliably honored — the account got created with an unpredictable password despite the flag being passed. The reliable method is piping the password twice via stdin (once for entry, once for confirmation), matching the default stdin-read behavior the --help text describes:

printf '%s\n%s\n' "$PASS" "$PASS" | maddy creds password noreply@myhostname.com

Always verify with an actual authenticated login attempt afterward — don't trust the CLI's exit code alone.

The Django side

Settings (proj/settings.py), following the project's existing decouple.config() pattern so the real password lives in .env / proj/local.py, never in the repo:

EMAIL_BACKEND       = config('EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend')
EMAIL_HOST          = config('EMAIL_HOST', default='myhostname.com')
EMAIL_PORT          = config('EMAIL_PORT', default=587, cast=int)
EMAIL_USE_TLS       = config('EMAIL_USE_TLS', default=True, cast=bool)
EMAIL_HOST_USER     = config('EMAIL_HOST_USER', default='noreply@myhostname.com')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
DEFAULT_FROM_EMAIL  = config('DEFAULT_FROM_EMAIL', default='noreply@myhostname.com')

A thin wrapper (api/mail.py), matching the existing push_to_user() helper's best-effort style (logs and returns False instead of raising, so a mail hiccup never breaks the calling view):

import logging

from django.conf import settings
from django.core.mail import send_mail

log = logging.getLogger(__name__)


def send_email_to_user(user, *, subject: str, message: str, html_message: str = None) -> bool:
    if not user.email:
        return False
    try:
        send_mail(
            subject=subject,
            message=message,
            from_email=settings.DEFAULT_FROM_EMAIL,
            recipient_list=[user.email],
            html_message=html_message,
        )
        return True
    except Exception as e:
        log.warning('send_email failed for %s: %s', user.email, e)
        return False

Usage, same pattern as the existing push-notification call sites:

from api.mail import send_email_to_user

send_email_to_user(
    task.assignee,
    subject='New task',
    message=f'{task.title} — #{task.work_order.number}',
)

For local development, set EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend in .env — emails print to the terminal instead of trying (and failing, since 587 isn't reachable from outside the server) to actually send.

The gotcha: EMAIL_HOST=127.0.0.1 breaks TLS certificate verification

The first working version used EMAIL_HOST = '127.0.0.1' — after all, that's what maddy listens on. It connects fine, but STARTTLS fails:

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
IP address mismatch, certificate is not valid for '127.0.0.1'. (_ssl.c:1123)

This is correct behavior, not a bug: smtplib.starttls() with a default ssl.create_default_context() validates the server's certificate hostname against whatever host you told SMTP() to connect to — exactly like a browser checks a website's cert against the URL's hostname. The cert is issued for myhostname.com; an IP literal can never match a domain-name certificate, no matter which IP it is.

The fix is not to disable certificate verification (never do that for anything, even loopback traffic — it silently reintroduces MITM risk and masks future misconfigurations). Instead, make the client connect using the same hostname the cert was issued for, and have that hostname resolve to loopback on this specific box:

# /etc/hosts, appended (only on the box that runs both Django and maddy)
127.0.0.1    myhostname.com
EMAIL_HOST = config('EMAIL_HOST', default='myhostname.com')  # not '127.0.0.1'

Now smtplib resolves myhostname.com to 127.0.0.1 locally (fast, no real network hop) and the TLS handshake's hostname check passes because the connection hostname and the certificate's CN genuinely match. This has a nice side effect too: anywhere without that /etc/hosts override (your laptop, CI), myhostname.com resolves to the real public IP, where port 587 isn't exposed — so it just times out instead of doing anything insecure. Fails closed by construction, no extra code needed.

Testing checklist

Useful commands when standing this up on a new server, in order:

# 1. config loads, listeners bind
systemctl status maddy
journalctl -u maddy -n 50

# 2. DNS actually propagated
dig +short MX myhostname.com
dig +short TXT myhostname.com
dig +short TXT default._domainkey.myhostname.com
dig +short TXT _dmarc.myhostname.com

# 3. cert verification succeeds (this is the /etc/hosts trick being tested)
python3 -c "
import smtplib, ssl
s = smtplib.SMTP('myhostname.com', 587, timeout=10)
s.starttls(context=ssl.create_default_context())
print('OK')
"

# 4. full authenticated round trip, local delivery
python3 -c "
import smtplib, ssl
from email.mime.text import MIMEText
msg = MIMEText('test'); msg['Subject']='test'
msg['From']='noreply@myhostname.com'; msg['To']='postmaster@myhostname.com'
s = smtplib.SMTP('myhostname.com', 587, timeout=10)
s.starttls(context=ssl.create_default_context())
s.login('noreply@myhostname.com', 'PASSWORD_HERE')
s.sendmail('noreply@myhostname.com', ['postmaster@myhostname.com'], msg.as_string())
s.quit()
"
maddy imap-msgs list postmaster@myhostname.com INBOX

# 5. outbound path + DKIM signing engage (send to a domain that will
#    fast-bounce, e.g. example.com, and confirm a DSN comes back locally)
#    ... send to test-recipient@example.com, then:
maddy imap-msgs list noreply@myhostname.com INBOX   # should show the bounce

Step 5 is the only reliable way to exercise the DKIM-signing code path locally — the stock config only signs mail on the outbound route (default_destination { modify { dkim ... } }), not on purely-local delivery between two accounts on the same server. example.com is RFC 2606-reserved and correctly declares "null MX" (RFC 7505), so it fails fast and predictably without spamming anyone real.

What's still outstanding — real-world deliverability

Everything above gets mail sent correctly with valid SPF/DKIM/DMARC. Whether it lands in an inbox instead of spam depends on factors DNS records alone don't cover:

  • Before relying on this for real traffic: send a message through mail-tester.com (or any real personal inbox you control) and check it actually lands in the inbox, not spam.