From 4581a92781875d6b8a6097d90fee1762c5dd9f88 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 29 Jun 2026 08:45:23 +0200 Subject: [PATCH] app-email: smtp_mapping for vaultwarden + mealie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vaultwarden via STARTTLS (accepts self-signed shim cert); Mealie via plaintext (NONE) — no accept-invalid-cert option, spike-validated mode. Compose files reference injected ${SMTP_*}. README documents the pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 +++ README.md | 54 +++++++++ REPORT.md | 138 ++++++----------------- templates/mealie/.felhom.yml | 15 +++ templates/mealie/docker-compose.yml | 9 ++ templates/vaultwarden/.felhom.yml | 18 +++ templates/vaultwarden/docker-compose.yml | 9 ++ 7 files changed, 153 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432847e..0dfe43a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2026-06-29 — App-email: smtp_mapping for Vaultwarden + Mealie +- Added the `smtp_mapping` block to `templates/vaultwarden/.felhom.yml` and `templates/mealie/.felhom.yml`, + enabling managed outbound email (app → in-controller shim → hub → Resend) for the two spike-proven apps + (`SPIKE-smtp-app-relay-2026-06-28`). The controller injects `SMTP_*` at deploy/redeploy when app-email is + on (global + per-app); the From address is `@felhom.eu`. SMTP auth creds are intentionally left unset + (the shim accepts no-auth on the Docker network). + - **Vaultwarden:** STARTTLS (`SMTP_SECURITY=starttls`) + `SMTP_ACCEPT_INVALID_CERTS/HOSTNAMES=true` to + accept the shim's self-signed cert. + - **Mealie:** plaintext (`SMTP_AUTH_STRATEGY=NONE`) on :2525 — Mealie has no accept-invalid-cert option, so + STARTTLS to a self-signed shim would fail; plaintext to the Docker-network-only shim is the spike-validated + mode. +- Both `docker-compose.yml` files now reference the injected `${SMTP_*}` keys (with harmless defaults) so the + values reach the container; empty `SMTP_HOST` keeps mail disabled when the toggle is off. +- Documented the `smtp_mapping` pattern in `README.md` so further apps are easy adds. + ## 2026-06-28 — Add SparkyFitness (v0.17.2) — nutrition/workout tracker - New app `templates/sparkyfitness/{docker-compose.yml,.felhom.yml}`: a self-hosted nutrition/calorie + workout/weight tracker (alternative to wger). Three containers — nginx **frontend** (SPA :80, the sole diff --git a/README.md b/README.md index 62f8bdd..8fb485a 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,60 @@ deploy_fields: locked_after_deploy: true ``` +### App-email mapping (`smtp_mapping`) + +Apps that can send outbound email (password resets, invites, confirmations) get it through +one managed path: **app → in-controller SMTP shim → hub → Resend**. The Resend key stays +hub-side; nothing app-specific lives on the box. An app opts in by declaring `smtp_mapping`, +which renames the generic relay settings to that app's own env-var names. + +When app-email is on (the household's **global** toggle AND the app's **per-app** toggle), +the controller injects at deploy/redeploy: `host` = the on-box shim, `port` = `2525`, +`from` = `@felhom.eu`, the `security` value, the optional display name, and any +fixed `extra` vars. The values are derived from settings on every compose — never written to +`app.yaml` — so a toggle change applies on the next redeploy without touching secrets. The +shim accepts **no-auth** on the Docker network, so leave any `SMTP_USERNAME`/`SMTP_PASSWORD` +unset. + +**The compose file must reference the mapped `${VAR}` keys** (with a harmless default) so the +injected values reach the container, e.g. `- SMTP_HOST=${SMTP_HOST:-}`. An empty `SMTP_HOST` +keeps the app's mail disabled when the toggle is off. + +| Field | Meaning | +|-------|---------| +| `host_var` | env key receiving the shim host (required) | +| `port_var` | env key receiving the port `2525` (required) | +| `security_var` | env key receiving the TLS mode (optional) | +| `security_value` | the app's term for the chosen mode — `starttls`, `TLS`, `NONE`, … | +| `from_var` | env key receiving the From address (required) | +| `from_name_var` | env key receiving the From display name (optional) | +| `from_local` | local-part of the From address (defaults to the app slug) → `@felhom.eu` | +| `extra` | fixed extra env (e.g. accept-invalid-cert flags) | + +```yaml +# Vaultwarden — STARTTLS to the shim (it accepts the self-signed cert): +smtp_mapping: + host_var: SMTP_HOST + port_var: SMTP_PORT + security_var: SMTP_SECURITY + security_value: starttls + from_var: SMTP_FROM + from_name_var: SMTP_FROM_NAME + from_local: vaultwarden + extra: + SMTP_ACCEPT_INVALID_CERTS: "true" + SMTP_ACCEPT_INVALID_HOSTNAMES: "true" +``` + +> **TLS choice per app.** Prefer STARTTLS to the shim for apps that can accept a self-signed +> cert (an "accept invalid certs" option). For apps that **can't** (e.g. Mealie), use +> plaintext (`security_value: "NONE"`) on `2525` — the shim offers it on the Docker network and +> it was spike-validated. Never publish the shim off-box, so plaintext there is safe. + +Currently mapped: **Vaultwarden**, **Mealie** (the two apps proven in +`SPIKE-smtp-app-relay-2026-06-28`). Adding email to another app is just its `smtp_mapping` +block + the matching compose `${VAR}` lines. + ## App Catalog | App | DB Type | RAM (request / limit) | Pi | HDD Data | Subdomain | diff --git a/REPORT.md b/REPORT.md index 6565769..b4904a0 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,110 +1,38 @@ -# REPORT — Add SparkyFitness to the app catalog (+ live deploy & health check) +# REPORT — App-email `smtp_mapping` for Vaultwarden + Mealie -**Date:** 2026-06-28 -**Task class:** Implementation — pure catalog addition (no Go/controller/agent/hub code change). -**Outcome:** ✅ Template added, synced, deployed live on guest 9201, all 3 containers healthy, end-to-end -`/api` path verified through the public edge. One acceptance item (interactive account register/login) was -**deliberately left for a human** — see "NOT validated" below. +**Date:** 2026-06-29 +**Task:** SMTP app-relay (catalog leg). Add `smtp_mapping` to the two spike-proven apps so deployed apps can +send outbound email via the managed path (app → in-controller shim → hub → Resend). Implements +`felhom.eu/documentation/audits/SPIKE-smtp-app-relay-2026-06-28.md` §7. ---- +## Files modified +- `templates/vaultwarden/.felhom.yml` — added `smtp_mapping` (STARTTLS; SMTP_SECURITY=starttls, + SMTP_FROM=vaultwarden@felhom.eu, SMTP_FROM_NAME, + SMTP_ACCEPT_INVALID_CERTS/HOSTNAMES). SMTP_USERNAME/ + SMTP_PASSWORD intentionally unset. +- `templates/vaultwarden/docker-compose.yml` — reference the injected `${SMTP_*}` keys (harmless defaults). +- `templates/mealie/.felhom.yml` — added `smtp_mapping` (plaintext; SMTP_AUTH_STRATEGY=NONE on :2525, + SMTP_FROM_EMAIL=mealie@felhom.eu, SMTP_FROM_NAME). +- `templates/mealie/docker-compose.yml` — reference the injected `${SMTP_*}` keys (harmless defaults). +- `README.md` — documented the `smtp_mapping` pattern (field table + STARTTLS-vs-plaintext guidance) so further + apps are easy adds. +- `CHANGELOG.md` — entry. -## 1. Confirmed baselines +## How it works +When app-email is on (global toggle + per-app toggle), the controller injects at deploy/redeploy: +`SMTP_HOST` = the on-box shim, `SMTP_PORT` = 2525, From = `@felhom.eu`, the app's security value, plus the +mapping's fixed `extra` vars. The values are derived from settings each compose — never persisted to `app.yaml`. +An empty `SMTP_HOST` (toggle off) leaves the app's mail disabled. -| Repo | Baseline | After | -|------|----------|-------| -| app-catalog-felhom.eu | `main` @ `d7a6a17` (working tree clean; `templates/sparkyfitness/` was 404) | template commit `4351d08`, docs commit (this push) | -| felhom-controller (running on demo) | **v0.87.0** (`gitea.dooplex.hu/admin/felhom-controller:0.87.0`, healthy on guest 9201) | unchanged — template proven-compatible against 0.87.0 | +## Validation +- Both `.felhom.yml` and both `docker-compose.yml` YAML-validated OK. +- Field names match the controller's `stacks.SMTPMapping` struct tags exactly (covered by the controller's + `TestMetadata_SMTPMappingParse` and the Vaultwarden/Mealie env-injection tests). +- Git sync picks up the change within ~15 min (or via "Sablonok frissítése"). -The running controller renders this template's `type: subdomain` + `data_key` fields correctly (deploy -screen showed Domain auto-filled, Subdomain `sparky`, and all 4 auto-secrets) — so **no STOP condition**; -no controller patch was needed. - -## 2. Files created - -- `templates/sparkyfitness/docker-compose.yml` -- `templates/sparkyfitness/.felhom.yml` - -Commits on `main`: -- `4351d08` — the two template files (after lint + image probes). -- (this push) — CHANGELOG + README + REPORT docs. - -`templates.json` (legacy Portainer index) intentionally left untouched. No other catalog apps modified. - -## 3. §1.3 image healthcheck-tooling findings (verified on build server 192.168.0.180, v0.17.2) - -| Image | Probe authored | Finding | Final probe kept | -|-------|----------------|---------|------------------| -| `codewithcj/sparkyfitness_server:v0.17.2` | `node -e` GET `/api/health` | `NODE_OK` (node on PATH via `--entrypoint node`) | **kept** the node-exec probe | -| `codewithcj/sparkyfitness:v0.17.2` (frontend) | `wget --spider` | both present: `/usr/bin/wget` **and** `/usr/bin/curl` | **kept** the `wget --spider` probe | - -No probe changes were required. Compose lints clean -(`docker compose config -q` with the 4 `${...}` vars set to dummies → `LINT_OK`). - -## 4. Deploy method + evidence - -**Method:** real UI flow via **claude-in-chrome** (no hand-written `app.yaml`). -1. `https://felhom.demo-felhom.eu` → Alkalmazások → **"Sablonok frissítése"** (sync). App count 55→56, - Telepíthető 52→53; SparkyFitness card appeared (`/stacks/sparkyfitness/deploy`). -2. Deploy screen: Domain auto = `demo-felhom.eu`; Subdomain default `sparky`; 4 secrets shown - "Automatikusan generálva"; memory gate **696 MB / 1664 MB (41%)** → fits, **not refused** (Scenario C - did not trigger). Clicked **"Telepítés indítása"** → "Konfiguráció mentve" → containers started. - -**`docker ps` (guest 9201):** -``` -sparkyfitness-frontend codewithcj/sparkyfitness:v0.17.2 Up (healthy) -sparkyfitness-server codewithcj/sparkyfitness_server:v0.17.2 Up (healthy) -sparkyfitness-db postgres:15-alpine Up (healthy) -``` - -**Server log excerpts (clean — Scenario A):** -``` -[INFO] Successfully applied migration: ...20260622120000_add_water_estimated_to_exercise_entries.sql -[INFO] Ensuring permissions for role: "sparkyapp" -[INFO] Successfully ensured permissions for role: "sparkyapp" -[INFO] Permissions granted to application user. -[INFO] Successfully applied all RLS policies. -[AUTH] Better Auth handler successfully mounted. -[INFO] SparkyFitnessServer listening on port 3010 -[INFO] Incoming request: GET /api/health -``` -No crash loop; **no EACCES** on `/app/SparkyFitnessServer/uploads`. Both DB roles confirmed present in the -DB (`SELECT rolname ... → sparky, sparkyapp`). - -**Secrets (Scenario B) — verified without exposing values** (from `docker inspect` env of the server): -- `SPARKY_FITNESS_API_ENCRYPTION_KEY` length = **64** (hex:32 ✓) -- `BETTER_AUTH_SECRET` length = **64** (✓) -- `SPARKY_FITNESS_FRONTEND_URL = https://sparky.demo-felhom.eu` (public https ✓ — CORS/Better-Auth origin) -- `SPARKY_FITNESS_DB_USER = sparky`, `SPARKY_FITNESS_APP_DB_USER = sparkyapp` (two distinct roles ✓) -- `SPARKY_FITNESS_DISABLE_SIGNUP = false` (household can register ✓) -All four secrets are `locked_after_deploy`; the two crypto keys carry `data_key: true`. - -**Browser checks:** -- `https://sparky.demo-felhom.eu/login` → SparkyFitness **native email/password login + Sign Up** page - loads over a **valid TLS cert** (no warning). No OIDC buttons (correct for the customer template). -- `https://sparky.demo-felhom.eu/api/health` (through the public Cloudflare edge → Traefik → frontend - nginx `/api` proxy → server) → **`{"status":"UP"}`**. This proves the frontend→server proxy + public - routing + TLS end-to-end. - -## 5. NOT yet validated (explicit) - -- **Interactive account register + login.** Deliberately not performed by me: creating an account / - entering credentials to authenticate falls under a browser-safety guardrail (prohibited even on - request). Everything up to the authenticated API path is proven (healthy server, `/api/health` proxied - through the public edge, CORS origin correctly set). **Recommended human step:** open - `https://sparky.demo-felhom.eu`, click **Sign Up**, register a throwaway account, confirm login reaches - the dashboard (≈30s). Expected to pass given the verified config. -- **Backup / restore of this app** (incl. the `data_key` recovery path for the two crypto keys) — not - exercised here. -- **Upload body-size limits** for profile/exercise images (the k3s ingress set `proxy-body-size: 50m`; - the Felhom Traefik path was not size-tested). -- **Garmin / MCP integrations** — out of scope (commented out upstream; not enabled). - -## 6. Observations (noticed, not acted on) - -- Upstream's compose uses `postgres:18` + `:latest`; per the validated k3s manifest we pinned - `postgres:15-alpine` with an explicit `PGDATA` subdir — kept that proven path. -- The frontend image ships **both** wget and curl; the server image runs node as a valid exec entrypoint — - both healthchecks are robust without extra tooling. -- Demo guest 9201 has only 2 GB total RAM; SparkyFitness `mem_limit` is 1792M. The `mem_request` (400M) - gate passed and real usage is far below the limit, but on a 2 GB box co-tenancy headroom is thin — worth - noting for nodes running several heavier apps. +## Notes / deviation +- **Mealie uses plaintext (NONE), not STARTTLS.** Mealie has no accept-invalid-cert option, so STARTTLS to the + self-signed shim would fail cert verification; the spike validated Mealie via plaintext on :2525 (the shim is + Docker-network-only, never published). This is the spike-faithful choice (spike §7 mealie table = NONE), + deviating from the prompt's Part-4 "TLS for mealie" wording. +- **Scope = these two proven apps only.** Further apps (immich, nextcloud, …) are a follow-on: each just needs + its own `smtp_mapping` block + the matching compose `${VAR}` lines. diff --git a/templates/mealie/.felhom.yml b/templates/mealie/.felhom.yml index 07487e1..991f51f 100644 --- a/templates/mealie/.felhom.yml +++ b/templates/mealie/.felhom.yml @@ -62,3 +62,18 @@ healthcheck: checks: - type: tcp port: 9000 + +# --- App-email mapping (apps → in-controller shim → hub → Resend) --- +# When app-email is on (global toggle + this app's per-app toggle), the controller injects +# host = the on-box shim, port = 2525, From = mealie@felhom.eu. Mealie has NO accept-invalid- +# cert option, so it talks PLAINTEXT (SMTP_AUTH_STRATEGY=NONE) to the shim on 2525 — the +# spike-validated mode (SPIKE-smtp-app-relay-2026-06-28 §7). SMTP_USER/SMTP_PASSWORD stay +# unset (no auth needed; the shim holds no Resend key). +smtp_mapping: + host_var: SMTP_HOST + port_var: SMTP_PORT + security_var: SMTP_AUTH_STRATEGY + security_value: "NONE" + from_var: SMTP_FROM_EMAIL + from_name_var: SMTP_FROM_NAME + from_local: mealie diff --git a/templates/mealie/docker-compose.yml b/templates/mealie/docker-compose.yml index 122d8a3..d2d989b 100644 --- a/templates/mealie/docker-compose.yml +++ b/templates/mealie/docker-compose.yml @@ -26,6 +26,15 @@ services: - MAX_WORKERS=1 - WEB_CONCURRENCY=1 - BASE_URL=https://${SUBDOMAIN}.${DOMAIN} + # App-email (managed relay). Injected by the controller only when app-email is on + # (global + per-app); empty SMTP_HOST = Mealie mail stays disabled. Mealie has no + # accept-invalid-cert option, so the relay uses plaintext (NONE) to the on-box shim — + # the spike-validated mode. See .felhom.yml smtp_mapping. + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-25} + - SMTP_AUTH_STRATEGY=${SMTP_AUTH_STRATEGY:-NONE} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-} + - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-} volumes: - mealie_data:/app/data/ networks: diff --git a/templates/vaultwarden/.felhom.yml b/templates/vaultwarden/.felhom.yml index 1f53c60..ce55aaa 100644 --- a/templates/vaultwarden/.felhom.yml +++ b/templates/vaultwarden/.felhom.yml @@ -84,3 +84,21 @@ healthcheck: path: "/alive" expect: status: 200 + +# --- App-email mapping (apps → in-controller shim → hub → Resend) --- +# When app-email is on (global toggle + this app's per-app toggle), the controller injects +# the relay SMTP settings: host = the on-box shim, port = 2525, From = vaultwarden@felhom.eu. +# Vaultwarden uses STARTTLS to the shim and accepts its self-signed cert +# (SMTP_ACCEPT_INVALID_CERTS/HOSTNAMES). SMTP_USERNAME/SMTP_PASSWORD are intentionally left +# unset — the shim accepts no-auth on the Docker network and holds no Resend key. +smtp_mapping: + host_var: SMTP_HOST + port_var: SMTP_PORT + security_var: SMTP_SECURITY + security_value: starttls + from_var: SMTP_FROM + from_name_var: SMTP_FROM_NAME + from_local: vaultwarden + extra: + SMTP_ACCEPT_INVALID_CERTS: "true" + SMTP_ACCEPT_INVALID_HOSTNAMES: "true" diff --git a/templates/vaultwarden/docker-compose.yml b/templates/vaultwarden/docker-compose.yml index 4fccb39..e64aef8 100644 --- a/templates/vaultwarden/docker-compose.yml +++ b/templates/vaultwarden/docker-compose.yml @@ -28,6 +28,15 @@ services: - ADMIN_TOKEN=${ADMIN_TOKEN:-} - WEBSOCKET_ENABLED=true - TZ=Europe/Budapest + # App-email (managed relay). Injected by the controller only when app-email is on + # (global + per-app); empty SMTP_HOST = Vaultwarden mail stays disabled. See .felhom.yml smtp_mapping. + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_SECURITY=${SMTP_SECURITY:-off} + - SMTP_FROM=${SMTP_FROM:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-} + - SMTP_ACCEPT_INVALID_CERTS=${SMTP_ACCEPT_INVALID_CERTS:-false} + - SMTP_ACCEPT_INVALID_HOSTNAMES=${SMTP_ACCEPT_INVALID_HOSTNAMES:-false} volumes: - vaultwarden_data:/data networks: