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

5.1 KiB

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.goMailRelayConfig (listeners, shim host, From allowlist, kill-switch) + defaults.
  • internal/settings/settings.go — global AppEmail{Enabled,FromName} toggle + accessors.
  • internal/stacks/metadata.goSMTPMapping block parse + HasSMTPMapping.
  • internal/stacks/deploy.goAppConfig.EmailEnabled (per-app toggle, persisted) + propagate in SaveAppConfig.
  • internal/stacks/manager.gostackEnv 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.sumgithub.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).