# 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 — DONE (2026-06-29) - **Built** `felhom-controller:0.88.0` on 192.168.0.180; **deployed** to guest 9201 via the golden/bootstrap mechanism (`docker pull` → `/etc/felhom-controller-image` → restart bootstrap). `docker ps`: `…:0.88.0 Up (healthy)`. The controller container is **already on `traefik-public`** (and bridge), so apps resolve the shim by `felhom-controller` — no network change needed. - **Global toggle via the REAL endpoint:** `POST /settings/app-email` (in-guest, the exact form the UI submits, with session cookie + `_csrf`) → HTTP 200; controller logged `[mailrelay] app-email shim ON (host=felhom-controller plain=[::]:2525 tls=[::]:2465)` and persisted `app_email.enabled=true` in settings.json. - **End-to-end happy path (real pipeline, real Resend send):** a raw MIME message with an **inline CID image**, From `vaultwarden@felhom.eu`, sent through the running shim from a throwaway container on `traefik-public` (`app → shim → hub → Resend`). Result: `MAIL 250 / RCPT 250 / DATA 250 "OK: queued"`. Shim log: `message relayed (from="vaultwarden@felhom.eu" rcpts=1 bytes=789 hub=200)`; hub log: `/api/v1/mail: relayed for demo-felhom`. The DATA-250 is returned only after the hub→Resend leg accepted (queued) the message. Recipient = the operator's own address; **inbox arrival is the operator's final confirmation** (Resend returned 250/queued for the verified `felhom.eu` domain, which the spike established delivers with inline image intact). The Resend key never touched the guest and is in no committed file. - **Security gate (live):** the same send From `evil@notfelhom.example` → `DATA 550 "5.7.1 sender address not permitted"` and **no `/api/v1/mail` call reached the hub** (rejected before forward — scenario B confirmed live). - **Method note:** the relay pipeline (the new, previously-unproven code) is validated against the live Resend account. The catalog `smtp_mapping` → per-app env injection is unit-tested (`TestSMTPEnv_*` incl. the Vaultwarden mapping); deploying the Vaultwarden app and clicking its admin "send test email" would additionally exercise that app's own SMTP client (already spike-proven) — recommended as the final manual smoke once the catalog sync has the new templates. > **Demo box state:** global app-email left **ON** on guest 9201 (the validated working state). No customer apps > have the per-app toggle on, so nothing else changed. ## 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).