outbound server
  • Python 62.8%
  • C# 28.4%
  • Shell 6.8%
  • Dockerfile 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-24 19:08:04 +02:00
Data Push fixes 2026-09-08 21:29:56 +02:00
deployment Update outbound 2026-10-06 15:56:03 +02:00
helpers Initial 2026-06-02 03:47:06 +02:00
mail Fix image 2026-10-06 16:33:42 +02:00
Models Fix out of network sending 2026-10-24 19:08:04 +02:00
Properties Initial 2026-06-02 03:47:06 +02:00
routes changes 2026-10-19 23:28:45 +02:00
.dockerignore Update outbound 2026-10-06 15:56:03 +02:00
.env.example Update outbound 2026-10-06 15:56:03 +02:00
.gitignore Initial 2026-06-02 03:47:06 +02:00
appsettings.Development.json Initial 2026-06-02 03:47:06 +02:00
appsettings.json Initial 2026-06-02 03:47:06 +02:00
docker-compose.private.yml Update outbound 2026-10-06 15:56:03 +02:00
docker-compose.yml Add application key 2026-10-11 18:56:51 +02:00
Dockerfile Update outbound 2026-10-06 15:56:03 +02:00
email-out-goed.csproj Add replying 2026-06-09 11:29:01 +02:00
email-out-goed.http Allow setting "from" 2026-06-03 23:15:13 +02:00
global.json Initial 2026-06-02 03:47:06 +02:00
Program.cs changes 2026-10-19 23:28:45 +02:00
README.md Fix image 2026-10-06 16:33:42 +02:00
requestbody.md Add replying 2026-06-09 11:29:01 +02:00
todo.md Switch up sending to use json, add validation layers and rate limits 2026-06-04 23:24:31 +02:00

Outbound mail nodes

Each deployment has an HTTP API and its own Postfix/OpenDKIM container. Caddy balances POST https://send.goed.email/send across APIs; the selected API submits to its local Postfix on port 587. A node such as out-nbg1.goed.email uses its own public IPv4, SMTP HELO, and DKIM selector. Users continue sending as [email protected].

Configure a node

Deploy docker-compose.yml as a Docker Compose resource in Coolify. Copy the variables in .env.example into its environment settings. For local Compose, copy that file to .env first.

Set these values per node:

MAIL_HOSTNAME=out-nbg1.goed.email
MAIL_PUBLIC_IPV4=YOUR_ACTUAL_PUBLIC_IPV4
DOMAIN=goed.email
PUBLIC_API_URL=https://send.goed.email

Set the existing API_KEY and DB_* settings on every deployment. All nodes use the same database and API key. The API always uses its local postfix:587 in this Compose stack. No public SMTP ports are mapped. Outbound IPv6 is disabled.

Coolify must deploy the Compose stack, rather than just the API Dockerfile. The Postfix image is built from mail/Dockerfile. Its optional MAIL_HOSTNAME and DOMAIN build arguments supply defaults; runtime environment variables override them, allowing the same image to run on multiple nodes:

docker build -f mail/Dockerfile --build-arg MAIL_HOSTNAME=out-nbg1.goed.email -t outbound-mail .
docker compose up -d --build
docker compose logs postfix

Startup logs print the actual public DKIM key and all required DNS instructions. If MAIL_PUBLIC_IPV4 is missing, the report prints a placeholder and automatic DNS setup is refused. Invalid hostnames/domains prevent mail-container startup.

Use a fixed public IPv4, allow outbound TCP 25 with your IP provider, and set its reverse DNS to the node hostname. MAIL_PUBLIC_IPV4 must be the SMTP egress address, including any NAT. Node A records must be DNS-only in Cloudflare.

Let each node configure Cloudflare DNS

The Cloudflare DNS API works on Free zones, within their record quota. Create an API token using Edit zone DNS, restricted to the goed.email zone. Providing the zone ID avoids needing permission to discover zones. Do not use a global API key. Token instructions and DNS API.

DNS_AUTOCONFIG=cloudflare
CLOUDFLARE_ZONE_ID=YOUR_32_CHARACTER_ZONE_ID
CLOUDFLARE_API_TOKEN=YOUR_ZONE_SCOPED_TOKEN

Store the token as a Coolify secret. Alternatively mount a token file into the Postfix container, set CLOUDFLARE_API_TOKEN_FILE to its container path, and leave CLOUDFLARE_API_TOKEN empty. The Compose template does not mount a secret file automatically; add that volume or Docker secret in your deployment.

