An SMTP/API gateway that sits in front of an outbound relay — OCI Email Delivery,
Amazon SES, Postmark, or any other SMTP provider. Internal applications submit mail
through a Resend-style REST API or through SMTP submission; relais authenticates the
sender, verifies strictly that the announced From address is one that credential
is allowed to use, and relays it.
It exists for the situation where several applications share one outbound relay and you want each of them to be able to send as its own addresses and no others, without handing every application the relay's own credentials.
DKIM is not handled here. Signing happens downstream, in the relay.
- No anonymous relaying. Every submission is authenticated, with no exception and no permissive debug mode.
- No secret in cleartext in the database. Relay passwords are sealed with AES-256-GCM under a key that lives only in the environment, and the keyring is rotatable. Sender credentials are stored as an HMAC fingerprint peppered from the environment, and cannot be recovered even with the database.
- An unauthorised
Fromis rejected and recorded, with enough context to investigate a compromised credential and never with the content of the message. - A credential with no configured pattern can send as nobody. The allow-list is closed by default. A credential can additionally be restricted in who it may send to, which is how a staging application is stopped from reaching real users.
- Message content is not kept. Payloads are held until delivery succeeds and are returned by no endpoint.
Deliberately out of scope, so the design stays small:
- No bounce or complaint handling. No DSN parsing, no webhooks. The schema anticipates none of it.
- No templates, no open or click tracking.
- No multi-tenant isolation. One deployment serves one organisation. The data model is clean enough to grow into it, without pretending to be there.
- No DKIM, DMARC or SPF management. That belongs to the relay and to DNS.
If you need those, a hosted sending platform will serve you better.
Requires Go 1.26+, Docker, and Node 22+ for the admin interface.
task setup # generates the keys this repository ships none of
docker compose up -d # Postgres + mailpit (a local SMTP sink)
task migrate # apply the schematask setup creates .env from .env.example and generates three secrets into it.
This repository ships no key material: .env.example leaves the secret variables
empty and relais refuses to start without them. That refusal is deliberate — a
committed default would be a key every reader of this repository already has, and a
service that starts with one is worse than a service that does not start.
Then configure a relay, a domain and a credential:
# The outbound relay. mailpit here; your provider in production.
relais backend add -name mailpit-dev -host 127.0.0.1 -port 1025 -tls none
# Which relay carries which domain's mail.
relais domain add -name example.test -backend mailpit-dev -include-subdomains
# A credential, and what it may use as From.
relais credential create -name my-app -type api_key -from '*@example.test'
# → the API key is printed ONCERun it, and send something:
task serve # :8080 sending API, :8081 admin API, :2525 submissioncurl -X POST http://127.0.0.1:8080/v1/emails \
-H "Authorization: Bearer relais_sk_..." \
-H "Content-Type: application/json" \
-d '{"from":"alerts@example.test","to":"someone@elsewhere.test",
"subject":"It works","text":"Hello."}'It arrives in mailpit at http://localhost:8025.
Before sending anything, you can ask relais which relay would carry a domain's mail:
relais domain resolve example.testThe equivalent question about a credential — would this address be allowed, and does anything route it? — is an admin API endpoint rather than a CLI command, because it is what the interface asks on every keystroke. See Administration.
The interface authenticates through OIDC and has no local bypass, so it needs a provider. One is included for development, with its realm already imported:
docker compose --profile auth up -d # Keycloak on :8180, or: task dev:authAdd the matching settings to .env — the issuer must be the same value on both
sides, since both deal in the same iss claim:
RELAIS_OIDC_ISSUER=http://localhost:8180/realms/relais
RELAIS_OIDC_AUDIENCE=relais
RELAIS_WEB_API_URL=http://127.0.0.1:8081
RELAIS_WEB_ORIGIN=http://localhost:3000
RELAIS_WEB_OIDC_ISSUER=http://localhost:8180/realms/relais
RELAIS_WEB_OIDC_CLIENT_ID=relais-web
RELAIS_WEB_OIDC_CLIENT_SECRET=dev-only-not-a-production-secret
RELAIS_WEB_INSECURE_COOKIE=trueThen run the interface and sign in as ops / ops (administrator) or watcher /
watcher (read-only):
task web:start # what ships: the built server, on http://localhost:3000
task web:dev # or the dev server with hot reload, on http://localhost:5173RELAIS_WEB_ORIGIN has to name the one you run. The OIDC redirect URI is built from
it, and the development realm allows exactly two callbacks — :3000 and :5173 — so
task web:dev needs the variable pointed at http://localhost:5173. Get it wrong and
the interface loads while every write is refused as cross-site, which reads like a
permission problem and is not one. task smoke drives the :3000 server.
The interface runs on the host rather than in a container for one reason: an OIDC
issuer URL has to resolve to the same provider from the browser and from the server
that exchanges the code, since both deal in the same iss value. On a development
machine the only name that satisfies both is localhost, which a container cannot
reach. Deployments have a real hostname, so the image itself is unchanged.
The development realm carries a fixed client secret and two throwaway passwords.
Those are fixtures for a loopback-only container in start-dev mode: they unlock
that container and nothing else.
curl -X POST http://localhost:8080/v1/emails \
-H "Authorization: Bearer relais_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-42" \
-d '{
"from": "My App <no-reply@example.com>",
"to": "recipient@elsewhere.test",
"cc": ["copy@elsewhere.test"],
"bcc": ["hidden@elsewhere.test"],
"subject": "Invoice #42",
"text": "Hello,",
"html": "<p>Hello,</p>",
"headers": {"X-Entity-Ref-Id": "inv-42"},
"attachments": [{"filename": "f.pdf", "content_type": "application/pdf", "content": "<base64>"}]
}'
# → 202 {"id": "...", "status": "queued", "message_id": "<...@example.com>", "recipients": [...]}
curl http://localhost:8080/v1/emails/<id> -H "Authorization: Bearer relais_sk_..."
# → 200 {"status": "sent", "sent_at": "...", ...}The contract follows Resend's for familiarity, without claiming strict compatibility.
to, cc, bcc and reply_to each accept either a string or an array. No Bcc
header is ever written: blind recipients travel in the envelope only.
| Status | Meaning |
|---|---|
| 202 | accepted and queued |
| 200 | replay of an Idempotency-Key — nothing was sent again |
| 400 | invalid JSON, or an unknown field (a typo is never silently ignored) |
| 401 | authentication failed (no detail: see the logs) |
| 403 | the credential may not use that From |
| 413 | body or message over the configured limit |
| 422 | validation: bad recipient, missing body, unconfigured domain… |
| 429 | rate limit exceeded (with Retry-After) |
Errors all share one shape, with a stable code — the same vocabulary the logs and the database use:
{"error": {"code": "sender_not_allowed", "message": "...", "message_id": "..."}}The full contract is described in docs/openapi-public.json, generated from the Go types that serve the requests.
For applications that only speak SMTP — WordPress, legacy PHP scripts, anything with a
"mail settings" screen. Create a credential of type smtp_user:
relais credential create -name wordpress -type smtp_user -username blog \
-from 'no-reply@example.com'
# → the SMTP password is printed ONCEClient configuration: host relais, port 587, STARTTLS, user blog, password as
printed.
Three guarantees, verified by tests and on the wire:
AUTHis not even advertised on a plaintext connection, and would be refused if a client tried it anyway;MAIL FROMwithout authentication is refused530 5.7.0— that is what "no anonymous relaying" means in protocol terms;- it is the header
Fromthat gets authorised, not the envelope. A legacy client that puts anything inMAIL FROMstill works; aFromoutside the allow-list is refused550 5.7.1and the attempted address is recorded.
The success reply carries the id, as Postfix does: 250 2.0.0 OK: queued as <uuid>,
which makes a client's own log correlatable with a relais message.
Four shapes, and nothing else:
| Pattern | Allows |
|---|---|
no-reply@app.example.com |
that exact address |
*@example.com |
any local part, on that exact domain |
no-reply@*.example.com |
that local part, on any subdomain |
*@*.example.com |
any local part, on any subdomain |
*.example.com does not cover example.com. Covering both takes two patterns,
deliberately: a wildcard should never reach further than what was written.
A partial wildcard (no-*@example.com) is refused, and no user-supplied regular
expression is ever evaluated. Domains are normalised to punycode, so Exemplé.COM and
xn--exempl-gva.com are the same pattern.
A credential can also be limited in who it may send to, with the same four shapes:
relais credential recipient add <credential-id> -to '*@ourteam.example.com'
# or at creation
relais credential create -name staging -from '*@staging.example.com' \
-to '*@ourteam.example.com'The case this exists for is a staging or preview application: it holds real data, it sends real mail, and nothing about the sender allow-list stops it reaching a real customer. Restricting its recipients turns "we emailed 4000 users from staging" into a refusal recorded against the credential that tried.
An empty recipient list means no restriction, which is the opposite of the sender list — where an empty list permits nothing. The asymmetry is deliberate: a sender allow-list answers "who may this credential claim to be", and closed-by-default is the only safe answer; a recipient allow-list is a guardrail an operator opts into, and there is no pattern that spells "any address", so closed-by-default would simply mean no credential could send. Adding the first pattern narrows; removing the last one widens. The CLI, the API and the interface all say which of the two just happened.
The check runs on the envelope recipients — the addresses mail is actually delivered
to, which is RCPT TO over SMTP and the union of to, cc and bcc over the API. One
recipient outside the list refuses the whole submission rather than quietly delivering
to the rest: a client believing it mailed five people when it mailed four is worse than
an error it can see. The refusal is a 403 on the API and 550 5.7.1 over SMTP, and it
is recorded like a sender refusal, with the attempted recipients and the client's IP.
/admin/v1/* on a separate listener (:8081 by default). Authentication is a JWT
from the configured OIDC issuer, validated against its JWKS; authorisation is group
membership, mapped to admin (read/write) or viewer (read-only).
Keeping it on its own port means exposing the sending API cannot expose the admin API: exposure becomes a network decision rather than a routing rule nobody must get wrong.
Beyond CRUD on relays, domains, credentials and patterns, four dry-run endpoints exist because they are what make the interface useful rather than merely functional:
| Endpoint | Answers |
|---|---|
POST /admin/v1/patterns:validate |
is this pattern valid, and what is its canonical form? |
POST /admin/v1/credentials/{id}/patterns:test |
would this credential be allowed to send as this address, and does any domain route it? |
POST /admin/v1/credentials/{id}/recipient-patterns:test |
may this credential send to this address, and is it restricted at all? |
GET /admin/v1/domains:resolve?sender= |
which relay would carry this sender's mail? |
POST /admin/v1/backends/{id}:test |
do these relay credentials actually work? (connects and authenticates, sends nothing) |
The first two exist so nothing outside Go reimplements the pattern grammar. A copy would drift, and the day it drifts the interface misreports what a credential may send as.
Four verbs, and they are not degrees of the same thing:
| Action | What survives | Reach for it when |
|---|---|---|
PATCH .../credentials/{id} (enabled) |
everything; reversible | pausing an application |
POST .../credentials/{id}:rotate |
the id, name, limits, allow-list and every past message's attribution | the secret needs replacing |
POST .../credentials/{id}:revoke |
the row, so its messages still name it; permanent, and there is no un-revoke | a secret leaked |
DELETE .../credentials/{id} |
the messages, but no longer their attribution (ON DELETE SET NULL) |
clearing out a credential whose history nobody needs |
Rotation exists because the alternative — create a replacement, revoke the old one —
also discards the allow-list somebody reviewed, the limits somebody tuned, and the id
every past message points at. It changes only what leaked. The old secret stops working
the moment the call returns, so rotate when the application is ready to be
reconfigured. An smtp_user keeps its username and receives a new password; an
api_key receives a whole new token, because the lookup half is part of the token
itself. A revoked credential cannot be rotated.
The same four are on the CLI: relais credential rotate|revoke|delete <id>.
OIDC discovery is lazy: it happens on the first admin request, not at startup. A
provider outage therefore never stops relais from relaying mail — it makes the admin
API unavailable, with a 503 rather than a 401.
Described in docs/openapi-admin.json.
A SvelteKit application in web/, served as its own container. It is a backend-for-frontend: every call to relais is made from its server, so no token ever reaches the browser. Six screens — dashboard, relays, domains, credentials, sender patterns with the dry runs above, and the message log.
Its design decisions are in docs/FRONTEND.md.
Everything comes from environment variables prefixed RELAIS_. See
.env.example, which documents each one.
keygen reads no configuration and touches no database, so it works before a .env
exists:
relais keygen key # RELAIS_SECRET_ENCRYPTION_KEYS
relais keygen pepper # RELAIS_SECRET_CREDENTIAL_PEPPER
task web:key # RELAIS_WEB_SESSION_KEYThe keyring is rotatable; the pepper is not — changing it invalidates every existing credential.
RELAIS_SECRET_ENCRYPTION_KEYS="1:<old>,2:<new>"
RELAIS_SECRET_ENCRYPTION_ACTIVE_KEY=2
relais backend rewrap # re-seals everything under key 2
RELAIS_SECRET_ENCRYPTION_KEYS="2:<new>" # the old one can goTwo sources, exactly one at a time:
-
RELAIS_TLS_CERT_FILE+RELAIS_TLS_KEY_FILE— production. Any tool that writes a certificate to disk fits: certbot, lego, cert-manager, a mounted secret.A renewal is picked up without a restart and without a signal. relais checks the files every
RELAIS_TLS_WATCH_INTERVAL(30s by default, two stats) and swaps the certificate in for the next handshake;SIGHUPstill forces it immediately. Watching rather than signalling matters when the renewer is another container: signalling one would mean handing it the Docker socket, which is a far larger privilege than fetching a certificate deserves.A failed reload keeps the previous certificate serving, and the check retries — a renewal caught half-written, or a file briefly unreadable, resolves itself on the next pass rather than being skipped until expiry.
For the ACME side, deploy/coolify/docker-compose.smtp.yaml carries a ready-made
legosidecar using the DNS-01 challenge. DNS-01 because HTTP-01 needs port 80 and TLS-ALPN-01 needs 443, both of which belong to the platform's proxy — and because it works for a hostname that serves no HTTP at all, which is the normal case for a name dedicated to mail.One setting there is not optional:
LEGO_DNS_RESOLVERS. Left to the default, lego checks its own challenge record through the container's resolver — Docker's stub — and a negative answer cached before the record appeared makes every attempt fail identically until the cache expires. The file explains it where it is set. -
RELAIS_TLS_SELF_SIGNED=true— tests and development. A certificate is generated at startup, its SHA-256 fingerprint logged so a client can pin it, and it is refused whenRELAIS_ENV=produnless explicitly overridden.
CI publishes two images to GHCR once every check has passed — lint, the suite with the race detector, the fuzz targets, the frontend checks and both image builds:
| Image | Contents | Ports |
|---|---|---|
ghcr.io/amenitydev/relais |
the gateway: sending API, submission server, workers | 8080 sending, 8081 admin, 2525 submission |
ghcr.io/amenitydev/relais-web |
the admin interface | 3000 |
Both are linux/amd64 and linux/arm64 and run as a non-root user; the gateway image
has no shell.
Tags:
sha-<short>— immutable, one per commit. This is what a rollback pins to.main— moves with the default branch.1.2.3,1.2andlatest— only from av*tag.latestdeliberately does not followmain: a deployment that tracks it should track releases, not every commit.
relais version reports git describe, so a running container can be traced back to
its source.
Two ready-made stacks for Coolify, differing only in whether SMTP submission is part of them:
| File | What it deploys |
|---|---|
| deploy/coolify/docker-compose.yaml | the sending API and the admin interface |
| deploy/coolify/docker-compose.smtp.yaml | the same, plus SMTP submission and a lego sidecar that obtains and renews its certificate over ACME DNS-01 |
Both use an external Postgres and run the migrations in a one-shot container before
the gateway starts. Both use Coolify's magic variables, so the interface's domain and
its RELAIS_WEB_ORIGIN cannot disagree, and the OIDC issuer is one value shared by
both halves. task lint checks that the two files have not drifted apart.
Otherwise there is nothing platform-specific to know. Both images are configured entirely through the environment and neither reads a config file. Postgres is external; any standard DSN works, including one pointing at a pooler.
Publish 587:2525 for submission rather than binding 587 in the container, which
avoids needing CAP_NET_BIND_SERVICE.
The admin interface needs a public hostname. The admin API on 8081 does not, and should
not — the separate listener exists so that exposure is a network decision, and
/admin/v1 answers 404 on the sending port anyway.
Whether the sending API needs a hostname depends on where the callers are. If every
application shares a network with relais, http://relais:8080 is enough and nothing has
to be public. On a platform that gives each application its own network — Coolify does —
there is no shared name to dial, so the sending API needs a domain like any other
service. When it has one, set RELAIS_HTTP_TRUSTED_PROXY_HEADER: behind a proxy the
socket peer is the proxy, and without it every rejected submission is recorded against
the proxy's address rather than the client's.
- Migrations are never implicit. Run
relais migrate upas its own step before starting a new version.servewill not do it for you. - The two issuers must be the same value.
RELAIS_OIDC_ISSUERandRELAIS_WEB_OIDC_ISSUERdeal in the sameissclaim. A mismatch means every token the interface obtains is rejected, and the error names neither variable. RELAIS_WEB_ORIGINmust be the interface's real public origin. The image derives the CSRF origin and the OIDC redirect URI from it. Get it wrong and navigation works while every write is refused.
task # list every task
task test # Go tests with the race detector (database-backed ones skip)
task test:all # everything, requiring the relais_test database
task fuzz # fuzz the sender-pattern matcher
task lint # vet, gofmt, go mod tidy -diff, OpenAPI freshness
task generate # regenerate the sqlc query layer
task openapi # regenerate the OpenAPI documents
task web:check # frontend: svelte-check, prettier, eslint, vitest
task smoke # drive the running stack end to end
task dev:reset # wipe the development database and replay the migrations
task dev:stop # stop every container, keeping the datatask is not required: every target is a readable sequence of commands in
Taskfile.yml.
The database-backed tests run against a separate relais_test database, created by
the development Postgres on first start. They TRUNCATE every table between cases, so
pointing them at the development database would wipe whatever you were working with —
without an error, and without any obvious cause later.
Setting RELAIS_TEST_DB_URL makes the database mandatory: an unreachable one fails
rather than skips, because a silently skipped integration suite is indistinguishable
from a passing one. With the variable unset, the development stack is tried and the
tests skip if it is not running.
The OpenAPI documents in docs/ are emitted by relais openapi, which reflects over
the request and response structs that actually serve the requests. The frontend's
TypeScript types are generated from those documents, and CI diffs both against a fresh
generation. A handler whose response shape changes without a regeneration fails the
build rather than shipping types that describe an API the server no longer has.
- docs/ARCHITECTURE.md — the decisions behind the shape of the code, and what was learned building it.
- docs/FRONTEND.md — the admin interface's design.
- docs/openapi-admin.json, docs/openapi-public.json — the generated API descriptions.
- SECURITY.md — what is in scope, and how to report a vulnerability.
- CONTRIBUTING.md — how to propose a change.
Pre-1.0. The gateway and the admin interface are complete and covered by tests; the API surface is stable enough that it is described by a generated OpenAPI document, but nothing is promised across versions yet.
AGPL-3.0. Running a modified relais as a network service obliges you to offer its source to the people using it. Deploying it unmodified, which is what most deployments do, carries no such obligation.