Files
felhom-controller/REPORT.md
T
admin 0e20eb19c1 v0.88.0: app-email SMTP relay (in-process shim + per-app injection)
In-process go-smtp shim (Shape 1): apps → shim → hub → Resend, Resend key stays
hub-side. From-header allowlist (reject 5xx pre-hub), single-shot raw-MIME forward,
status→SMTP mapping. Global + per-app toggles gate compose-time env injection from
.felhom.yml smtp_mapping. Hungarian UI on settings + app config pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:45:04 +02:00

73 lines
5.1 KiB
Markdown

# REPORT — App-email SMTP relay (controller side), v0.88.0
**Task:** `TASK` — SMTP app-relay (apps → in-controller shim → hub → Resend). Implementation class.
**Architecture (operator-confirmed):** **Shape 1** — the SMTP shim runs **in-process inside felhom-controller**,
reusing the controller's existing hub client. (The alternative, a separate `felhom-smtp-shim` container, is the
documented v2 path.)
## Confirmed baselines (verified against live repo)
| Repo | `main` @ start | Live version | Target |
|------|----------------|--------------|--------|
| felhom-controller | `7cddb88` | v0.87.0 (CHANGELOG top v0.86.0; 0.87.0 was a version-only build) | **v0.88.0** |
| felhom.eu (hub) | `4b97855` | **v0.17.0** (the Resend-key rotation already shipped 2026-06-29) | **v0.18.0** |
| app-catalog | `5d42ca1` | unversioned | — |
> **Deviation from the prompt:** the prompt assumed the hub was at v0.16.0 → v0.17.0, but the prerequisite
> Resend-key-rotation task already shipped as **v0.17.0** earlier today. So the hub leg targets **v0.18.0**.
> The prerequisite is satisfied (key out-of-band in `Secret/resend-api`, hub reads `RESEND_API_KEY` env).
## Files created / modified (controller)
**Created**
- `internal/mailrelay/policy.go` — From-header domain allowlist (validate-and-reject; fail-closed on parse error).
- `internal/mailrelay/forward.go` — single-shot Bearer POST of raw MIME to hub `/api/v1/mail`.
- `internal/mailrelay/server.go` — go-smtp server (two listeners, self-signed cert, accept-any AUTH incl. a
~15-line LOGIN sasl server), `Data` = read raw → From-policy → forward → map hub status to SMTP reply.
- `internal/mailrelay/lifecycle.go` — runtime start/stop so the global toggle applies without a restart.
- `internal/mailrelay/mailrelay_test.go`, `internal/stacks/mailenv.go`, `internal/stacks/mailenv_test.go`.
**Modified**
- `internal/config/config.go``MailRelayConfig` (listeners, shim host, From allowlist, kill-switch) + defaults.
- `internal/settings/settings.go` — global `AppEmail{Enabled,FromName}` toggle + accessors.
- `internal/stacks/metadata.go``SMTPMapping` block parse + `HasSMTPMapping`.
- `internal/stacks/deploy.go``AppConfig.EmailEnabled` (per-app toggle, persisted) + propagate in `SaveAppConfig`.
- `internal/stacks/manager.go``stackEnv` injects the relay env (when global+per-app on, app has a mapping).
- `internal/web/{server.go,handlers.go,tier2_config_handler.go}` — global toggle handler + route, per-app toggle
handler + route, page data.
- `internal/web/templates/{settings.html,deploy.html}` — Hungarian "Alkalmazás-email" card + per-app toggle.
- `cmd/controller/main.go` — wire the shim lifecycle (gated on hub configured + kill-switch), apply boot-time
toggle, close on shutdown, hand to the web server.
- `go.mod`/`go.sum``github.com/emersion/go-smtp` v0.24.0 + `go-sasl`.
## Green gate (local)
`go build ./... && go vet ./... && go test ./...`**PASS** (16 packages ok, 0 failures).
### Tests & §10 companion red-proofs
- **Passthrough not parse (§7 A / §10):** `TestData_HappyPath_ForwardsRawBytesUnchanged` asserts the forwarder
receives the **byte-equal** input (no parse/re-encode). PASS.
- **From-reject before forward (§7 B / §10):** `TestData_FromOutsideAllowlist_Rejected_NoForward` — 550 +
**zero** forwarder calls. Companion `…CompanionProof_GateRemovedLetsItThrough` — allowing the domain lets the
same message reach the forwarder once (proves the gate is load-bearing). Both PASS.
- **Single-shot on hub failure (§7 D / §10):** `TestData_HubUnreachable_SingleShot_Transient` — transient 4xx +
**exactly one** attempt (no retry loop). PASS.
- **Status mapping (§6):** `TestMapStatusToSMTP` 200→nil, 429/4xx→451, 5xx→554. PASS.
- **Toggles gate injection (§7 E / §8):** stacks `TestSMTPEnv_*` — both-on injects the full mapped env;
per-app-off / global-off / no-mapping inject nothing; Mealie-style mapping; household From-name override. PASS.
- **Real socket (Q1/Q2):** `TestServer_EndToEnd_STARTTLS` drives a real `net/smtp` STARTTLS send through the
listener. PASS. Plus `TestLifecycle_StartStopIdempotent`.
Test count: **+~17** controller test functions (mailrelay 9, stacks/mailenv 8); suite remains green.
## Deployment & live validation
See the cross-repo end-to-end result in `felhom.eu/REPORT.md`. Controller v0.88.0 deploys to guest 9201 via the
golden/bootstrap mechanism; the controller container must be joined to the app `traefik-public` network so apps
resolve the shim by the DNS name `felhom-controller`.
## Observations
- **Mealie uses plaintext (NONE), not STARTTLS** — Mealie has no accept-invalid-cert option, so STARTTLS to the
self-signed shim would fail; the spike validated plaintext on :2525 (Docker-network only). Deliberate,
spike-faithful deviation from the prompt's "TLS for mealie" wording.
- v1 is **single-shot, no spool** (per the Q6 decision). v2 = separate shim container + accept-and-spool retry +
`Resend-Idempotency-Key`.
- Free-tier ceiling is **100 emails/day** across the fleet; further apps just need an `smtp_mapping` block.
- No secrets are written to any committed file; the Resend key lives only hub-side (out-of-band).