After DKIM initialization, automatic setup:

  • Creates/updates the node A record with proxying disabled, node SPF TXT, and selector-specific DKIM TXT. Records created by the node carry an ownership comment; it updates only its owned records. Matching preexisting records are accepted, while conflicting unowned records require manual reconciliation.
  • Adds the node's ip4: authorization to the domain's single SPF TXT without removing other senders or changing its existing policy. Duplicate or malformed SPF records stop synchronization. Redirect policies retain their redirect.
  • Creates v=DMARC1; p=none only when no TXT exists at the DMARC name. Existing policy is preserved and subsequently validated by mail-check.

All cooperating nodes acquire the same PostgreSQL advisory lock before DNS updates. They must use the same database and a direct connection or session pooling, not PgBouncer transaction pooling. Other DNS editors do not participate in this lock; avoid editing SPF during synchronization.

Synchronization runs at startup and can be rerun manually:

docker compose exec postfix mail-dns-sync

In Coolify's Postfix terminal, simply run mail-dns-sync. Repeated syncs do not duplicate records. Transient API failures retry with bounded backoff; persistent failures log the problem and leave the mail service running so you can repair configuration. Writes already completed remain applied. Always run mail-check before routing production traffic to a new node.

Cloudflare automation never changes incoming MX records, Caddy's send.goed.email record, registrar DNSSEC settings, or TLSA records. Provider PTR setup remains manual because your DNS zone does not control the provider's reverse zone.

Check the node

docker compose exec postfix mail-check
docker compose exec postfix mail-check --recipient-domain example.net

In Coolify's Postfix terminal:

mail-check

Checks cover public A/PTR agreement, observed IPv4 egress, effective SMTP identity, SPF authorization/lookup limits, the published DKIM key against the persisted key and signer configuration, DMARC/alignment, local SMTP and signing-service access, outbound mandatory TLS, and the HTTPS certificate for PUBLIC_API_URL.

The recipient probe resolves MX records and attempts port 25 and STARTTLS, defaulting to gmail.com (override MAIL_CHECK_RECIPIENT_DOMAIN). It does not send a message. Recipient certificate verification is reported separately from successful transport encryption. Postfix's encrypt policy requires encryption, but does not enforce recipient PKIX certificate verification; the checker is stricter about the probed recipient's certificate. A failed probe cannot establish whether all possible recipients are reachable.

Results include PASS, FAIL, WARN, UNKNOWN, and N/A. Exit status is 0 when required checks pass, 1 for a confirmed failure, or 2 when required checks could not complete. Confirmed failures take precedence over incomplete checks. DNS propagation and unavailable external services can leave checks incomplete; rerun after correcting the problem. The egress probe observes HTTPS IPv4 routing; SMTP-specific NAT must also be verified using a delivered message.

Published A, PTR, TXT, and MX checks use the trusted resolver configured by DNSSEC_RESOLVER, bypassing Docker's internal DNS. Otherwise Docker can resolve the node's own hostname to its private container address and cause a false A-record failure.

_25._tcp.<node> TLSA and DNSSEC

The checker explicitly queries _25._tcp.out-nbg1.goed.email for TLSA. Published records require DNSSEC authentication and a matching certificate on a reachable port-25 STARTTLS endpoint. An unreachable endpoint, insecure DNSSEC result, or fingerprint mismatch fails the check. It supports DANE-EE usage 3, certificate or public-key selection (0/1), and exact/SHA-256/SHA-512 matching (0/1/2). Other forms are reported incomplete, never silently accepted.

DNSSEC validation uses the AD result from an explicitly trusted validating recursive resolver, default https://cloudflare-dns.com/dns-query over certificate-verified HTTPS. Set DNSSEC_RESOLVER to another trusted DNS-over-HTTPS URL, or an IP address for a trusted local validating resolver. The direct-IP option assumes a trustworthy network path. The checker does not validate the DNSSEC chain independently.

These deployments are outbound-only: they do not expose a public port-25 listener or provision a public SMTP certificate. Therefore missing node TLSA is reported not applicable. A TLSA record authenticates the server accepting an SMTP connection; recipients do not consult the sender's TLSA to authenticate messages. Do not copy _25._tcp.mail.goed.email to outbound nodes: it belongs to that hostname's receiving endpoint. DKIM is the outbound public key published in DNS. SMTP DANE specification.

