From 2241663823a9e6e3eb7d6ad0e6d586471d848d24 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 3 Apr 2026 20:04:09 +0200 Subject: [PATCH] docs: add Stalwart mail server documentation Complete documentation for the self-hosted email infrastructure: architecture, Stalwart config, DNS records, account management, Fritz!Box port forwarding TODO, troubleshooting, and Brevo fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/MAIL_SERVER.md | 215 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/MAIL_SERVER.md diff --git a/docs/MAIL_SERVER.md b/docs/MAIL_SERVER.md new file mode 100644 index 000000000..8097fbf96 --- /dev/null +++ b/docs/MAIL_SERVER.md @@ -0,0 +1,215 @@ +# Mail Server — Stalwart on Mac Mini + +Self-hosted email infrastructure using Stalwart (Rust) for all transactional emails. +Fully replaces Brevo SMTP — no external email provider dependency. + +## Architecture + +``` +Browser (Register/Reset/etc.) + → mana-auth (Hono/Bun, Port 3001) + → mana-notify (Go, Port 3013) via POST /api/v1/notifications/send + → Stalwart (Rust, Port 587 internal) via SMTP STARTTLS + → Recipient MX (e.g. gmail-smtp-in.l.google.com, Port 25) +``` + +### Key Design Decisions + +- **mana-auth has no SMTP dependency** — sends all emails via mana-notify HTTP API +- **mana-notify is the single SMTP gateway** — provider swappable via env vars +- **Stalwart runs inside the Docker stack** — same network as all other services +- **DKIM signing is automatic** — Stalwart signs all outgoing mail with Ed25519 + RSA keys + +## Components + +| Component | Role | Container | Port | +|-----------|------|-----------|------| +| mana-auth | Triggers emails (verify, reset, invite) | `mana-auth` | 3001 | +| mana-notify | Central email gateway with retry/queue | `mana-core-notify` | 3013 | +| Stalwart | SMTP server, DKIM signing, delivery | `mana-mail` | 25, 587, 465, 993, 8443 | + +## Configuration + +### Docker Compose (`docker-compose.macmini.yml`) + +```yaml +stalwart: + image: stalwartlabs/stalwart:latest + container_name: mana-mail + restart: always + mem_limit: 256m + ports: + - "25:25" # SMTP (inbound from other servers) + - "587:587" # Submission (outbound from mana-notify) + - "465:465" # SMTPS + - "993:993" # IMAPS + - "8443:8080" # Web Admin UI + volumes: + - stalwart_data:/opt/stalwart-mail +``` + +### mana-notify SMTP Environment + +```env +SMTP_HOST=stalwart # Docker service name +SMTP_PORT=587 +SMTP_USER=noreply # Stalwart username (without @domain) +SMTP_PASSWORD=ManaNoReply2026! +SMTP_FROM=ManaCore +SMTP_INSECURE_TLS=true # Self-signed cert inside Docker network +``` + +### mana-auth Environment + +```env +MANA_NOTIFY_URL=http://mana-notify:3013 # Routes emails through mana-notify +MANA_CORE_SERVICE_KEY= # Auth for mana-notify API +``` + +## Stalwart Admin + +### Web UI + +Access via SSH tunnel: `ssh -L 8443:localhost:8443 mana-server` +Then open `http://localhost:8443` + +- **Username:** `admin` +- **Password:** Set via `STALWART_ADMIN_PASSWORD` env var + +### Accounts + +| Account | Email | Role | Purpose | +|---------|-------|------|---------| +| admin | — | superuser | Admin access, not for SMTP | +| noreply | noreply@mana.how | user | SMTP sending for all transactional emails | +| postmaster | postmaster@mana.how | user | DMARC reports, bounces | + +### Creating Accounts via API + +```bash +# Password must be SHA512-crypt hashed ($6$ prefix) +HASH=$(docker exec mana-mail sh -c 'echo -n "PASSWORD" | openssl passwd -6 -stdin') + +curl -u admin:PASSWORD http://localhost:8443/api/principal -X POST \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"individual\",\"name\":\"USERNAME\",\"secrets\":[\"$HASH\"],\"emails\":[\"USER@mana.how\"]}" + +# Assign 'user' role (required for SMTP access) +curl -u admin:PASSWORD http://localhost:8443/api/principal/USERNAME -X PATCH \ + -H "Content-Type: application/json" \ + -d '[{"action":"set","field":"roles","value":["user"]}]' +``` + +## DNS Records (Cloudflare) + +All records are set on `mana.how` zone: + +| Type | Name | Content | Proxy | +|------|------|---------|-------| +| A | `mail` | `194.191.241.139` | DNS only | +| MX | `@` | `mail.mana.how` (Priority 10) | — | +| TXT | `@` | `v=spf1 a mx a:mail.mana.how ~all` | — | +| TXT | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mana.how` | — | +| TXT | `mana2026._domainkey` | `v=DKIM1; k=ed25519; h=sha256; p=oR3PJitX2xsZh5sFm1Lt5gMqR2jureP2WxVdY9CTjk4=` | — | +| TXT | `rsa2026._domainkey` | `v=DKIM1; k=rsa; h=sha256; p=MIIBIjANBgkqhkiG9w0BAQE...` (full key in Cloudflare) | — | + +### Regenerating DKIM Keys + +```bash +curl -u admin:PASSWORD http://localhost:8443/api/dkim -X POST \ + -H "Content-Type: application/json" \ + -d '{"algorithm":"Ed25519","selector":"mana2026","domain":"mana.how"}' + +# Get DNS records to update in Cloudflare +curl -u admin:PASSWORD http://localhost:8443/api/dns/records/mana.how +``` + +## Port Forwarding (Fritz!Box) — TODO + +For **receiving** external emails (bounces, replies), the router must forward these ports to the Mac Mini (`192.168.178.131`): + +| Port | Protocol | Purpose | +|------|----------|---------| +| 25 | TCP | SMTP inbound (other mail servers deliver here) | +| 587 | TCP | Submission (external mail clients) | +| 465 | TCP | SMTPS (external mail clients, implicit TLS) | + +### Fritz!Box Setup + +1. Open `http://fritz.box` → Login +2. Go to **Internet → Freigaben → Portfreigaben** +3. Click **Gerät für Freigaben hinzufügen** → Select Mac Mini (`192.168.178.131`) +4. Add port forwarding for 25, 587, 465 (TCP each) +5. Save and apply + +**Note:** Port forwarding is only needed for mail reception. Outgoing mail delivery (Port 25 outbound) works without forwarding and is already functional. + +### Verify Port Forwarding + +```bash +# From an external machine: +nc -z mail.mana.how 25 # Should connect +nc -z mail.mana.how 587 # Should connect +``` + +## Stalwart Config Customizations + +The following were added to `/opt/stalwart/etc/config.toml` inside the container: + +```toml +[lookup.default] +hostname = "mail.mana.how" + +[server.security] +analysis.scan-ban = false # Disable auto-ban for Docker internal IPs + +[server.allowed-ip] +172.18.0.0/16 = true # Trust Docker network +``` + +**Important:** These changes are inside the container volume (`stalwart_data`). They persist across restarts but would be lost if the volume is deleted. + +## Troubleshooting + +### Check email delivery + +```bash +# mana-notify logs (did it send?) +docker logs mana-core-notify --since 5m + +# Stalwart logs (did it deliver?) +docker exec mana-mail tail -30 /opt/stalwart/logs/stalwart.log.$(date +%Y-%m-%d) +``` + +### Common errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `mana-notify error: 401` | Service key mismatch | Ensure `MANA_CORE_SERVICE_KEY` matches between mana-auth and mana-notify | +| `smtp not configured` | Empty SMTP_USER or SMTP_PASSWORD | Check `.env` on server, restart mana-notify | +| `security.ip-blocked` | Stalwart banned mana-notify IP | Restart Stalwart container (clears bans) | +| `535 Authentication credentials invalid` | Wrong password or username format | Use username without domain (`noreply`, not `noreply@mana.how`) | +| `550 SPF/DKIM did not pass` | DNS records missing or not propagated | Verify with `dig +short @1.1.1.1 mana.how TXT` | +| `Messages missing Message-ID` | Email missing RFC 5322 headers | mana-notify adds Message-ID + Date automatically | + +### Switching back to Brevo (emergency fallback) + +Update `.env` on Mac Mini: +```env +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USER=94cde5002@smtp-brevo.com +SMTP_PASSWORD= +SMTP_INSECURE_TLS=false +``` +Then: `docker compose -f docker-compose.macmini.yml up -d mana-notify` + +## Resource Usage + +| Metric | Value | +|--------|-------| +| RAM (idle) | ~50 MB | +| RAM (under load) | ~100 MB | +| Docker mem_limit | 256 MB | +| Disk (data volume) | ~10 MB initially | +| Container image | ~30 MB |