Skip to content

Repository files navigation

toggle-smtp-relay

A small, multi-tenant SMTP relay that fronts AWS SES. Apps speak SMTP to the relay; the relay authenticates them, applies a per-sender allow-list, and forwards each message to SES — either via the SES API (IAM creds) or the SES SMTP endpoint (SES SMTP creds), chosen per sender. It also exposes daily send stats, a CLI for password/stats management, health endpoints, and Sentry error/perf tracking.

Built for Toggle's local machines. SES-only for now.

Why not Postfix / off-the-shelf

Per-authenticated-user routing to different AWS credentials, with password rotation tracking, label-aware From: allow-lists, and per-username daily stats, isn't something stock relays do cleanly.

How it works

  • Synchronous pass-through. The relay holds the app's SMTP connection, calls SES inline, and returns 250 only after SES accepts. No spool, no retry engine — the app owns retries (normal SMTP behaviour). No message bodies are stored.
  • Inbound: STARTTLS on :587, relay-terminated. AUTH (PLAIN + LOGIN) is offered only after TLS. The TLS cert is issued/renewed externally by Caddy and hot-reloaded (no restart on renewal).
  • Identity & routing: each login (AUTH username) has an allowedSenders list of {address, cred}. The cred is chosen by exact match of the label-stripped, lowercased From: header against that list. Multi-address or unparseable From: is rejected; the message is forwarded byte-for-byte.
  • Config split: declarative YAML (creds, logins, allowed senders) is operator-owned and never written by the relay (reloaded on SIGHUP). SQLite (system-owned) holds password hashes + daily counters; the CLI mutates only the DB, and auth reads hashes from it live.
  • Secrets: a <field>Env key names an env var resolved at boot (fail-closed if unset). Secrets are never serialized; config show prints only the var name. A Sentry before_send hook scrubs resolved secrets from every event.

Build

Pure-Go (modernc SQLite), single static binary — daemon and CLI in one.

go build -trimpath -ldflags="-s -w" -o relay ./cmd/relay
go test -race ./...

Configure

Copy and edit the samples, then validate:

cp .env.example .env          # fill in the secret values
$EDITOR config.yaml           # creds, logins, allowedSenders, cert paths
relay config check --config config.yaml

config check validates structure, env refs, cred refs, and duplicate usernames, and exits non-zero on any problem — run it before every deploy.

CLI

relay serve                                   run the daemon
relay password create  <username>             create a password (shown once)
relay password list    <username>             list a login's passwords
relay password disable <username> <id>        disable (interlocked)
relay password enable  <username> <id>        re-enable a disabled password
relay password delete  <username> <id>        delete a disabled password
relay stats [--username u] [--from d] [--to d] daily counters
relay config show                             config with secrets masked
relay config check                            validate config
relay gc                                      sweep orphan password rows
relay healthcheck                             probe /healthz (container probe)

All commands accept --config <path> (default /etc/relay/config.yaml).

  • Passwords are generated by the CLI and shown once; only the bcrypt hash is stored (max 5 active per login).
  • disable is the reversible safety valve and requires typing the username to confirm when the password was used within the interlock window or is the last active one; on a non-TTY it aborts. delete only removes an already-disabled row.

Health

/healthz (liveness, process-only) and /readyz (config loaded + DB usable) on 127.0.0.1:8080. SES is never probed — a SES outage surfaces via counters and Sentry alerts, not liveness.

Send outcomes (stats)

Per (username, day_utc, outcome, reason):

  • success — SES accepted (got a messageId).
  • rejected — SES definitively declined (throttling / quota / unverified / message rejected). → ask AWS to raise quota.
  • failure — no clear answer (timeout / network / bad creds / 5xx / panic). → check creds/network/SES.
  • denied_policy — allow-list refusal; never reaches SES.

Sentry captures failure-class always; rejected only for throttling/quota (deduped 1 event / 5 min); routine denied_policy and other rejections stay in counters.

Deploy

docker compose up -d                          # daemon
docker compose exec relay relay password create app1 --config /etc/relay/config.yaml

Distroless non-root image; SQLite on the relay-data volume; config.yaml mounted read-only; the Caddy cert tree bind-mounted read-only (set the real tls.certFile/keyFile paths — the ACME CA sub-dir differs by issuer, and its host path comes from CADDY_DATA_DIR in .env). The health port is published to localhost only.

Published images: ghcr.io/toggle-corp/toggle-smtp-relay (tags vX.Y.Z, vX.Y, vX, latest — image tags mirror the git tag).

Release

Releases use toggle-corp/fugit (vendored as the ./fugit submodule) for the changelog + tag, and two GitHub Actions workflows:

  • ci.yml — gofmt / go vet / go test -race / build, on every PR and push to main.
  • release.yml — on a v* tag push: builds and pushes the image to ghcr.io, then cuts a GitHub Release whose body is the changelog rendered from fugit's shared cliff.toml.

Cut a release:

git submodule update --init            # first checkout only (pulls ./fugit)
./release.sh                           # prompts for version; needs git-cliff, semver, typos, gh
git push origin <tag> && git push      # tag push triggers image publish + GitHub Release

release.sh writes CHANGELOG.md, commits it, and creates a signed tag vX.Y.Z (requires a signing key). Version scheme is require (v-prefixed git tags); the published image tags mirror the git tag (also v-prefixed).

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages