# REPORT — App-email gap closure: plaintext listener + split-From, v0.89.0 **Task:** Close the two relay gaps from `FINDING-app-email-rollout-2026-06-29.md` so opportunistic-STARTTLS clients (cal.com, nextcloud) can use the relay. Controller mechanism + two catalog mappings + live send-tests. **Locked posture:** plaintext on the single-tenant app Docker bridge is accepted (no TLS/CA work). ## Baselines (verified) - felhom-controller `main` @ `6692a2f`, **v0.88.0 → v0.89.0**. app-catalog touched. Hub untouched (N/A). ## Files changed (controller) - `internal/mailrelay/server.go` — **Gap 1:** added a third listener `PlainNoTLSAddr` (default `:2526`), plaintext, with `TLSConfig` left **nil** so go-smtp does NOT advertise STARTTLS. `:2525`/`:2465` unchanged. `Start`/`Close`/addr-accessor updated for the third socket. - `internal/stacks/metadata.go` — **Gap 2:** `SMTPMapping` gains `TLSMode` (`yaml:"tls_mode"`) + `FromDomainVar` (`yaml:"from_domain_var"`). - `internal/stacks/mailenv.go` — `smtpEnv` now picks the port via `shimPortForTLSMode` (`""`/`starttls`→2525; `plaintext`→2526; `implicit-tls`→2465) instead of hardcoded 2525; split-From injection when `FromDomainVar` is set (`FromVar=` + `FromDomainVar=`), else the existing `@`. - `internal/config/config.go` — `mail_relay.plain_no_tls_listen` (default `:2526`). `cmd/controller/main.go` — pass `PlainNoTLSAddr` to the lifecycle Options. ## Green gate (local) `go build ./... && go vet ./... && go test ./...` — **PASS** (16 packages, 0 failures). - `TestSMTPEnv_PortByTLSMode` — plaintext→2526, starttls/empty→2525, implicit-tls→2465. **Companion** `TestShimPortForTLSMode` — plaintext and starttls must map to DIFFERENT ports (fails if the switch is dropped). - `TestSMTPEnv_SplitFrom` — `from_domain_var` set → two keys (`MAIL_FROM_ADDRESS=nextcloud`+`MAIL_DOMAIN=felhom.eu`), port 2526. **Companion** — a mapping without `from_domain_var` yields the single `vaultwarden@felhom.eu`. - `TestServer_PlainNoTLSListener_NoSTARTTLS` — `:2526` server has `TLSConfig==nil` & a real EHLO shows it does **not** advertise STARTTLS while `:2525` does. ## Deployed v0.89.0 + listener Built `:0.89.0` on 180, deployed to guest 9201 (golden/bootstrap). `docker ps`: `…:0.89.0 Up (healthy)`. Shim started **three** listeners: `plaintext+STARTTLS on :2525`, `implicit-TLS on :2465`, and the new `plaintext-only (no STARTTLS) listener on :2526`. ## Live send-tests - **nextcloud (Symfony Mailer) — Scenario B PROVEN:** toggle ON injected `SMTP_HOST=felhom-controller`, `SMTP_PORT=2526`, `SMTP_SECURE=` (empty), and the **split From** `MAIL_FROM_ADDRESS=nextcloud` + `MAIL_DOMAIN=felhom.eu`. **§8 env-consumption:** nextcloud applied it on redeploy (`occ config:system:get` shows `mail_smtphost=felhom-controller mail_smtpport=2526 mail_smtpsecure= mail_from_address=nextcloud mail_domain=felhom.eu`) — the official image reads these via `getenv()` every boot, **no occ step needed**. A real password-reset relayed: shim `relayed (from="nextcloud@felhom.eu" rcpts=1 bytes=20430 hub=200)`. The Symfony client sent **plaintext to :2526 and never attempted STARTTLS** → delivered. (Gap 1 + Gap 2 proven end-to-end with a real opportunistic-STARTTLS client.) - **cal.com (Nodemailer) — Scenario A PROVEN:** toggle ON injected `EMAIL_SERVER_HOST=felhom-controller`, `EMAIL_SERVER_PORT=2526`, `EMAIL_FROM=calcom@felhom.eu` (verified in-container). A real signup (HTTP 201 "Created user") triggered the verification email: shim `relayed (from="calcom@felhom.eu" rcpts=1 bytes=10260 hub=200)`. The Nodemailer client sent **plaintext to :2526 and never attempted STARTTLS** → delivered. (calcom needed three pre-existing template fixes first — see Observations.) - **No regression (Scenario C):** gitea redeployed under v0.89.0 still injects `SMTP_PORT=2525` + `smtp+starttls`; a real gitea forgot-password relayed `(from="gitea@felhom.eu" hub=200)` via the unchanged :2525 STARTTLS path. ## Observations - **Three pre-existing cal.com template bugs** surfaced (never caught — the bad image pin meant calcom never deployed): (1) image `v4.8.7` doesn't exist as a published image → `v4.6.9`; (2) missing `DATABASE_DIRECT_URL` (Prisma `migrate deploy` fails → incomplete schema → 500s); (3) healthcheck path `/api/health` is 404 in v4.x → `/api/auth/providers`. All fixed in the catalog. - **immich (Gap 3) remains unwired by decision** — it has no SMTP env (admin-UI/DB or `IMMICH_CONFIG_FILE` JSON); the next email piece is a config-file render+mount mechanism. - **Other email-capable apps now wire trivially** as `tls_mode: plaintext` (ghost, outline, grafana, …) — no per-app TLS determination needed under the accepted posture. - No secrets in any committed file; the Resend key stays hub-side.