Over-engineered play stack for infinidim.net — Stalwart wired into the shared Postgres + Redis + Garage S3 over the tailnet, with no WAN presence. Public mail ports are fronted by a separate caddy-l4 layer-4 proxy (caddy/) that can run on any tailnet host tagged tag:reverse-proxy — decoupled from the mailbox. - docker-compose.yml: ts-stalwart sidecar + stalwart, backends via MagicDNS - config/config.toml: PG (data/fts) + Redis (lookup) + S3 (blob) strawman - caddy/: xcaddy build with caddy-l4, JSON layer-4 mail proxy, own compose - acl-snippet.hujson: tag:stalwart owner + backend/edge grants - .env.example + gitignored .env (pulled from shared infra) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
3.8 KiB
TOML
109 lines
3.8 KiB
TOML
# ============================================================================
|
|
# Stalwart config — the over-engineered wiring: Postgres + Redis + Garage S3.
|
|
# ============================================================================
|
|
# THIS IS A STRAWMAN. Stalwart's config schema shifts between releases and a
|
|
# lot of runtime config moves into the admin store after first boot. Treat
|
|
# every key here as "verify against the version you pinned" — the same pitfall
|
|
# the federatedSocial repo learned the hard way. Pin the image tag once it works.
|
|
#
|
|
# Secrets come in via %{env:NAME}% so this file stays commit-safe.
|
|
# ============================================================================
|
|
|
|
[server]
|
|
hostname = "%{env:STALWART_HOSTNAME}%"
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Listeners — all bind inside the sidecar netns (tailnet only). The public
|
|
# edge is caddy/ (layer 4). PROXY protocol carries the real client IP across
|
|
# the proxy hop, which mail reputation (SPF/DNSBL/greylisting) depends on.
|
|
# ----------------------------------------------------------------------------
|
|
[server.listener.smtp]
|
|
bind = ["[::]:25"]
|
|
protocol = "smtp"
|
|
|
|
[server.listener.submission]
|
|
bind = ["[::]:587"]
|
|
protocol = "smtp"
|
|
|
|
[server.listener.submissions]
|
|
bind = ["[::]:465"]
|
|
protocol = "smtp"
|
|
tls.implicit = true
|
|
|
|
[server.listener.imap]
|
|
bind = ["[::]:143"]
|
|
protocol = "imap"
|
|
|
|
[server.listener.imaptls]
|
|
bind = ["[::]:993"]
|
|
protocol = "imap"
|
|
tls.implicit = true
|
|
|
|
[server.listener.http]
|
|
# JMAP, autoconfig/autodiscover, and the web admin. Fronted by the existing
|
|
# layer-7 Caddy vhost (mail.infinidim.net), not the L4 proxy.
|
|
bind = ["[::]:8080"]
|
|
protocol = "http"
|
|
|
|
# Trust the PROXY-protocol header only from the tailnet hop (CGNAT range).
|
|
[server.proxy]
|
|
trusted-networks = ["100.64.0.0/10"]
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stores — three backends because we can.
|
|
# ----------------------------------------------------------------------------
|
|
[store.pg]
|
|
type = "postgresql"
|
|
host = "%{env:DB_HOST}%"
|
|
port = 5432
|
|
database = "%{env:STALWART_DB_NAME}%"
|
|
user = "%{env:STALWART_DB_USER}%"
|
|
password = "%{env:STALWART_DB_PASSWORD}%"
|
|
|
|
[store.redis]
|
|
type = "redis"
|
|
urls = ["%{env:REDIS_URL}%"]
|
|
|
|
[store.s3]
|
|
type = "s3"
|
|
endpoint = "%{env:S3_ENDPOINT}%"
|
|
region = "%{env:S3_REGION}%"
|
|
bucket = "%{env:S3_BUCKET}%"
|
|
access-key = "%{env:S3_ACCESS_KEY}%"
|
|
secret-key = "%{env:S3_SECRET_KEY}%"
|
|
|
|
# Which store backs which concern.
|
|
[storage]
|
|
data = "pg" # accounts, mailboxes, metadata, indexes
|
|
fts = "pg" # full-text search
|
|
blob = "s3" # message bodies + attachments → Garage
|
|
lookup = "redis" # rate limits, caches, token revocation (distributed)
|
|
directory = "internal"
|
|
|
|
[directory.internal]
|
|
type = "internal"
|
|
store = "pg"
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Outbound — relay via smarthost when the VPS blocks direct :25.
|
|
# ----------------------------------------------------------------------------
|
|
[queue.outbound]
|
|
# If STALWART_SMARTHOST is set, route everything through it; else direct MX.
|
|
next-hop = "%{env:STALWART_SMARTHOST}%"
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# TLS — Stalwart terminates mail TLS itself (the L4 proxy is pass-through),
|
|
# so it needs the cert. DNS-01 works fine behind the proxy (no inbound needed).
|
|
# Swap to a mounted certbot cert by pointing [certificate] at files instead.
|
|
# ----------------------------------------------------------------------------
|
|
[acme.le]
|
|
directory = "https://acme-v02.api.letsencrypt.org/directory"
|
|
challenge = "dns-01"
|
|
provider = "%{env:STALWART_ACME_PROVIDER}%"
|
|
secret = "%{env:STALWART_ACME_TOKEN}%"
|
|
domains = ["%{env:STALWART_HOSTNAME}%"]
|
|
|
|
[authentication.fallback-admin]
|
|
user = "admin"
|
|
secret = "%{env:STALWART_FALLBACK_ADMIN_SECRET}%"
|