A small self-hosted outbound email relay for Trusted Servants Pro, for running the portal on hosts that block outbound SMTP ports (25/465/587) — most notably DigitalOcean droplets, but also many other cloud providers.
Instead of the app connecting to an SMTP server directly, it POSTs each message as JSON to this relay over HTTPS (behind a reverse proxy). The relay runs somewhere with SMTP egress and performs the actual delivery. SMTP credentials live only on the relay, never in the app's database.
TSP app ──HTTPS──▶ TS Pro Relay ──SMTP:587/465──▶ mail server
(no SMTP egress) (this repo) (Gmail, SES, …)
The relay ships a web interface with a login so an operator can set everything up without editing JSON or env files:
- Transaction Log — every send (and unauthorized attempt) with status, sender, recipients, subject, and any error. Counters for total / sent / failed / unauthorized.
- Settings — the upstream SMTP server, a one-click API key (reveal / copy / regenerate), an allowed-sender allowlist, attachment size limit, a Send test email button, optional Cloudflare Turnstile bot protection on the login page, and the admin password.
Configuration and the log are stored in a SQLite DB on the ./data
volume. The SMTP password and API key are encrypted at rest with a key
derived from RELAY_SECRET_KEY.
The published image is on Docker Hub as
viibeware/tspro-relay.
You don't need to clone this repo to run it — just a docker-compose.yml
and a .env.
mkdir tspro-relay && cd tspro-relayservices:
relay:
image: viibeware/tspro-relay:latest
# The relay serves BOTH the admin UI and the JSON send API on one port.
# In production put a TLS-terminating reverse proxy in front (see below)
# and have the TSP app POST to the https:// URL.
ports:
- "0.0.0.0:8026:8000"
environment:
# Signs sessions AND derives the at-rest encryption key for the
# stored SMTP password + API key. REQUIRED — set a long random value.
# python -c "import secrets; print(secrets.token_urlsafe(48))"
- RELAY_SECRET_KEY=${RELAY_SECRET_KEY:?set RELAY_SECRET_KEY in .env}
# First-boot admin login (ignored once the admin row exists).
- RELAY_ADMIN_USER=${RELAY_ADMIN_USER:-admin}
- RELAY_ADMIN_PASSWORD=${RELAY_ADMIN_PASSWORD:-admin}
- RELAY_LOG_LEVEL=${RELAY_LOG_LEVEL:-INFO}
# Set to 1 ONLY for local HTTP testing without TLS.
- RELAY_INSECURE_COOKIES=${RELAY_INSECURE_COOKIES:-}
volumes:
- ./data:/data # relay.db (settings, admin, transaction log)
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=5).status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s# Signs login sessions AND encrypts the stored SMTP password + API key.
# REQUIRED. Generate a strong value:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
RELAY_SECRET_KEY=replace-with-a-long-random-value
# First-boot admin login (change the password from the UI afterwards).
RELAY_ADMIN_USER=admin
RELAY_ADMIN_PASSWORD=change-me-on-first-logindocker compose up -dThe relay (UI + API) is now on port 8026. Open http://<host>:8026,
sign in, and on Settings fill in your SMTP server and copy the API
key.
Building from source instead? Clone this repo and use
image:→build: .in the compose file, thendocker compose up -d --build.
The login cookie and Bearer token must never cross plaintext. Put a
reverse proxy in front that terminates HTTPS and proxies to
127.0.0.1:8026.
Caddy
relay.example.com {
reverse_proxy 127.0.0.1:8026
}
nginx
location / {
proxy_pass http://127.0.0.1:8026;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
Then point the TSP app at https://relay.example.com.
In the portal: Settings → Domain / Email
- Sending method → API relay (HTTPS)
- Relay URL →
https://relay.example.com - Relay API key → the key from the relay's Settings page
- From email / From name → your sender identity
- Save Email Settings, then Send Test. The result also lands in the relay's Transaction Log.
Header: Authorization: Bearer <api-key> · Body: JSON
{
"from_email": "noreply@example.com",
"from_name": "Trusted Servants Pro",
"to": ["someone@example.org"],
"subject": "Hello",
"text": "Plain-text body",
"html": "<p>Optional HTML body</p>",
"reply_to": "replies@example.org",
"reply_to_name": "Replies",
"attachments": [
{"filename": "doc.pdf", "mime_type": "application/pdf", "content_b64": "..."}
]
}200 {"ok": true} on success; otherwise {"ok": false, "error": "..."}
with 401 (bad key), 403 (From not allowed), 413 (attachments too
big), or 502 (SMTP failed).
Unauthenticated liveness probe; reports whether SMTP + an API key are configured, without leaking secrets.
| Var | Required | Default | Notes |
|---|---|---|---|
RELAY_SECRET_KEY |
✅ | — | Signs sessions + encrypts stored secrets. Keep it stable — rotating it invalidates the stored SMTP password + API key. |
RELAY_ADMIN_USER |
admin |
First-boot admin username. | |
RELAY_ADMIN_PASSWORD |
admin |
First-boot password — change it in the UI. | |
RELAY_LOG_LEVEL |
INFO |
DEBUG | INFO | WARNING | ERROR. |
|
RELAY_INSECURE_COOKIES |
— | Set 1 only for local HTTP testing (no TLS). |
|
RELAY_DATA_DIR |
/data |
Where relay.db lives. |
Everything else (SMTP host/port/security/credentials, API key, allowed senders, attachment limit, Turnstile keys) is managed from the Settings page.
docker-compose.test.yml (in this repo) brings up the relay built from
source plus a Mailpit SMTP sink to verify delivery. Mailpit's inbox
is bound to localhost only; the relay UI is on the LAN. See the comments
at the top of that file.
- Always run the UI + API behind TLS in production.
- Change the seeded admin password immediately (Settings → Admin account).
- Keep
RELAY_SECRET_KEYlong, random, and stable. - Use the Allowed From list so a leaked key can't be used to spoof arbitrary senders.
- Optionally enable Cloudflare Turnstile (Settings → Login bot
protection) to challenge the sign-in page. The relay needs outbound
HTTPS to
challenges.cloudflare.comfor verification.
Released under the GNU Affero General Public License v3.0 — see LICENSE. If you run a modified version as a network service, the AGPL requires you to offer your users the corresponding source.