The checker does not change existing TLSA records. If you later add public SMTP endpoints, certificate provisioning, renewal, DNSSEC, and TLSA rotation must be managed together before enabling those endpoints.

Route through Coolify's Caddy

For APIs on different servers, use an explicit private/VPN host binding:

API_BIND_ADDRESS=10.0.0.11
API_HOST_PORT=8080
docker compose -f docker-compose.yml -f docker-compose.private.yml up -d --build

The override requires Docker Compose 2.24.4+ for !override. In Coolify, configure the equivalent API port mapping on each node. Permit the mapped port only from the Caddy server/private network using your provider firewall. Postfix stays on a dedicated per-deployment Docker bridge with Internet egress and no host ports. The API's default Compose mapping is an ephemeral host port; use the override for stable cross-server upstreams.

Edit deployment/send.caddy with the three reachable private API addresses. Install it as /data/coolify/proxy/caddy/dynamic/send.caddy on your gateway and reload its Caddy configuration. Do not assign send.goed.email to an individual node too, which would create a competing generated route. Point send.goed.email DNS to this gateway and allow ports 80/443 for Caddy's HTTPS certificate management. Coolify Caddy documentation.

The example uses round-robin, /ready active checks, and passive connection failure detection. /ready returns 200 when the API can connect to its database and local SMTP service, otherwise 503. /health remains the liveness endpoint. Readiness does not rerun Internet DNS checks on every request.

POST retries remain disabled: /send has no idempotency protection. An HTTP failure after Postfix acceptance can still leave a message queued/sent, so callers must not blindly retry uncertain sends. Existing per-user rate limits remain per API instance. Shared globally enforced limits are outside this change.

Keys, redeployment, and retirement

./dkim-keys preserves the existing key location; each independent deployment needs its own persistent directory. Back it up securely. Existing keys are reused; startup sets this directory to owner opendkim:opendkim and mode 750 so Coolify-created bind mounts are accessible to the signer without exposing keys. Existing .private files are also set to opendkim:opendkim and mode 400 on every start. This happens after mounts are attached; no manual permission commands are needed after rebuilding/redeploying the image. The post-init hook repeats the repair immediately before services start and checks read access as the opendkim account. Look for DKIM permissions verified in startup logs. The signer wrapper fails visibly if access is lost, rather than silently sleeping while supervision claims it is running. The startup report derives the public record from the actual private key, including when an old .txt file contains a previous selector. Default selector is the hostname's first label; optional DKIM_SELECTOR overrides must remain unique. Queue storage uses the deployment's postfix-queue volume. Preserve both when moving/recreating a resource. Do not run docker compose down -v on production.

To add a node, deploy with a unique hostname/selector and its public IP, set PTR, sync DNS, pass mail-check, and then add its API to Caddy. For retirement, remove it from Caddy, allow its queue to drain, then remove its SPF authorization and node-specific records manually once no longer needed. Stopping a node does not delete DNS. Changing a node IP adds the new SPF authorization but deliberately does not remove the old one automatically.

Incoming MX, users' reply mail, and delivery-status bounces still need your existing receiving infrastructure. Correct DNS and TLS do not guarantee inbox placement: validate a real received message's SPF/DKIM/DMARC results, and monitor queue errors, reputation, and complaints.

Development checks

docker build -f mail/Dockerfile -t outbound-mail:test .
docker run --rm --entrypoint /opt/mail-node/venv/bin/python \
  -e PYTHONPATH=/opt/mail-node -v "$PWD/mail/tests:/tests:ro" \
  outbound-mail:test -m unittest discover -s /tests -v
docker build -t outbound-api:test .

Run bash mail/tests/permissions.sh after building the mail image to reproduce Coolify's UID-1001 key mount and verify startup/restart repair without key rotation. Also run MAIL_TEST_STARTUP=upstream bash mail/tests/permissions.sh to exercise the post-init repair when the custom entrypoint is bypassed.

mail/tests/integration.py runs only inside a disposable started mail container with /tests mounted and test-only dkimpy==1.1.8 installed in its virtual environment. It changes transport settings to local test receivers and verifies actual DKIM signatures, encrypted delivery/HELO, signer outages, and queueing for a recipient without TLS. Never run it on a production node.