A self-hosted transactional mail platform - a single Go binary that hosts
multiple sending domains, issues per-app SMTP credentials, DKIM-signs and delivers
mail directly to destination MXes, and optionally receives inbound mail per-mailbox
and forwards it to a webhook. A React WebUI (embedded via go:embed) covers every
API capability.
Not a general-purpose MTA: no IMAP/POP3, no local mailboxes, no relaying for arbitrary users.
git clone <repo> && cd relay
./install.shinstall.sh installs the prerequisites (Go, Node.js, PostgreSQL), provisions the
relay role + database, generates a 0600 relay.toml with a fresh secret
key / admin password / API token, builds the SPA + binary, and installs and
starts a systemd service (relayd.service) that runs the binary. It's
idempotent - safe to re-run - and prints the generated login details.
Secrets live in relay.toml and are read via RELAY_CONFIG, so the database
password, secret key, admin password, and tokens never appear in ps or the
process environment.
sudo systemctl status relayd # it's already running after install
journalctl -u relayd -f # logsOptions: --hostname <fqdn> sets the mail host name (also derives SPF/DMARC/IPs);
--tls provisions Let's Encrypt on :443 + the mail ports (--acme-email for
the contact); --public-ipv4/--public-ipv6 or --detect-public-ip set the
sending IPs (see below); --no-service skips the systemd unit and just builds +
configures (run it yourself with RELAY_CONFIG=./relay.toml ./relayd);
--with-test-db also creates the relay_test database for make test.
Debian/Ubuntu for the package steps; on other distros it skips the installs and
just builds if Go/Node/Postgres are already present.
Health: curl localhost:8080/healthz · Metrics: authenticated curl -H "Authorization: Bearer <token>" localhost:8080/metrics
Relay derives what a sending host needs from the hostname in relay.toml: the
SPF include target (spf.<hostname>), the DMARC address
(dmarc@<registrable-domain>), and - from the host's own interfaces - the
sending IPv4/IPv6. Settings → Server DNS lists the exact A/AAAA/SPF
records to publish for the mail host, plus the PTR reminder. Set a real hostname
up front for production:
sudo ./install.sh --hostname mail.example.com --tls --acme-email ops@example.comBehind NAT / port-forwarding, the host only sees its private IP, so auto-detection can't find the public one - give it explicitly:
./install.sh --hostname mail.example.com --public-ipv4 203.0.113.10
# or let it ask the internet (opt-in; needs egress):
./install.sh --hostname mail.example.com --detect-public-ipThe value lands in relay.toml (sending_ipv4 / sending_ipv6) and can be
edited any time (sudo systemctl restart relayd to apply). If nothing is set and
no public IP is found, Settings → Server DNS shows a warning rather than
emitting wrong records. Note IPv6 auto/echo detection may return a temporary
(privacy/SLAAC) address - pin sending_ipv6 to the address your PTR points at.
make all # build SPA + binary
cp relay.toml.example relay.toml # fill in secret_key, database_url, admin_* ; chmod 600
RELAY_CONFIG=./relay.toml ./relayd # auto-applies migrationsConfig precedence is: built-in defaults → relay.toml (path in RELAY_CONFIG) →
RELAY_* environment variables (override individual keys). .env.example documents
the env-var form if you prefer that for local dev.
Domains can be onboarded, verified, and wired to webhooks entirely over the REST
API using a bearer API key (created on the API keys screen or via
POST /v1/api-keys). See docs/API.md for the full flow —
create key → POST /v1/domains (returns the DNS records) → publish → POST /v1/domains/{id}/verify (activates) → credentials / mailbox webhooks.
Go under cmd/ + internal/, SPA under web/, SQL migrations under
migrations/.
| Target | Purpose |
|---|---|
make all |
build SPA + binary |
make run |
run relayd (auto-migrate) |
make test |
Go unit/integration tests |
make lint |
golangci-lint + tsc |
make migrate / migrate-down |
apply / roll back migrations |
make sqlc |
regenerate typed queries |
make web-build |
build the embedded SPA |
make e2e |
Playwright suite |
These are ops requirements, not code. Mail delivery will fail or be junked
without them. The exact records to publish are shown in Settings → Server DNS
for your configured hostname/IP (the reference deployment is mail.as135559.net.au
on 160.30.37.130 / 2001:df4:2040:5::2):
- PTR / reverse DNS for each sending IP must resolve to the EHLO hostname, and forward DNS must match back.
- Port 25 egress confirmed with the upstream/DC (must not be blocked or SNAT-rewritten - a common problem on residential/cloud NATs).
- IP not on major blocklists (Spamhaus, Barracuda, etc.).
- Warm-up plan - ramp volume gradually; monitor Google Postmaster Tools and Microsoft SNDS from day one.
Single static binary as a systemd service on a dedicated VM. TLS via Let's Encrypt (certmagic, HTTP-01) for the server hostname only - hosted domains never need certs (identity is DKIM/SPF/DMARC). See Phase 7.