tailwart/CLAUDE.md
Wayne Hayes 2eb8a0c225 Scaffold tailwart: Stalwart mailbox as a Tailscale sidecar
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>
2026-06-03 22:25:38 -04:00

5.1 KiB

CLAUDE.md — tailwart

Guidance for Claude Code in this repo. Read before editing.

What this is

A play deployment of Stalwart (all-in-one mail/JMAP/IMAP/SMTP server) wired, gratuitously, into three shared backends — Postgres, Redis, and Garage S3 — to see how far the federatedSocial Tailscale-sidecar pattern stretches past the fediverse apps. Target domain: infinidim.net (may become real later).

It is self-contained and outside /opt/federatedSocial on purpose: that's an upstream clone that git pull overwrites. tailwart owns its own .env, compose, config, ACL snippet, and Caddy build, and only reads from the tailnet (shared infra over MagicDNS) at runtime.

Architecture — two ends of one wire

  public IP host (tag:reverse-proxy)            tailnet-only mailbox
  ┌───────────────────────────┐                ┌────────────────────────┐
  │ caddy/  (caddy-l4)         │   tailnet      │ ts-stalwart sidecar     │
  │  :25 :465 :587 :143 :993 ──┼───WireGuard───▶│  stalwart (no WAN, no   │
  │  PROXY protocol v2         │                │  host ports)            │
  └───────────────────────────┘                └───────────┬────────────┘
        L7 JMAP vhost on the main Caddy                     │
        mail.infinidim.net → :8080                  ┌───────┴───────┐
                                                    ▼       ▼       ▼
                                              Postgres   Redis   Garage S3
                                            (the-record)(slo-time)(garage)
  • Mailbox (docker-compose.yml): Stalwart in a Tailscale sidecar via network_mode: service:ts-stalwart. Binds nothing on the host. All mail ports listen on the tailnet only.
  • Edge (caddy/): a layer-4 TCP proxy. Pure pass-through; Stalwart owns TLS. Can run on a different machine than the mailbox — the key idea.
  • Backends: data+fts → Postgres, blob → Garage S3, lookup/in-memory → Redis. One stalwart role/db, one Garage bucket, one Redis logical DB.

The .env contract

.env (gitignored) is the whole operator surface; .env.example is the template. Both compose files read it. Secrets reach Stalwart as env vars and are referenced from config/config.toml via %{env:NAME}% so the toml stays commit-safe. Never hardcode a value that belongs in .env — except the two spots a static file forces it: caddy/caddy.json dial targets and any MagicDNS host in the toml.

Sidecar boilerplate

Identical to federatedSocial's (TS_ACCEPT_DNS true, kernel networking, 127.0.0.1 healthcheck, ephemeral OAuth auth). Don't drift it. Tag: tag:stalwart.

Prerequisites (shared tailnet infra — already running for the fediverse)

  1. Postgres role + db: stalwart / STALWART_DB_NAME. Create via the federatedSocial bootstrap.sh flow or a one-off CREATE ROLE … LOGIN; CREATE DATABASE … OWNER ….
  2. Garage bucket stalwart-mail + grant the shared access key access to it.
  3. Redis: nothing to create — just use a dedicated logical DB index (STALWART_REDIS_DB) so we don't collide with the apps.
  4. Admin console: assign tag:stalwart to the OAuth client (Devices/Core + Keys/AuthKeys) and add acl-snippet.hujson to the policy.

Pitfalls (some learned the hard way next door)

  • Mail edge is layer 4, not layer 7. Don't try to give the L4 ports a normal Caddy vhost. SNI/Host routing doesn't apply to :25.
  • PROXY protocol or your mail reputation dies. Without it Stalwart sees the proxy's tailnet IP as every client → SPF/DNSBL/greylisting break. Both ends must agree (caddy.json proxy_protocol: v2 ↔ config [server.proxy] trusted-networks).
  • Stalwart config drifts between versions and migrates into the admin store after first boot. config/config.toml is a strawman — verify keys against the pinned image tag before trusting them. Pin the tag once it works.
  • POSTGRES_PASSWORD/role passwords only apply on an empty volume. If a password "doesn't work," the stored credential drifted — ALTER USER, don't re-init. And never test a password over 127.0.0.1 against these Postgres containers: pg_hba trusts loopback and accepts ANY password. Test over the tailnet (scram) or you'll fool yourself.
  • Outbound :25 is usually blocked on VPS. Set STALWART_SMARTHOST.
  • Mail forces WAN ports. :25 must be world-reachable for inbound federation — this is the one place the tailnet-only model can't hold. Keep submission/IMAP tailnet-only if you want a tighter surface.

What not to do

  • Don't put files in /opt/federatedSocial. Read its .env if you must; never write there.
  • Don't add ports: to the Stalwart container — the edge proxy is the only public surface, and it lives in caddy/.
  • Don't commit .env or a built Caddy binary (see .gitignore).
  • Don't break the sidecar netns boundary with bridge networks or host ports.