- Python 62.8%
- C# 28.4%
- Shell 6.8%
- Dockerfile 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| Data | ||
| deployment | ||
| helpers | ||
| Models | ||
| Properties | ||
| routes | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| appsettings.Development.json | ||
| appsettings.json | ||
| docker-compose.private.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| email-out-goed.csproj | ||
| email-out-goed.http | ||
| global.json | ||
| Program.cs | ||
| README.md | ||
| requestbody.md | ||
| todo.md | ||
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=noneonly when no TXT exists at the DMARC name. Existing policy is preserved and subsequently validated bymail-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.