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
hyprlab/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: hyprlab/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).
# REQUIRED — there is no admin/admin fallback.
- RELAY_ADMIN_USER=${RELAY_ADMIN_USER:-admin}
- RELAY_ADMIN_PASSWORD=${RELAY_ADMIN_PASSWORD:?set RELAY_ADMIN_PASSWORD in .env}
- RELAY_LOG_LEVEL=${RELAY_LOG_LEVEL:-INFO}
# Set to 1 ONLY for local HTTP testing without TLS.
- RELAY_INSECURE_COOKIES=${RELAY_INSECURE_COOKIES:-}
# Reverse proxies (IPs/CIDRs) whose X-Forwarded-For may be trusted
# for logged client IPs. Leave blank to log the direct peer.
- RELAY_TRUSTED_PROXIES=${RELAY_TRUSTED_PROXIES:-}
# Per-IP ceiling on /api/send requests per hour (0 disables).
- RELAY_SEND_PER_HOUR=${RELAY_SEND_PER_HOUR:-60}
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).
# REQUIRED — the container refuses to start without a password.
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.
Two proxy-related settings worth adding:
- HSTS — the relay does not emit
Strict-Transport-Securityitself (it never knows whether TLS is in play); set it at the proxy, e.g. nginxadd_header Strict-Transport-Security "max-age=31536000" always;(Caddy sends sensible defaults with aheaderdirective). RELAY_TRUSTED_PROXIES(optional) — by default the relay trustsX-Forwarded-Foras-is for the client IPs shown in the Transaction Log, which works out of the box behind one proxy hop but lets a direct client spoof its logged address. Set this to your proxy's address as seen by the relay (for the compose setup above, the docker bridge, e.g.172.16.0.0/12) to honour the header only from your proxy and make logged IPs spoof-proof.
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 or
request body too big), 429 (per-IP rate limit — see
RELAY_SEND_PER_HOUR), or 502 (SMTP failed — the response is generic;
delivery details appear only in the relay's Transaction Log). Messages
are capped at 100 recipients (400).
Unauthenticated liveness probe; returns {"ok": true} only.
Configuration state is available to authenticated callers via
GET /api/health (Bearer-authenticated).
| Var | Required | Default | Notes |
|---|---|---|---|
RELAY_SECRET_KEY |
✅ | — | Signs sessions + encrypts stored secrets (HKDF-derived keys). The relay refuses to start without it. Keep it stable — rotating it invalidates the stored SMTP password + API key. Use 32+ chars. |
RELAY_ADMIN_USER |
admin |
First-boot admin username. | |
RELAY_ADMIN_PASSWORD |
✅ | — | First-boot password (compose refuses to start without it). If it is ever seeded as admin, the UI forces a password change at first login. |
RELAY_TRUSTED_PROXIES |
— | Comma-separated IPs/CIDRs of reverse proxies. Blank = X-Forwarded-For trusted as-is (logged IPs are spoofable); set = header honoured only from these addresses. |
|
RELAY_SEND_PER_HOUR |
60 |
Per-IP ceiling on /api/send requests per hour; 0 disables. Login is separately throttled (5 failures/minute per IP). |
|
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. Both the relay
UI and Mailpit's inbox are bound to localhost only, and the stack
requires RELAY_SECRET_KEY + RELAY_ADMIN_PASSWORD in the environment.
See the comments at the top of that file.
Built in:
- Sessions and at-rest encryption keys are HKDF-derived from
RELAY_SECRET_KEY; the relay refuses to boot without one. - Forced password change whenever the admin account carries the seeded default password.
- Login lockout (5 failures/minute per IP) and a per-IP
/api/sendceiling (RELAY_SEND_PER_HOUR). - Security response headers on every page (CSP,
X-Frame-Options,X-Content-Type-Options,Referrer-Policy). - 100-recipient cap per message; SMTP error details are kept out of API responses (they appear in the Transaction Log only).
- Settings/credential changes and log clears are recorded in a
settings_audittable insiderelay.db(who / when / from where). - The container runs as an unprivileged user (uid 1000).
Operator checklist:
- Always run the UI + API behind TLS in production, and set HSTS at the reverse proxy (see TLS in production).
- Populate the Allowed From list. Blank accepts any sender — set it so a leaked key can't spoof arbitrary addresses.
- Keep
RELAY_SECRET_KEYlong (32+ chars), random, and stable. - Set
RELAY_TRUSTED_PROXIESif you want Transaction Log IPs to be spoof-proof (by default theX-Forwarded-Forheader is trusted as-is). - The API key is a plain bearer token with no replay protection — TLS end-to-end between the TSP app and the relay is what protects it.
- Optionally enable Cloudflare Turnstile (Settings → Login bot
protection) to challenge the sign-in page. The relay needs outbound
HTTPS to
challenges.cloudflare.comfor verification, and verifies the token'shostnamematches this relay.
TS Pro Relay is built by a human maintainer working with generative AI as a development tool:
- Code — the large majority of the Python code in this repository was written with Anthropic's Claude (via Claude Code), working from the maintainer's direction. The maintainer decides what gets built, reviews the results, tests every release, and signs off on everything that ships.
- Text — documentation, release notes, and in-app copy are largely AI-drafted and human-edited.
- The app itself contains no AI. The relay has no AI features and makes no requests to AI services — it only accepts mail from your TS Pro instance and hands it to your SMTP provider. AI was used to build the app, not to run it.
Bug reports and pull requests are welcome from humans and their AI tools alike; everything merged gets the same human review.
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.