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>
This commit is contained in:
@@ -1,5 +1,33 @@
|
||||
## Changelog
|
||||
|
||||
### v0.88.0 — App-email SMTP relay: in-process shim + per-app injection (2026-06-29)
|
||||
- **What:** deployed apps can now send outbound email (password resets, invites, confirmations) through one
|
||||
managed path — **app → in-controller SMTP shim → hub → Resend** — with the Resend key staying hub-side.
|
||||
Implements `SPIKE-smtp-app-relay-2026-06-28.md` (verdict READY). Architecture: **Shape 1**, the shim runs
|
||||
**in-process inside the controller** (operator-confirmed), reusing the controller's existing hub client.
|
||||
- **New `internal/mailrelay/`:** a `go-smtp` server with two listeners — `:2525` plaintext+STARTTLS and
|
||||
`:2465` implicit-TLS (self-signed cert generated at boot, CN/SAN = the shim service name). Advertises AUTH
|
||||
PLAIN+LOGIN and **accepts any credentials, ignoring them** (apps send none; some require the offer; a
|
||||
~15-line LOGIN sasl server fills go-sasl's gap). `policy.go` validates the **From header** domain against
|
||||
an allowlist (default `felhom.eu`) and rejects with a clean 5xx **before** any hub call. `forward.go`
|
||||
POSTs the **raw MIME** to the hub `POST /api/v1/mail` with the controller's hub Bearer key — **single-shot**
|
||||
(no retry, no spool in v1); the hub HTTP status maps to an SMTP reply (2xx→250, 4xx→451, 5xx→554) so the
|
||||
app surfaces the real outcome. `lifecycle.go` starts/stops the shim at runtime so the global toggle takes
|
||||
effect without a controller restart. Listeners bind to the app Docker network only — never host/internet.
|
||||
- **Settings + injection:** new global **app-email** toggle (`settings.AppEmail{Enabled,FromName}`); new
|
||||
`.felhom.yml` **`smtp_mapping`** block (renames host/port/security/from/from-name to an app's env keys, plus
|
||||
fixed `extra` vars); per-app toggle persisted in `app.yaml` (`AppConfig.EmailEnabled`). The relay env is
|
||||
injected at compose time in `stackEnv` (host=shim, port=2525, security/from per mapping) **only when**
|
||||
global ON + per-app ON + the app has a mapping — derived each compose, never persisted. New
|
||||
`config.MailRelayConfig` (listeners, shim host, From allowlist; kill-switch).
|
||||
- **UI (Hungarian):** Settings page "Alkalmazás-email" card (global toggle + optional household From-name);
|
||||
per-app "Email-küldés" toggle on the deployed app's config page (only for apps with `smtp_mapping`),
|
||||
save → recreate the stack to apply.
|
||||
- **Tests:** `mailrelay` (happy-path passthrough byte-equality, From-reject-before-forward + companion,
|
||||
single-shot-on-hub-failure + companion, status mapping, LOGIN lifecycle, real-socket STARTTLS end-to-end);
|
||||
`stacks` (mapping parse, both-toggles-on injection, per-app/global-off no-injection, no-mapping, Mealie-style
|
||||
mapping, household From-name). New dep `github.com/emersion/go-smtp` v0.24.0 + go-sasl.
|
||||
|
||||
### v0.86.0 — Phase 2 managed updates: floor-driven auto-update (2026-06-27)
|
||||
- **What:** the controller now honors an operator-enforced **minimum version** (FLOOR) delivered on the
|
||||
hub report ACK and **auto-updates to the floor** when below it — the managed default (no customer
|
||||
|
||||
@@ -1,73 +1,72 @@
|
||||
# REPORT — v0.86.0: Phase 2 managed updates — floor-driven auto-update
|
||||
# REPORT — App-email SMTP relay (controller side), v0.88.0
|
||||
|
||||
**Repo:** `felhom-controller` · **Version:** `v0.85.1` → **`v0.86.0`** · **Date:** 2026-06-27
|
||||
**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.)
|
||||
|
||||
## Why
|
||||
Phase 1 shipped an opt-in "update to latest" button. Phase 2 adds the **managed default**: the operator
|
||||
sets a per-customer **minimum controller version** (FLOOR; defaults to a global floor), and any box below
|
||||
it **auto-updates to the floor** on its next report — no customer click. Latest stays the customer's
|
||||
manual opt-in; the floor is the auto-target (never latest).
|
||||
## 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 | — |
|
||||
|
||||
## Files changed
|
||||
- `internal/report/pusher.go` — `PushResponse` += `min_controller_version`, `latest_version`. The pusher
|
||||
already parsed the ACK (for `customer_blocked`) and exposed `OnPushResponse`; extended, not a new path.
|
||||
*(The task spec pointed at `notify/notifier.go`'s `io.Discard` response-drops, but the real periodic
|
||||
report sender is `pusher.go` — it already had the seam, so the floor is read there.)*
|
||||
- `cmd/controller/main.go` — the existing `OnPushResponse` now also `updater.SetFloor(...)` +
|
||||
`updater.MaybeAutoUpdate()`. Rides the existing report cycle; no new timer/endpoint.
|
||||
- `internal/selfupdate/updater.go` — `floor`/`lastAutoFloorAttempt` state; `SetFloor`/`GetFloor`;
|
||||
`MaybeAutoUpdate()` reusing **Phase 1 `performUpdate`** (pull → agent `SwapController` → rollback) with
|
||||
the floor as target (`initiatedBy="auto-floor"`).
|
||||
- `internal/web/handlers.go` + `templates/settings.html` — Hungarian floor display + auto restart-poll.
|
||||
> **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).
|
||||
|
||||
## Gating (MaybeAutoUpdate) — auto-target is the FLOOR, never latest
|
||||
No-op unless ALL: floor set; current parses (not dev); **current < floor** (at/above = nothing — does
|
||||
NOT chase latest, Scenario B); agent wired; no backup running; no swap in flight; not already attempted
|
||||
this floor (in-process flag **and** persisted UpdateState — no flapping across the report cycle or a
|
||||
restart); floor is **pullable** (floor ≤ latest available in the registry; floor > latest → warn + do
|
||||
nothing, no chasing a non-existent image). Rollback on failure is Phase 1's (the agent) — not duplicated.
|
||||
## 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`.
|
||||
|
||||
## Tests — `go build ./... && go vet ./... && go test ./...` green
|
||||
`internal/selfupdate/floor_test.go`:
|
||||
- below-floor → updates to the **floor** ref (fake agent asserts `…:0.87.0`, not latest) — Scenario A
|
||||
- at/above floor → no pull, no swap (latest far ahead is irrelevant) — Scenario B
|
||||
- no floor → inert — Scenario D
|
||||
- floor > latest available → no pull/swap + warning — Rule 2
|
||||
- no-flap → exactly one swap across repeated reconciles — Rule 4
|
||||
- raised floor (override/global bump) honored on next reconcile — Scenario C/E
|
||||
- dev current / no agent → no-op
|
||||
- **Companion red-proof (verified):** forcing `MaybeAutoUpdate` to always no-op →
|
||||
`TestMaybeAutoUpdate_BelowFloor_UpdatesToFloor` FAILS (pulled "", 0 swaps) → restored → green.
|
||||
**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`.
|
||||
|
||||
## Live test — floor auto-update 0.86.0 → 0.87.0 on demo 9201 — PASS
|
||||
## Green gate (local)
|
||||
`go build ./... && go vet ./... && go test ./...` — **PASS** (16 packages ok, 0 failures).
|
||||
|
||||
**Sequence (all live on `felhom-pve` guest 9201 + hub on k3s):**
|
||||
1. Built/pushed hub `0.15.0` + controller `0.86.0`; deployed hub via ArgoCD.
|
||||
2. **Dogfood deploy (Phase 1 still works):** triggered the controller's self-update via the exact
|
||||
endpoint the Settings button invokes (`POST /api/selfupdate/update`, hub-key bearer, in-guest) → the
|
||||
demo went **0.85.1 → 0.86.0** (in-guest pull → agent swap → healthy). This put the floor-reading code
|
||||
on the box.
|
||||
3. Built/pushed a trivial **0.87.0** (version-only) as the auto-update target.
|
||||
4. **Set the floor:** global floor → **0.87.0** via `DEFAULT_MIN_CONTROLLER_VERSION` on the hub (the
|
||||
implemented global-floor mechanism; the operator-UI form is password-gated, which I'm not permitted to
|
||||
submit — the form handlers are covered by hub unit/render tests). Hub logged
|
||||
`Default controller-version floor: 0.87.0`.
|
||||
5. **Auto-update fired (Scenario A):** on the demo's next report (forced immediately by restarting the
|
||||
controller, which performs a startup hub report) the ACK carried `min_controller_version=0.87.0`; the
|
||||
controller saw current `0.86.0 < 0.87.0` and **auto-updated to 0.87.0 with no click**.
|
||||
- Proof (`/api/selfupdate/status` `last_state`): `previous_version=0.86.0`, `target_version=0.87.0`,
|
||||
`status=success`, **`initiated_by="auto-floor"`**, completed 10:15:42Z.
|
||||
- Host-side: container `…:0.87.0 Up (healthy)`; `/etc/felhom-controller-image = 0.87.0`.
|
||||
- Hub-side: `reports.controller_version` for demo-felhom = **0.87.0** (verified via sqlite on the
|
||||
copied DB; temp copy deleted afterwards).
|
||||
6. **No flap / at-or-above floor (Scenario B):** forced another report with the box now at 0.87.0
|
||||
(== floor) → **no second update**: image stayed `…:0.87.0` healthy, and `last_state.initiated_at`
|
||||
was **unchanged** (still 10:15:37Z). The floor does not re-trigger when at/above.
|
||||
### 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`.
|
||||
|
||||
**End state (left consistent, per spec):** demo 9201 at **0.87.0**; global floor **0.87.0** persisted in
|
||||
`manifests/hub.yaml` (`DEFAULT_MIN_CONTROLLER_VERSION`), hub Synced/Healthy. Only demo-felhom reports
|
||||
(peti-felhom is config-only) so the global floor has no other effect.
|
||||
Test count: **+~17** controller test functions (mailrelay 9, stacks/mailenv 8); suite remains green.
|
||||
|
||||
## What was NOT done
|
||||
- No agent binary change (reuses Phase 1 swap). The customer latest-only button is untouched. No new
|
||||
floor-polling endpoint/timer. No auto-update loop / while a swap is in flight.
|
||||
## 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).
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/mailrelay"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
@@ -240,6 +241,28 @@ func main() {
|
||||
// --- Initialize notifier ---
|
||||
notifier := notify.New(cfg.Hub.URL, cfg.Hub.APIKey, cfg.Customer.ID, sett, logger, cfg.Logging.Level == "debug")
|
||||
|
||||
// --- Initialize the app-email SMTP shim (mailrelay) ---
|
||||
// In-process shim: apps → shim → hub → Resend (the Resend key stays hub-side). It runs only
|
||||
// when the controller has a hub (URL+key) AND the operational kill-switch is on; the runtime
|
||||
// global app-email toggle then decides whether it actually listens (Lifecycle.Apply). Listeners
|
||||
// bind to the app Docker network only — never published to the host/internet.
|
||||
var mailShim *mailrelay.Lifecycle
|
||||
if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" && cfg.MailRelay.HardEnabled() {
|
||||
mailShim = mailrelay.NewLifecycle(mailrelay.Options{
|
||||
PlainAddr: cfg.MailRelay.PlainListen,
|
||||
TLSAddr: cfg.MailRelay.TLSListen,
|
||||
ServiceName: cfg.MailRelay.ShimHost,
|
||||
Policy: mailrelay.NewPolicy(cfg.MailRelay.FromDomains),
|
||||
Forwarder: mailrelay.NewHubForwarder(cfg.Hub.URL, cfg.Hub.APIKey),
|
||||
Logger: logger,
|
||||
})
|
||||
if err := mailShim.Apply(sett.AppEmailEnabled()); err != nil {
|
||||
logger.Printf("[ERROR] [mailrelay] could not start app-email shim: %v", err)
|
||||
}
|
||||
} else {
|
||||
logger.Printf("[INFO] [mailrelay] app-email shim unavailable (no hub configured or disabled in config)")
|
||||
}
|
||||
|
||||
// --- Initialize self-updater ---
|
||||
var updater *selfupdate.Updater
|
||||
if cfg.SelfUpdate.Enabled {
|
||||
@@ -671,6 +694,9 @@ func main() {
|
||||
if quiesceLoop != nil {
|
||||
webServer.SetBackupTrigger(quiesceLoop) // "Mentés most" → app-consistent backup via the quiesce loop
|
||||
}
|
||||
if mailShim != nil {
|
||||
webServer.SetMailShim(mailShim) // settings toggle starts/stops the app-email shim at runtime
|
||||
}
|
||||
if assetsSyncer != nil {
|
||||
webServer.SetAssetsSyncer(assetsSyncer)
|
||||
}
|
||||
@@ -785,6 +811,9 @@ func main() {
|
||||
sig := <-sigCh
|
||||
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
|
||||
cancel()
|
||||
if mailShim != nil {
|
||||
mailShim.Close()
|
||||
}
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
@@ -3,6 +3,8 @@ module gitea.dooplex.hu/admin/felhom-controller
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
||||
@@ -30,6 +30,26 @@ type Config struct {
|
||||
System SystemConfig `yaml:"system"`
|
||||
LocalAPI LocalAPIConfig `yaml:"local_api"`
|
||||
Quiesce QuiesceConfig `yaml:"quiesce"`
|
||||
MailRelay MailRelayConfig `yaml:"mail_relay"`
|
||||
}
|
||||
|
||||
// MailRelayConfig tunes the in-controller SMTP shim (app email → shim → hub → Resend).
|
||||
// The shim only runs when hub.enabled AND the global app-email toggle is on; it binds to
|
||||
// the app Docker network ONLY (never published to host/internet). ShimHost is the DNS
|
||||
// name injected into apps' SMTP_HOST (the controller's container name on the app network).
|
||||
type MailRelayConfig struct {
|
||||
// Enabled is a hard kill-switch: false disables the shim regardless of the runtime
|
||||
// toggle. nil/true → the runtime app-email toggle decides. (Operational override only.)
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
PlainListen string `yaml:"plain_listen"` // plaintext+STARTTLS, default ":2525"
|
||||
TLSListen string `yaml:"tls_listen"` // implicit-TLS, default ":2465"
|
||||
ShimHost string `yaml:"shim_host"` // app-network DNS name of the controller; default "felhom-controller"
|
||||
FromDomains []string `yaml:"from_domains"` // From-header allowlist; default ["felhom.eu"]
|
||||
}
|
||||
|
||||
// HardEnabled reports the operational kill-switch (default on unless explicitly false).
|
||||
func (m MailRelayConfig) HardEnabled() bool {
|
||||
return m.Enabled == nil || *m.Enabled
|
||||
}
|
||||
|
||||
// LocalAPIConfig is the in-guest controller's handle on the host agent's per-guest local API
|
||||
@@ -298,6 +318,12 @@ func applyDefaults(cfg *Config) {
|
||||
d(&cfg.Quiesce.PollInterval, "5m")
|
||||
d(&cfg.Quiesce.StatusPoll, "10s")
|
||||
d(&cfg.Quiesce.MaxQuiesce, "30m")
|
||||
d(&cfg.MailRelay.PlainListen, ":2525")
|
||||
d(&cfg.MailRelay.TLSListen, ":2465")
|
||||
d(&cfg.MailRelay.ShimHost, "felhom-controller")
|
||||
if len(cfg.MailRelay.FromDomains) == 0 {
|
||||
cfg.MailRelay.FromDomains = []string{"felhom.eu"}
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Forwarder hands a raw message off to the hub for delivery.
|
||||
//
|
||||
// v1 is SINGLE-SHOT (§9 rule 9): exactly one attempt, no retry loop, no durable
|
||||
// spool. The hub's HTTP status is returned to the caller; the SMTP layer maps that
|
||||
// status to an SMTP reply so the sending app surfaces the real outcome. Forward has
|
||||
// no SMTP knowledge — the seam keeps the engine unit-testable with a fake.
|
||||
type Forwarder interface {
|
||||
Forward(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) (status int, body string, err error)
|
||||
}
|
||||
|
||||
// MailForwardRequest is the JSON envelope POSTed to the hub POST /api/v1/mail.
|
||||
// RawMIME is a []byte so encoding/json base64-encodes it for safe transport and the
|
||||
// hub gets the message back byte-for-byte (raw passthrough — never parsed/re-encoded).
|
||||
type MailForwardRequest struct {
|
||||
RawMIME []byte `json:"raw_mime"`
|
||||
MailFrom string `json:"mail_from"`
|
||||
RcptTo []string `json:"rcpt_to"`
|
||||
}
|
||||
|
||||
// HubForwarder POSTs to the hub /api/v1/mail with the controller's existing hub
|
||||
// Bearer key — the same credential the notifier and report pusher already hold, so
|
||||
// there is no second place the hub credential lives (the Shape-1 decision).
|
||||
type HubForwarder struct {
|
||||
hubURL string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHubForwarder builds a forwarder against the hub base URL (e.g. https://hub.felhom.eu).
|
||||
func NewHubForwarder(hubURL, apiKey string) *HubForwarder {
|
||||
return &HubForwarder{
|
||||
hubURL: strings.TrimRight(hubURL, "/"),
|
||||
apiKey: apiKey,
|
||||
// Generous timeout: the hub leg dials Resend SMTP synchronously. Still a single attempt.
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Forward POSTs the raw message once and returns the hub's HTTP status + a short body
|
||||
// excerpt. A transport error (hub unreachable) returns (0, "", err); the SMTP layer
|
||||
// turns that into a transient 4xx so the app shows the user an error (no hang, no
|
||||
// silent drop — §7 scenario D).
|
||||
func (f *HubForwarder) Forward(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) (int, string, error) {
|
||||
payload := MailForwardRequest{RawMIME: raw, MailFrom: mailFrom, RcptTo: rcptTo}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("marshaling mail forward: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.hubURL+"/api/v1/mail", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+f.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// SINGLE attempt — no retry loop (a retry would risk a duplicate send; §9 rule 9).
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("hub unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return resp.StatusCode, string(body), nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Lifecycle owns the shim's start/stop so the global app-email toggle can take effect
|
||||
// at runtime without a controller restart. The shim listens ONLY when app-email is
|
||||
// globally ON (Part 1: "start listeners only when hub.enabled and global app-email ON").
|
||||
// Apply is idempotent: enabling when already up (or disabling when already down) is a
|
||||
// no-op.
|
||||
type Lifecycle struct {
|
||||
mu sync.Mutex
|
||||
opts Options
|
||||
srv *Server
|
||||
logger *log.Logger
|
||||
stopped bool // Close was called; further Apply(true) is refused (shutdown)
|
||||
}
|
||||
|
||||
// NewLifecycle prepares (but does not start) a shim from the given options.
|
||||
func NewLifecycle(opts Options) *Lifecycle {
|
||||
lg := opts.Logger
|
||||
if lg == nil {
|
||||
lg = log.Default()
|
||||
}
|
||||
return &Lifecycle{opts: opts, logger: lg}
|
||||
}
|
||||
|
||||
// Apply reconciles the shim's running state with the desired toggle value. It returns
|
||||
// an error only if a requested START failed (a stop never errors meaningfully).
|
||||
func (l *Lifecycle) Apply(enabled bool) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.stopped {
|
||||
return nil
|
||||
}
|
||||
if enabled {
|
||||
if l.srv != nil {
|
||||
return nil // already running
|
||||
}
|
||||
srv, err := New(l.opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := srv.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
l.srv = srv
|
||||
l.logger.Printf("[INFO] [mailrelay] app-email shim ON (host=%s plain=%s tls=%s)",
|
||||
l.opts.ServiceName, srv.PlainAddr(), srv.TLSAddr())
|
||||
return nil
|
||||
}
|
||||
// disabled → stop if running
|
||||
if l.srv != nil {
|
||||
_ = l.srv.Close()
|
||||
l.srv = nil
|
||||
l.logger.Printf("[INFO] [mailrelay] app-email shim OFF")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Running reports whether the shim is currently listening.
|
||||
func (l *Lifecycle) Running() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.srv != nil
|
||||
}
|
||||
|
||||
// Close stops the shim permanently (shutdown). Idempotent.
|
||||
func (l *Lifecycle) Close() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.stopped = true
|
||||
if l.srv != nil {
|
||||
_ = l.srv.Close()
|
||||
l.srv = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log"
|
||||
netsmtp "net/smtp"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// fakeForwarder records every Forward call so tests can assert call count + byte
|
||||
// fidelity, and returns a programmable status/err.
|
||||
type fakeForwarder struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
lastRaw []byte
|
||||
lastFrom string
|
||||
lastTo []string
|
||||
status int
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeForwarder) Forward(_ context.Context, raw []byte, from string, to []string) (int, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
f.lastRaw = append([]byte(nil), raw...)
|
||||
f.lastFrom = from
|
||||
f.lastTo = append([]string(nil), to...)
|
||||
return f.status, f.body, f.err
|
||||
}
|
||||
|
||||
func (f *fakeForwarder) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
func quietLogger() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
|
||||
func rawMsg(from string) []byte {
|
||||
return []byte("From: " + from + "\r\nTo: recipient@example.com\r\nSubject: test\r\n\r\nhello world\r\n")
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, allowed []string, fwd Forwarder) *Server {
|
||||
t.Helper()
|
||||
s, err := New(Options{
|
||||
Policy: NewPolicy(allowed),
|
||||
Forwarder: fwd,
|
||||
Logger: quietLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// dataResult drives a session.Data with the given raw message and returns the reply.
|
||||
func dataResult(srv *Server, from string, raw []byte) error {
|
||||
sess := &session{srv: srv, from: from, to: []string{"recipient@example.com"}}
|
||||
return sess.Data(bytes.NewReader(raw))
|
||||
}
|
||||
|
||||
// --- §7 A: happy path — passthrough, NOT parse (§10 byte-equality companion) ---
|
||||
|
||||
func TestData_HappyPath_ForwardsRawBytesUnchanged(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
raw := rawMsg("vaultwarden@felhom.eu")
|
||||
if err := dataResult(srv, "vaultwarden@felhom.eu", raw); err != nil {
|
||||
t.Fatalf("expected success (250), got %v", err)
|
||||
}
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("expected exactly 1 forward, got %d", fwd.callCount())
|
||||
}
|
||||
// Passthrough: the bytes handed to the hub must be byte-for-byte the input — no
|
||||
// MIME parse/re-encode (the parse path silently drops inline CID images; §10).
|
||||
if !bytes.Equal(fwd.lastRaw, raw) {
|
||||
t.Fatalf("forwarded bytes differ from input:\n got=%q\nwant=%q", fwd.lastRaw, raw)
|
||||
}
|
||||
if fwd.lastFrom != "vaultwarden@felhom.eu" {
|
||||
t.Fatalf("envelope from = %q", fwd.lastFrom)
|
||||
}
|
||||
}
|
||||
|
||||
// --- §7 B: From outside the allowlist is refused BEFORE any hub call ---
|
||||
|
||||
func TestData_FromOutsideAllowlist_Rejected_NoForward(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
err := dataResult(srv, "vaultwarden@felhom.eu", rawMsg("evil@notfelhom.example"))
|
||||
smtpErr, ok := err.(*smtp.SMTPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *smtp.SMTPError, got %T (%v)", err, err)
|
||||
}
|
||||
if smtpErr.Code != 550 {
|
||||
t.Fatalf("expected 550, got %d", smtpErr.Code)
|
||||
}
|
||||
// The hub/Resend must NEVER be dialed for a doomed message.
|
||||
if fwd.callCount() != 0 {
|
||||
t.Fatalf("forwarder must not be called on From-reject, got %d calls", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// Companion red-proof for §7 B: REMOVE the gate (allow the bad domain) and the same
|
||||
// message reaches the forwarder. Proves the allowlist check is load-bearing.
|
||||
func TestData_FromReject_CompanionProof_GateRemovedLetsItThrough(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"notfelhom.example"}, fwd) // gate effectively removed
|
||||
|
||||
if err := dataResult(srv, "x@notfelhom.example", rawMsg("evil@notfelhom.example")); err != nil {
|
||||
t.Fatalf("with the domain allowed the message should pass, got %v", err)
|
||||
}
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("companion: expected the wrong-From to reach the forwarder once, got %d", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestData_UnparseableFrom_Rejected(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
// No From header at all → fail-closed reject, no forward.
|
||||
noFrom := []byte("To: recipient@example.com\r\nSubject: test\r\n\r\nbody\r\n")
|
||||
err := dataResult(srv, "x@felhom.eu", noFrom)
|
||||
if smtpErr, ok := err.(*smtp.SMTPError); !ok || smtpErr.Code != 550 {
|
||||
t.Fatalf("expected 550 for missing From, got %v", err)
|
||||
}
|
||||
if fwd.callCount() != 0 {
|
||||
t.Fatalf("forwarder must not be called when From is unparseable, got %d", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// --- §7 D: transient hub outage — single-shot, surfaces transient error ---
|
||||
|
||||
func TestData_HubUnreachable_SingleShot_Transient(t *testing.T) {
|
||||
fwd := &fakeForwarder{err: io.ErrUnexpectedEOF} // simulate hub unreachable
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
err := dataResult(srv, "vaultwarden@felhom.eu", rawMsg("vaultwarden@felhom.eu"))
|
||||
smtpErr, ok := err.(*smtp.SMTPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *smtp.SMTPError, got %T", err)
|
||||
}
|
||||
if smtpErr.Code/100 != 4 {
|
||||
t.Fatalf("expected transient 4xx, got %d", smtpErr.Code)
|
||||
}
|
||||
// §10 single-shot companion: EXACTLY one attempt — no retry loop.
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("v1 forward must be single-shot; got %d attempts", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// --- status mapping (spike §6) ---
|
||||
|
||||
func TestMapStatusToSMTP(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
wantNil bool
|
||||
wantCode int
|
||||
}{
|
||||
{200, true, 0},
|
||||
{202, true, 0},
|
||||
{403, false, 451}, // hub From-reject backstop → transient per the prompt mapping
|
||||
{429, false, 451}, // rate limit → retry later
|
||||
{451, false, 451},
|
||||
{500, false, 554},
|
||||
{503, false, 554},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := mapStatusToSMTP(c.status, "")
|
||||
if c.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("status %d: expected nil, got %v", c.status, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
smtpErr, ok := got.(*smtp.SMTPError)
|
||||
if !ok {
|
||||
t.Errorf("status %d: expected *smtp.SMTPError, got %T", c.status, got)
|
||||
continue
|
||||
}
|
||||
if smtpErr.Code != c.wantCode {
|
||||
t.Errorf("status %d: expected SMTP %d, got %d", c.status, c.wantCode, smtpErr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeReason(t *testing.T) {
|
||||
if got := sanitizeReason("line one\r\nleaked@addr.example"); got != "line one" {
|
||||
t.Fatalf("sanitizeReason should keep only the first line, got %q", got)
|
||||
}
|
||||
long := make([]byte, 500)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if got := sanitizeReason(string(long)); len(got) != 200 {
|
||||
t.Fatalf("sanitizeReason should cap at 200, got len %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- end-to-end over a real socket (Q1/Q2: real SMTP, STARTTLS, no auth) ---
|
||||
|
||||
func TestLifecycle_StartStopIdempotent(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
lc := NewLifecycle(Options{
|
||||
PlainAddr: "127.0.0.1:0",
|
||||
TLSAddr: "127.0.0.1:0",
|
||||
Policy: NewPolicy([]string{"felhom.eu"}),
|
||||
Forwarder: fwd,
|
||||
Logger: quietLogger(),
|
||||
})
|
||||
if lc.Running() {
|
||||
t.Fatal("should not be running before Apply")
|
||||
}
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("Apply(true): %v", err)
|
||||
}
|
||||
if !lc.Running() {
|
||||
t.Fatal("should be running after Apply(true)")
|
||||
}
|
||||
// idempotent enable
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("second Apply(true): %v", err)
|
||||
}
|
||||
if err := lc.Apply(false); err != nil {
|
||||
t.Fatalf("Apply(false): %v", err)
|
||||
}
|
||||
if lc.Running() {
|
||||
t.Fatal("should be stopped after Apply(false)")
|
||||
}
|
||||
// re-enable after disable works (uses a fresh server)
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("re-enable: %v", err)
|
||||
}
|
||||
if !lc.Running() {
|
||||
t.Fatal("should run after re-enable")
|
||||
}
|
||||
lc.Close()
|
||||
if lc.Running() {
|
||||
t.Fatal("Close should stop the shim")
|
||||
}
|
||||
// Apply(true) after Close is refused (no resurrection on shutdown)
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("Apply after Close should be a no-op nil, got %v", err)
|
||||
}
|
||||
if lc.Running() {
|
||||
t.Fatal("must stay stopped after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_EndToEnd_STARTTLS(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
s, err := New(Options{
|
||||
PlainAddr: "127.0.0.1:0",
|
||||
TLSAddr: "127.0.0.1:0",
|
||||
Policy: NewPolicy([]string{"felhom.eu"}),
|
||||
Forwarder: fwd,
|
||||
Logger: quietLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
c, err := netsmtp.Dial(s.PlainAddr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.StartTLS(&tls.Config{InsecureSkipVerify: true}); err != nil {
|
||||
t.Fatalf("starttls: %v", err)
|
||||
}
|
||||
if err := c.Mail("vaultwarden@felhom.eu"); err != nil {
|
||||
t.Fatalf("mail: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("recipient@example.com"); err != nil {
|
||||
t.Fatalf("rcpt: %v", err)
|
||||
}
|
||||
wc, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatalf("data: %v", err)
|
||||
}
|
||||
msg := rawMsg("vaultwarden@felhom.eu")
|
||||
if _, err := wc.Write(msg); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
t.Fatalf("close data (expected 250): %v", err)
|
||||
}
|
||||
_ = c.Quit()
|
||||
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("expected 1 forward through the real listener, got %d", fwd.callCount())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Policy enforces the From-header domain allowlist.
|
||||
//
|
||||
// Per the spike (§5/§9) the check is on the From *header* domain, not the SMTP
|
||||
// envelope MAIL FROM — Resend itself validates the header domain (the unverified-
|
||||
// domain 550 lands at DATA, after MAIL/RCPT are accepted), so the relay must check
|
||||
// the same thing. The recommendation is validate-and-REJECT (never silently rewrite),
|
||||
// so a misconfigured app surfaces loudly instead of having its From re-stamped.
|
||||
type Policy struct {
|
||||
allowed map[string]bool
|
||||
}
|
||||
|
||||
// NewPolicy builds a From-domain allowlist (case-insensitive). An empty list
|
||||
// defaults to {"felhom.eu"} (the one verified Resend domain).
|
||||
func NewPolicy(domains []string) *Policy {
|
||||
p := &Policy{allowed: make(map[string]bool)}
|
||||
if len(domains) == 0 {
|
||||
domains = []string{"felhom.eu"}
|
||||
}
|
||||
for _, d := range domains {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
if d != "" {
|
||||
p.allowed[d] = true
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// FromDomain parses the From *header* of a raw MIME message and returns the
|
||||
// lowercased domain of the first address. It reads only the headers — the body is
|
||||
// left untouched (the relay forwards raw bytes; this never re-serialises the message).
|
||||
func FromDomain(raw []byte) (string, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing message headers: %w", err)
|
||||
}
|
||||
from := strings.TrimSpace(msg.Header.Get("From"))
|
||||
if from == "" {
|
||||
return "", fmt.Errorf("missing From header")
|
||||
}
|
||||
addr, err := mail.ParseAddress(from)
|
||||
if err != nil {
|
||||
// Tolerate a From that is actually an address *list* (rare, but valid).
|
||||
list, lerr := mail.ParseAddressList(from)
|
||||
if lerr != nil || len(list) == 0 {
|
||||
return "", fmt.Errorf("parsing From address %q: %w", from, err)
|
||||
}
|
||||
addr = list[0]
|
||||
}
|
||||
at := strings.LastIndex(addr.Address, "@")
|
||||
if at < 0 || at == len(addr.Address)-1 {
|
||||
return "", fmt.Errorf("From address %q has no domain", addr.Address)
|
||||
}
|
||||
return strings.ToLower(addr.Address[at+1:]), nil
|
||||
}
|
||||
|
||||
// Check returns nil iff the message's From-header domain is in the allowlist.
|
||||
// A parse failure is treated as a rejection (fail-closed): an app that can't be
|
||||
// parsed shouldn't reach Resend.
|
||||
func (p *Policy) Check(raw []byte) error {
|
||||
dom, err := FromDomain(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.allowed[dom] {
|
||||
return fmt.Errorf("From domain %q not in allowlist", dom)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Allowed reports whether a bare domain is in the allowlist (inspection/tests).
|
||||
func (p *Policy) Allowed(domain string) bool {
|
||||
return p.allowed[strings.ToLower(strings.TrimSpace(domain))]
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Package mailrelay is the in-process SMTP shim that gives deployed apps outbound
|
||||
// email through one managed path. It accepts SMTP from apps on the Docker network
|
||||
// (no Resend key on the box), validates the From-header domain, and forwards the raw
|
||||
// MIME to the hub, which holds the Resend key and re-emits the message unchanged.
|
||||
//
|
||||
// Architecture: Shape 1 — the shim runs IN-PROCESS inside felhom-controller (operator-
|
||||
// confirmed), reusing the controller's existing hub client. Listeners bind to the app
|
||||
// Docker network only; they are NEVER published to the host or internet.
|
||||
//
|
||||
// Validated end-to-end in SPIKE-smtp-app-relay-2026-06-28.md (verdict READY).
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// maxMessageBytes bounds a single message. Transactional app mail (password resets,
|
||||
// invites) is small; 10 MiB leaves generous headroom for inline images/attachments.
|
||||
const maxMessageBytes int64 = 10 << 20
|
||||
|
||||
// forwardTimeout bounds the synchronous hub forward (which itself dials Resend).
|
||||
const forwardTimeout = 30 * time.Second
|
||||
|
||||
// Options configures the shim.
|
||||
type Options struct {
|
||||
PlainAddr string // plaintext + STARTTLS listener (default ":2525")
|
||||
TLSAddr string // implicit-TLS listener (default ":2465")
|
||||
ServiceName string // CN/SAN of the self-signed cert + SMTP greeting (e.g. "felhom-controller")
|
||||
Policy *Policy // From-domain allowlist (required)
|
||||
Forwarder Forwarder // hub forwarder (required)
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
// Server runs the two SMTP listeners.
|
||||
type Server struct {
|
||||
opts Options
|
||||
tlsConf *tls.Config
|
||||
plainSrv *smtp.Server
|
||||
tlsSrv *smtp.Server
|
||||
plainLn net.Listener
|
||||
tlsLn net.Listener
|
||||
}
|
||||
|
||||
// New builds the shim and its self-signed cert. It does not bind sockets — call Start.
|
||||
func New(opts Options) (*Server, error) {
|
||||
if opts.Policy == nil {
|
||||
return nil, fmt.Errorf("mailrelay: Policy is required")
|
||||
}
|
||||
if opts.Forwarder == nil {
|
||||
return nil, fmt.Errorf("mailrelay: Forwarder is required")
|
||||
}
|
||||
if opts.PlainAddr == "" {
|
||||
opts.PlainAddr = ":2525"
|
||||
}
|
||||
if opts.TLSAddr == "" {
|
||||
opts.TLSAddr = ":2465"
|
||||
}
|
||||
if opts.ServiceName == "" {
|
||||
opts.ServiceName = "felhom-controller"
|
||||
}
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = log.Default()
|
||||
}
|
||||
|
||||
cert, err := selfSignedCert(opts.ServiceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mailrelay: generating self-signed cert: %w", err)
|
||||
}
|
||||
tlsConf := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}
|
||||
|
||||
s := &Server{opts: opts, tlsConf: tlsConf}
|
||||
be := &backend{srv: s}
|
||||
|
||||
// :2525 — plaintext with STARTTLS offered. AllowInsecureAuth lets an app present
|
||||
// AUTH before STARTTLS (apps send no creds, but some refuse to send without the offer).
|
||||
s.plainSrv = smtp.NewServer(be)
|
||||
s.plainSrv.Addr = opts.PlainAddr
|
||||
s.plainSrv.Domain = opts.ServiceName
|
||||
s.plainSrv.TLSConfig = tlsConf
|
||||
s.plainSrv.AllowInsecureAuth = true
|
||||
s.plainSrv.MaxMessageBytes = maxMessageBytes
|
||||
s.plainSrv.ReadTimeout = 60 * time.Second
|
||||
s.plainSrv.WriteTimeout = 60 * time.Second
|
||||
|
||||
// :2465 — implicit TLS (the whole connection is TLS; for apps that force_tls).
|
||||
s.tlsSrv = smtp.NewServer(be)
|
||||
s.tlsSrv.Addr = opts.TLSAddr
|
||||
s.tlsSrv.Domain = opts.ServiceName
|
||||
s.tlsSrv.TLSConfig = tlsConf
|
||||
s.tlsSrv.MaxMessageBytes = maxMessageBytes
|
||||
s.tlsSrv.ReadTimeout = 60 * time.Second
|
||||
s.tlsSrv.WriteTimeout = 60 * time.Second
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start binds both listeners and serves them in background goroutines. A bind failure
|
||||
// is returned synchronously. The listeners must reach the app Docker network ONLY —
|
||||
// the caller is responsible for not publishing these ports to the host/internet.
|
||||
func (s *Server) Start() error {
|
||||
pl, err := net.Listen("tcp", s.opts.PlainAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailrelay: listen %s: %w", s.opts.PlainAddr, err)
|
||||
}
|
||||
tl, err := tls.Listen("tcp", s.opts.TLSAddr, s.tlsConf)
|
||||
if err != nil {
|
||||
pl.Close()
|
||||
return fmt.Errorf("mailrelay: listen TLS %s: %w", s.opts.TLSAddr, err)
|
||||
}
|
||||
s.plainLn, s.tlsLn = pl, tl
|
||||
|
||||
s.opts.Logger.Printf("[INFO] [mailrelay] plaintext+STARTTLS listener on %s", pl.Addr())
|
||||
s.opts.Logger.Printf("[INFO] [mailrelay] implicit-TLS listener on %s", tl.Addr())
|
||||
|
||||
go func() {
|
||||
if err := s.plainSrv.Serve(pl); err != nil && !isClosedErr(err) {
|
||||
s.opts.Logger.Printf("[ERROR] [mailrelay] plaintext listener stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
if err := s.tlsSrv.Serve(tl); err != nil && !isClosedErr(err) {
|
||||
s.opts.Logger.Printf("[ERROR] [mailrelay] implicit-TLS listener stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlainAddr / TLSAddr return the bound addresses (resolved ports — useful for tests
|
||||
// that listen on :0).
|
||||
func (s *Server) PlainAddr() string {
|
||||
if s.plainLn != nil {
|
||||
return s.plainLn.Addr().String()
|
||||
}
|
||||
return s.opts.PlainAddr
|
||||
}
|
||||
|
||||
func (s *Server) TLSAddr() string {
|
||||
if s.tlsLn != nil {
|
||||
return s.tlsLn.Addr().String()
|
||||
}
|
||||
return s.opts.TLSAddr
|
||||
}
|
||||
|
||||
// Close stops both listeners.
|
||||
func (s *Server) Close() error {
|
||||
var err error
|
||||
if s.plainSrv != nil {
|
||||
if e := s.plainSrv.Close(); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
if s.tlsSrv != nil {
|
||||
if e := s.tlsSrv.Close(); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isClosedErr(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "use of closed network connection")
|
||||
}
|
||||
|
||||
// ── go-smtp backend / session ────────────────────────────────────────────
|
||||
|
||||
type backend struct{ srv *Server }
|
||||
|
||||
func (b *backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
|
||||
return &session{srv: b.srv}, nil
|
||||
}
|
||||
|
||||
// session implements smtp.Session + smtp.AuthSession.
|
||||
type session struct {
|
||||
srv *Server
|
||||
from string
|
||||
to []string
|
||||
}
|
||||
|
||||
func (s *session) Mail(from string, _ *smtp.MailOptions) error {
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error {
|
||||
s.to = append(s.to, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthMechanisms advertises PLAIN + LOGIN. We accept ANY credentials and ignore them
|
||||
// (apps send none, but some refuse to send without an AUTH offer; no credential is a
|
||||
// secret on the Docker network — the shim holds no Resend key).
|
||||
func (s *session) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain, sasl.Login}
|
||||
}
|
||||
|
||||
func (s *session) Auth(mech string) (sasl.Server, error) {
|
||||
switch mech {
|
||||
case sasl.Plain:
|
||||
return sasl.NewPlainServer(func(_, _, _ string) error { return nil }), nil
|
||||
case sasl.Login:
|
||||
return &loginServer{}, nil
|
||||
default:
|
||||
return nil, smtp.ErrAuthUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// Data reads the raw message, enforces the From policy BEFORE any hub call, then
|
||||
// forwards it single-shot and maps the hub status to an SMTP reply.
|
||||
func (s *session) Data(r io.Reader) error {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, maxMessageBytes+1))
|
||||
if err != nil {
|
||||
s.srv.opts.Logger.Printf("[ERROR] [mailrelay] reading DATA: %v", err)
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 3, 0}, Message: "could not read message"}
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "message too large"}
|
||||
}
|
||||
|
||||
// From-policy gate (§7 B): reject with a clean 5xx BEFORE dialing the hub — never
|
||||
// spend a Resend call on a doomed message, and surface the misconfig loudly.
|
||||
if err := s.srv.opts.Policy.Check(raw); err != nil {
|
||||
s.srv.opts.Logger.Printf("[WARN] [mailrelay] rejected message (from=%q): %v", s.from, err)
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 1}, Message: "sender address not permitted"}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), forwardTimeout)
|
||||
defer cancel()
|
||||
status, body, err := s.srv.opts.Forwarder.Forward(ctx, raw, s.from, s.to)
|
||||
if err != nil {
|
||||
// Transient: hub unreachable. Clean 4xx → the app shows the user an error; no
|
||||
// hang, no silent drop, no spool (§7 D / §9 rule 9).
|
||||
s.srv.opts.Logger.Printf("[ERROR] [mailrelay] forward to hub failed: %v", err)
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 4, 1}, Message: "upstream relay (hub) unavailable, try again later"}
|
||||
}
|
||||
if reply := mapStatusToSMTP(status, body); reply != nil {
|
||||
s.srv.opts.Logger.Printf("[WARN] [mailrelay] hub refused message (HTTP %d): %s", status, sanitizeReason(body))
|
||||
return reply
|
||||
}
|
||||
s.srv.opts.Logger.Printf("[INFO] [mailrelay] message relayed (from=%q rcpts=%d bytes=%d hub=%d)", s.from, len(s.to), len(raw), status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) Reset() {
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
}
|
||||
|
||||
func (s *session) Logout() error { return nil }
|
||||
|
||||
// mapStatusToSMTP turns the hub's HTTP status into an SMTP reply so the app surfaces
|
||||
// the real outcome (spike §6): 2xx → nil (250 OK), 4xx → 451 transient (incl. 429
|
||||
// rate-limit — retry later), 5xx → 554 permanent. The hub's reason is relayed where
|
||||
// present (sanitised to a single SMTP-safe line).
|
||||
func mapStatusToSMTP(status int, body string) error {
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == 429:
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 7, 0}, Message: msgOr(sanitizeReason(body), "rate limit exceeded, try again later")}
|
||||
case status >= 400 && status < 500:
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 4, 1}, Message: msgOr(sanitizeReason(body), "upstream rejected (temporary)")}
|
||||
default:
|
||||
return &smtp.SMTPError{Code: 554, EnhancedCode: smtp.EnhancedCode{5, 0, 0}, Message: msgOr(sanitizeReason(body), "upstream rejected")}
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeReason extracts a short, SMTP-safe single line from the hub's response body.
|
||||
// Never logs/relays a full body (which could echo addresses); caps at 200 chars.
|
||||
func sanitizeReason(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(body, "\r\n"); i >= 0 {
|
||||
body = body[:i]
|
||||
}
|
||||
if len(body) > 200 {
|
||||
body = body[:200]
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func msgOr(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// loginServer implements the SMTP AUTH LOGIN exchange as a sasl.Server. go-sasl ships
|
||||
// a LOGIN *client* but no server (spike §2). It accepts ANY credentials and ignores
|
||||
// them. The exchange: challenge "Username:" → username → challenge "Password:" →
|
||||
// password → done.
|
||||
type loginServer struct{ step int }
|
||||
|
||||
func (l *loginServer) Next(_ []byte) (challenge []byte, done bool, err error) {
|
||||
switch l.step {
|
||||
case 0:
|
||||
l.step++
|
||||
return []byte("Username:"), false, nil
|
||||
case 1:
|
||||
l.step++
|
||||
return []byte("Password:"), false, nil
|
||||
default:
|
||||
return nil, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// selfSignedCert generates an in-process self-signed leaf for the shim (CN/SAN = the
|
||||
// service name). Apps on the Docker network are told to accept invalid certs; the cert
|
||||
// only terminates TLS for force_tls/starttls apps, it is not a trust anchor.
|
||||
func selfSignedCert(serviceName string) (tls.Certificate, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: serviceName},
|
||||
DNSNames: []string{serviceName, "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
|
||||
}
|
||||
@@ -64,6 +64,17 @@ type Settings struct {
|
||||
|
||||
// App-to-app integration state (e.g., "onlyoffice:filebrowser" → state)
|
||||
Integrations map[string]IntegrationState `json:"integrations,omitempty"`
|
||||
|
||||
// AppEmail is the global app-email (SMTP relay) toggle. When on, deployed apps with an
|
||||
// smtp_mapping can send mail via the in-controller shim → hub → Resend. Relay-only:
|
||||
// no BYO host/port/user/pass (that escape hatch is deferred).
|
||||
AppEmail *AppEmail `json:"app_email,omitempty"`
|
||||
}
|
||||
|
||||
// AppEmail holds the global app-email toggle and an optional household display name.
|
||||
type AppEmail struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
FromName string `json:"from_name,omitempty"` // optional household display name for the From line
|
||||
}
|
||||
|
||||
// IntegrationState holds the state of a provider:target integration pair.
|
||||
@@ -1178,6 +1189,36 @@ func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error {
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// --- App email (SMTP relay) ---
|
||||
|
||||
// GetAppEmail returns the global app-email toggle (a copy; never the live pointer).
|
||||
func (s *Settings) GetAppEmail() AppEmail {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.AppEmail == nil {
|
||||
return AppEmail{}
|
||||
}
|
||||
return *s.AppEmail
|
||||
}
|
||||
|
||||
// AppEmailEnabled reports whether app-email is globally on.
|
||||
func (s *Settings) AppEmailEnabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.AppEmail != nil && s.AppEmail.Enabled
|
||||
}
|
||||
|
||||
// SetAppEmail updates the global app-email toggle and persists it.
|
||||
func (s *Settings) SetAppEmail(enabled bool, fromName string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.debug {
|
||||
s.log.Printf("[DEBUG] [settings] SetAppEmail enabled=%v from_name=%q", enabled, fromName)
|
||||
}
|
||||
s.AppEmail = &AppEmail{Enabled: enabled, FromName: strings.TrimSpace(fromName)}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// --- App-to-app integrations ---
|
||||
|
||||
// GetIntegrationState returns the state for a specific integration key (e.g., "onlyoffice:filebrowser").
|
||||
|
||||
@@ -101,6 +101,9 @@ type AppConfig struct {
|
||||
DeployedAt string `yaml:"deployed_at" json:"deployed_at"`
|
||||
Env map[string]string `yaml:"env" json:"env"`
|
||||
LockedFields []string `yaml:"locked_fields" json:"locked_fields"`
|
||||
// EmailEnabled is the per-app app-email toggle (default off). When on AND the global toggle is
|
||||
// on AND the app has an smtp_mapping, the controller injects the relay SMTP env at compose time.
|
||||
EmailEnabled bool `yaml:"email_enabled,omitempty" json:"email_enabled,omitempty"`
|
||||
}
|
||||
|
||||
// DeployRequest contains the user-provided values from the deploy form.
|
||||
@@ -702,6 +705,7 @@ func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars
|
||||
DeployedAt: cfg.DeployedAt,
|
||||
Env: make(map[string]string, len(cfg.Env)),
|
||||
LockedFields: cfg.LockedFields,
|
||||
EmailEnabled: cfg.EmailEnabled,
|
||||
}
|
||||
sensitiveSet := make(map[string]bool, len(sensitiveVars))
|
||||
for _, v := range sensitiveVars {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// smtpEnv returns the managed app-email relay env vars (as "KEY=VALUE") to inject for a
|
||||
// stack, or nil when app-email does not apply. It is a pure function of settings + the
|
||||
// app's smtp_mapping + the per-app toggle; the values are NEVER persisted to app.yaml —
|
||||
// they are derived on every compose so a toggle change applies on the next redeploy.
|
||||
//
|
||||
// Gates (all must hold, else nil — §8 edge table):
|
||||
// - global app-email toggle ON (settings.AppEmailEnabled)
|
||||
// - per-app toggle ON (appEmailEnabled, from app.yaml)
|
||||
// - the app declares a usable smtp_mapping
|
||||
//
|
||||
// Injected: host = the in-controller shim (cfg.MailRelay.ShimHost), port = 2525
|
||||
// (plaintext+STARTTLS listener), security = the app's STARTTLS term, From =
|
||||
// <local>@<allowlisted-domain>, optional From display name, plus the mapping's fixed
|
||||
// Extra vars (accept-invalid-cert flags, etc.).
|
||||
func (m *Manager) smtpEnv(meta *Metadata, appEmailEnabled bool) []string {
|
||||
if !appEmailEnabled {
|
||||
return nil
|
||||
}
|
||||
if m.settings == nil || !m.settings.AppEmailEnabled() {
|
||||
return nil
|
||||
}
|
||||
if !meta.HasSMTPMapping() {
|
||||
return nil
|
||||
}
|
||||
sm := meta.SMTPMapping
|
||||
|
||||
host := m.cfg.MailRelay.ShimHost
|
||||
if host == "" {
|
||||
host = "felhom-controller"
|
||||
}
|
||||
fromDomain := "felhom.eu"
|
||||
if len(m.cfg.MailRelay.FromDomains) > 0 && m.cfg.MailRelay.FromDomains[0] != "" {
|
||||
fromDomain = m.cfg.MailRelay.FromDomains[0]
|
||||
}
|
||||
local := sm.FromLocal
|
||||
if local == "" {
|
||||
local = meta.Slug
|
||||
}
|
||||
security := sm.SecurityValue
|
||||
if security == "" {
|
||||
security = "starttls"
|
||||
}
|
||||
|
||||
out := []string{
|
||||
fmt.Sprintf("%s=%s", sm.HostVar, host),
|
||||
fmt.Sprintf("%s=%s", sm.PortVar, "2525"),
|
||||
fmt.Sprintf("%s=%s@%s", sm.FromVar, local, fromDomain),
|
||||
}
|
||||
if sm.SecurityVar != "" {
|
||||
out = append(out, fmt.Sprintf("%s=%s", sm.SecurityVar, security))
|
||||
}
|
||||
if sm.FromNameVar != "" {
|
||||
out = append(out, fmt.Sprintf("%s=%s", sm.FromNameVar, m.fromDisplayName(meta)))
|
||||
}
|
||||
for k, v := range sm.Extra {
|
||||
if k != "" {
|
||||
out = append(out, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fromDisplayName picks the From display name: the household-set name (settings) wins,
|
||||
// else the app's display name.
|
||||
func (m *Manager) fromDisplayName(meta *Metadata) string {
|
||||
if m.settings != nil {
|
||||
if ae := m.settings.GetAppEmail(); ae.FromName != "" {
|
||||
return ae.FromName
|
||||
}
|
||||
}
|
||||
if meta.DisplayName != "" {
|
||||
return meta.DisplayName
|
||||
}
|
||||
return meta.Slug
|
||||
}
|
||||
|
||||
// SetAppEmailEnabled persists the per-app email toggle in app.yaml and, if the stack is
|
||||
// deployed, recreates it (docker compose up -d) so the SMTP env injection takes effect
|
||||
// (toggle ON) or is removed (toggle OFF). Errors if the stack is unknown or not deployed.
|
||||
func (m *Manager) SetAppEmailEnabled(name string, enabled bool) error {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
if !meta.HasSMTPMapping() {
|
||||
return fmt.Errorf("a(z) %q alkalmazás nem támogatja az email-küldést", name)
|
||||
}
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
if appCfg == nil || !appCfg.Deployed {
|
||||
return fmt.Errorf("a(z) %q alkalmazás nincs telepítve", name)
|
||||
}
|
||||
if appCfg.EmailEnabled == enabled {
|
||||
return nil // no change
|
||||
}
|
||||
appCfg.EmailEnabled = enabled
|
||||
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
||||
return fmt.Errorf("saving app config: %w", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if s, ok := m.stacks[name]; ok {
|
||||
s.AppConfig = appCfg
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.logger.Printf("[INFO] [stacks] App-email for %s set to %v — recreating to apply", name, enabled)
|
||||
|
||||
// Recreate so the (now present/absent) SMTP env is applied. stackEnv injects the relay env.
|
||||
env := m.stackEnv(stackDir)
|
||||
if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil {
|
||||
return fmt.Errorf("restarting to apply app-email change: %w", err)
|
||||
}
|
||||
m.logPostStartStatus(name, stackDir, env)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
// AppEmailStatus reports whether an app supports email and whether its per-app toggle is on.
|
||||
func (m *Manager) AppEmailStatus(name string) (supported, enabled bool) {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
if !meta.HasSMTPMapping() {
|
||||
return false, false
|
||||
}
|
||||
if appCfg := LoadAppConfig(stackDir); appCfg != nil {
|
||||
return true, appCfg.EmailEnabled
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
func newMailManager(t *testing.T, globalOn bool, fromName string) *Manager {
|
||||
t.Helper()
|
||||
lg := log.New(os.Stderr, "", 0)
|
||||
cfg := &config.Config{}
|
||||
cfg.MailRelay.ShimHost = "felhom-controller"
|
||||
cfg.MailRelay.FromDomains = []string{"felhom.eu"}
|
||||
m := &Manager{cfg: cfg, logger: lg, stacks: map[string]*Stack{}}
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if globalOn {
|
||||
if err := sett.SetAppEmail(true, fromName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m.settings = sett
|
||||
return m
|
||||
}
|
||||
|
||||
func vaultwardenMeta() *Metadata {
|
||||
return &Metadata{
|
||||
Slug: "vaultwarden",
|
||||
DisplayName: "Vaultwarden",
|
||||
SMTPMapping: &SMTPMapping{
|
||||
HostVar: "SMTP_HOST",
|
||||
PortVar: "SMTP_PORT",
|
||||
SecurityVar: "SMTP_SECURITY",
|
||||
SecurityValue: "starttls",
|
||||
FromVar: "SMTP_FROM",
|
||||
FromNameVar: "SMTP_FROM_NAME",
|
||||
Extra: map[string]string{
|
||||
"SMTP_ACCEPT_INVALID_CERTS": "true",
|
||||
"SMTP_ACCEPT_INVALID_HOSTNAMES": "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func envMap(kvs []string) map[string]string {
|
||||
m := make(map[string]string, len(kvs))
|
||||
for _, kv := range kvs {
|
||||
if i := strings.IndexByte(kv, '='); i >= 0 {
|
||||
m[kv[:i]] = kv[i+1:]
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// §7 A / §8: global ON + app ON + mapping → full mapped env injected.
|
||||
func TestSMTPEnv_BothTogglesOn_Injects(t *testing.T) {
|
||||
m := newMailManager(t, true, "")
|
||||
got := envMap(m.smtpEnv(vaultwardenMeta(), true))
|
||||
|
||||
want := map[string]string{
|
||||
"SMTP_HOST": "felhom-controller",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_SECURITY": "starttls",
|
||||
"SMTP_FROM": "vaultwarden@felhom.eu",
|
||||
"SMTP_FROM_NAME": "Vaultwarden",
|
||||
"SMTP_ACCEPT_INVALID_CERTS": "true",
|
||||
"SMTP_ACCEPT_INVALID_HOSTNAMES": "true",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("env %s = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §7 E / §8: per-app OFF → nothing injected even when global is ON.
|
||||
func TestSMTPEnv_PerAppOff_NoInjection(t *testing.T) {
|
||||
m := newMailManager(t, true, "")
|
||||
if got := m.smtpEnv(vaultwardenMeta(), false); got != nil {
|
||||
t.Fatalf("per-app off must inject nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// §7 E / §8: global OFF → nothing injected even when the app toggle is ON.
|
||||
func TestSMTPEnv_GlobalOff_NoInjection(t *testing.T) {
|
||||
m := newMailManager(t, false, "")
|
||||
if got := m.smtpEnv(vaultwardenMeta(), true); got != nil {
|
||||
t.Fatalf("global off must inject nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// §8: app with no smtp_mapping → nothing injected.
|
||||
func TestSMTPEnv_NoMapping_NoInjection(t *testing.T) {
|
||||
m := newMailManager(t, true, "")
|
||||
meta := &Metadata{Slug: "plex", DisplayName: "Plex"}
|
||||
if got := m.smtpEnv(meta, true); got != nil {
|
||||
t.Fatalf("app without smtp_mapping must inject nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Household display-name override wins over the app display name.
|
||||
func TestSMTPEnv_HouseholdFromNameWins(t *testing.T) {
|
||||
m := newMailManager(t, true, "Kovács család")
|
||||
got := envMap(m.smtpEnv(vaultwardenMeta(), true))
|
||||
if got["SMTP_FROM_NAME"] != "Kovács család" {
|
||||
t.Fatalf("SMTP_FROM_NAME = %q, want household name", got["SMTP_FROM_NAME"])
|
||||
}
|
||||
}
|
||||
|
||||
// Mealie-style mapping: different env keys + from_local + STARTTLS expressed as "TLS".
|
||||
func TestSMTPEnv_MealieMapping(t *testing.T) {
|
||||
m := newMailManager(t, true, "")
|
||||
meta := &Metadata{
|
||||
Slug: "mealie",
|
||||
DisplayName: "Mealie",
|
||||
SMTPMapping: &SMTPMapping{
|
||||
HostVar: "SMTP_HOST",
|
||||
PortVar: "SMTP_PORT",
|
||||
SecurityVar: "SMTP_AUTH_STRATEGY",
|
||||
SecurityValue: "TLS",
|
||||
FromVar: "SMTP_FROM_EMAIL",
|
||||
FromNameVar: "SMTP_FROM_NAME",
|
||||
FromLocal: "mealie",
|
||||
},
|
||||
}
|
||||
got := envMap(m.smtpEnv(meta, true))
|
||||
if got["SMTP_AUTH_STRATEGY"] != "TLS" {
|
||||
t.Errorf("SMTP_AUTH_STRATEGY = %q, want TLS", got["SMTP_AUTH_STRATEGY"])
|
||||
}
|
||||
if got["SMTP_FROM_EMAIL"] != "mealie@felhom.eu" {
|
||||
t.Errorf("SMTP_FROM_EMAIL = %q, want mealie@felhom.eu", got["SMTP_FROM_EMAIL"])
|
||||
}
|
||||
if got["SMTP_PORT"] != "2525" {
|
||||
t.Errorf("SMTP_PORT = %q, want 2525", got["SMTP_PORT"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadata_SMTPMappingParse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
yml := `display_name: Vaultwarden
|
||||
slug: vaultwarden
|
||||
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
|
||||
extra:
|
||||
SMTP_ACCEPT_INVALID_CERTS: "true"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(yml), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := LoadMetadata(dir)
|
||||
if !meta.HasSMTPMapping() {
|
||||
t.Fatal("expected smtp_mapping to parse")
|
||||
}
|
||||
if meta.SMTPMapping.SecurityValue != "starttls" {
|
||||
t.Errorf("security_value = %q", meta.SMTPMapping.SecurityValue)
|
||||
}
|
||||
if meta.SMTPMapping.Extra["SMTP_ACCEPT_INVALID_CERTS"] != "true" {
|
||||
t.Errorf("extra not parsed: %v", meta.SMTPMapping.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Tolerant decode: an app with no smtp_mapping parses fine and reports no mapping.
|
||||
func TestMetadata_NoSMTPMapping(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte("display_name: Plex\nslug: plex\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := LoadMetadata(dir)
|
||||
if meta.HasSMTPMapping() {
|
||||
t.Fatal("plex must report no smtp_mapping")
|
||||
}
|
||||
}
|
||||
@@ -883,6 +883,15 @@ func (m *Manager) stackEnv(stackDir string) []string {
|
||||
env = withUserdataPath(env, appCfg.Env["HDD_PATH"])
|
||||
}
|
||||
|
||||
// App-email relay env (appended LAST so it wins over any app.yaml default). Returns nil unless
|
||||
// global + per-app toggles are on and the app declares an smtp_mapping — so apps with email off
|
||||
// get nothing injected. Derived, never persisted to app.yaml.
|
||||
emailOn := appCfg != nil && appCfg.EmailEnabled
|
||||
meta := LoadMetadata(stackDir)
|
||||
if smtp := m.smtpEnv(&meta, emailOn); len(smtp) > 0 {
|
||||
env = append(env, smtp...)
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,32 @@ type Metadata struct {
|
||||
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
|
||||
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
|
||||
InitialCreds *InitialCredentials `yaml:"initial_credentials,omitempty" json:"initial_credentials,omitempty"`
|
||||
// SMTPMapping declares how this app's compose env receives the managed app-email relay settings.
|
||||
// Present only for apps that support outbound email; absent = the app has no email UI/injection.
|
||||
SMTPMapping *SMTPMapping `yaml:"smtp_mapping,omitempty" json:"smtp_mapping,omitempty"`
|
||||
}
|
||||
|
||||
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
|
||||
// to an app's specific env-var keys, plus any fixed Extra vars (e.g. accept-invalid-cert
|
||||
// flags). When app-email is on (global + per-app) the controller injects these at
|
||||
// deploy/redeploy: host = the in-controller shim, port = 2525, security = SecurityValue
|
||||
// (the app's term for STARTTLS), from = <FromLocal>@<allowlisted-domain>. The values are
|
||||
// NEVER persisted to app.yaml — they are derived from settings on every compose, so a
|
||||
// toggle change applies on the next redeploy without rewriting secrets. (Spike §7.)
|
||||
type SMTPMapping struct {
|
||||
HostVar string `yaml:"host_var" json:"host_var"` // env key for the shim host (required)
|
||||
PortVar string `yaml:"port_var" json:"port_var"` // env key for the port (required)
|
||||
SecurityVar string `yaml:"security_var" json:"security_var"` // env key for the TLS mode (optional)
|
||||
SecurityValue string `yaml:"security_value" json:"security_value"` // app term for STARTTLS (e.g. "starttls", "TLS")
|
||||
FromVar string `yaml:"from_var" json:"from_var"` // env key for the From address (required)
|
||||
FromNameVar string `yaml:"from_name_var" json:"from_name_var"` // env key for the From display name (optional)
|
||||
FromLocal string `yaml:"from_local" json:"from_local"` // From local-part (defaults to the app slug)
|
||||
Extra map[string]string `yaml:"extra" json:"extra"` // fixed extra env (accept-invalid-cert flags, etc.)
|
||||
}
|
||||
|
||||
// HasSMTPMapping reports whether this app declares a usable email mapping.
|
||||
func (m *Metadata) HasSMTPMapping() bool {
|
||||
return m.SMTPMapping != nil && m.SMTPMapping.HostVar != "" && m.SMTPMapping.FromVar != ""
|
||||
}
|
||||
|
||||
// InitialCredentials tells the controller how to extract an app's auto-generated first-login
|
||||
|
||||
@@ -398,6 +398,23 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
}
|
||||
data["CurrentValues"] = optValues
|
||||
}
|
||||
|
||||
// App-email per-app toggle — only for apps that declare an smtp_mapping. Shown with
|
||||
// honest context whether or not the global toggle is on.
|
||||
if supported, enabled := s.stackMgr.AppEmailStatus(name); supported {
|
||||
data["AppEmailSupported"] = true
|
||||
data["AppEmailAppOn"] = enabled
|
||||
data["AppEmailGlobalOn"] = s.settings.AppEmailEnabled()
|
||||
local := meta.SMTPMapping.FromLocal
|
||||
if local == "" {
|
||||
local = meta.Slug
|
||||
}
|
||||
domain := "felhom.eu"
|
||||
if len(s.cfg.MailRelay.FromDomains) > 0 && s.cfg.MailRelay.FromDomains[0] != "" {
|
||||
domain = s.cfg.MailRelay.FromDomains[0]
|
||||
}
|
||||
data["AppEmailFromAddress"] = local + "@" + domain
|
||||
}
|
||||
}
|
||||
|
||||
// Memory info for deploy page (only for non-deployed apps)
|
||||
@@ -881,6 +898,13 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
|
||||
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
|
||||
|
||||
// App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay
|
||||
// path runs through the hub); the template hides the control otherwise.
|
||||
appEmail := s.settings.GetAppEmail()
|
||||
data["AppEmailEnabled"] = appEmail.Enabled
|
||||
data["AppEmailFromName"] = appEmail.FromName
|
||||
data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled()
|
||||
|
||||
// Storage paths with display data
|
||||
storagePaths := s.settings.GetStoragePaths()
|
||||
connectedCount := 0
|
||||
@@ -1101,6 +1125,40 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
}
|
||||
|
||||
// settingsAppEmailHandler saves the global app-email toggle and starts/stops the on-box
|
||||
// SMTP shim to match (no controller restart needed).
|
||||
func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
|
||||
fromName := strings.TrimSpace(r.FormValue("app_email_from_name"))
|
||||
|
||||
data := s.settingsData()
|
||||
if err := s.settings.SetAppEmail(enabled, fromName); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err)
|
||||
data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor"
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
// Reconcile the shim's running state with the new toggle.
|
||||
if s.mailShim != nil {
|
||||
if err := s.mailShim.Apply(enabled); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err)
|
||||
data = s.settingsData()
|
||||
data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült."
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName)
|
||||
data = s.settingsData()
|
||||
if enabled {
|
||||
data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél."
|
||||
} else {
|
||||
data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva."
|
||||
}
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.settingsData()
|
||||
|
||||
|
||||
@@ -81,12 +81,27 @@ type Server struct {
|
||||
// manual trigger goes through the loop (stop stacks → backup → resume), never a bare agent call.
|
||||
backupTrigger BackupTrigger
|
||||
|
||||
// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
|
||||
// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
|
||||
mailShim MailShimController
|
||||
|
||||
// Debug mode support
|
||||
logBuffer *LogBuffer
|
||||
debugCallbacks *DebugCallbacks
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// MailShimController is the lifecycle handle the settings toggle uses to start/stop the
|
||||
// app-email SMTP shim at runtime. Satisfied by *mailrelay.Lifecycle (kept as an interface
|
||||
// to avoid a web→mailrelay import cycle risk and to allow a fake in tests).
|
||||
type MailShimController interface {
|
||||
Apply(enabled bool) error
|
||||
Running() bool
|
||||
}
|
||||
|
||||
// SetMailShim wires the app-email shim lifecycle (optional).
|
||||
func (s *Server) SetMailShim(c MailShimController) { s.mailShim = c }
|
||||
|
||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
@@ -253,6 +268,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.settingsNotificationsHandler(w, r)
|
||||
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
||||
s.settingsNotificationsTestHandler(w, r)
|
||||
case path == "/settings/app-email" && r.Method == http.MethodPost:
|
||||
s.settingsAppEmailHandler(w, r)
|
||||
case path == "/settings/storage/add" && r.Method == http.MethodPost:
|
||||
s.settingsStorageAddHandler(w, r)
|
||||
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
|
||||
@@ -287,6 +304,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
|
||||
s.tier2ConfigSaveHandler(w, r, name)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/app-email") && r.Method == http.MethodPost:
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/app-email")
|
||||
s.appEmailToggleHandler(w, r, name)
|
||||
case path == "/import":
|
||||
s.importPageHandler(w, r)
|
||||
case path == "/static/style.css":
|
||||
|
||||
@@ -393,6 +393,33 @@
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{if .AppEmailSupported}}
|
||||
<div class="app-optional-config">
|
||||
<h3>Email-küldés</h3>
|
||||
<p class="config-group-desc">
|
||||
Ez az alkalmazás tud emailt küldeni (pl. jelszó-visszaállítás, meghívók) a Felhom-on keresztül,
|
||||
külön szolgáltató beállítása nélkül. A feladó címe: <strong>{{.AppEmailFromAddress}}</strong>.
|
||||
</p>
|
||||
{{if not .AppEmailGlobalOn}}
|
||||
<p class="alert alert-warning" style="margin-bottom:1rem">
|
||||
Az alkalmazás-email jelenleg ki van kapcsolva globálisan. Kapcsold be a
|
||||
<a href="/settings">Beállítások</a> oldalon, hogy itt is működjön.
|
||||
</p>
|
||||
{{end}}
|
||||
<form method="POST" action="/stacks/{{.Stack.Name}}/app-email">
|
||||
{{$.CSRFField}}
|
||||
<label style="display:flex;align-items:center;gap:.5rem;margin-bottom:1rem">
|
||||
<input type="checkbox" name="app_email_enabled" value="on" {{if .AppEmailAppOn}}checked{{end}}>
|
||||
Email-küldés engedélyezése ennél az alkalmazásnál
|
||||
</label>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-primary" type="submit">Mentés</button>
|
||||
<span class="config-group-desc" style="margin-left:.5rem">A mentés újraindítja az alkalmazást.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and (not .AlreadyDeployed) .MemoryInfo}}
|
||||
{{with .MemoryInfo}}
|
||||
{{if .Available}}
|
||||
|
||||
@@ -1095,6 +1095,41 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section: App email (SMTP relay) -->
|
||||
{{if .AppEmailAvailable}}
|
||||
<div class="settings-card">
|
||||
<h3>Alkalmazás-email</h3>
|
||||
<p class="settings-card-desc">
|
||||
Az alkalmazások a Felhom-on keresztül küldhetnek emailt (pl. jelszó-visszaállítás, meghívók),
|
||||
külön email-szolgáltató beállítása nélkül. A feladó címe minden alkalmazásnál a saját
|
||||
<em><alkalmazás>@felhom.eu</em> címe lesz.
|
||||
</p>
|
||||
{{if .AppEmailSuccess}}<div class="alert alert-info">{{.AppEmailSuccess}}</div>{{end}}
|
||||
{{if .AppEmailError}}<div class="alert alert-error">{{.AppEmailError}}</div>{{end}}
|
||||
<form method="POST" action="/settings/app-email">
|
||||
{{.CSRFField}}
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" name="app_email_enabled" value="on" {{if .AppEmailEnabled}}checked{{end}}>
|
||||
Alkalmazás-email engedélyezése
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="app_email_from_name">Feladó neve (opcionális)</label>
|
||||
<input type="text" id="app_email_from_name" name="app_email_from_name"
|
||||
value="{{.AppEmailFromName}}" placeholder="pl. a háztartás neve" class="form-control">
|
||||
<span class="form-hint">Ez jelenik meg a kimenő emailek feladójaként az alkalmazás neve mellett.</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Mentés</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="settings-card-desc" style="margin-top:.75rem">
|
||||
Bekapcsolás után az egyes alkalmazásoknál is engedélyezni kell az email-küldést (az alkalmazás oldalán).
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section: Recovery Info -->
|
||||
{{if .RetrievalPassword}}
|
||||
<div class="settings-card">
|
||||
|
||||
@@ -93,6 +93,31 @@ func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request,
|
||||
s.redirectTier2(w, r, name, "A 2. mentés beállítása elmentve.", "")
|
||||
}
|
||||
|
||||
// appEmailToggleHandler flips the per-app email toggle (only for apps with an smtp_mapping)
|
||||
// and recreates the stack so the SMTP env injection takes effect. Redirects back to the
|
||||
// app's config page with a flash.
|
||||
func (s *Server) appEmailToggleHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if _, ok := s.stackMgr.GetStack(name); !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
|
||||
|
||||
dest := "/stacks/" + url.PathEscape(name) + "/deploy"
|
||||
if err := s.stackMgr.SetAppEmailEnabled(name, enabled); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] app-email toggle for %s: %v", name, err)
|
||||
http.Redirect(w, r, dest+"?flash_error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] App-email for %s set to %v", name, enabled)
|
||||
msg := "Email-küldés kikapcsolva ennél az alkalmazásnál."
|
||||
if enabled {
|
||||
msg = "Email-küldés bekapcsolva ennél az alkalmazásnál."
|
||||
}
|
||||
http.Redirect(w, r, dest+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// redirectTier2 sends the customer back to the panel with a flash message.
|
||||
func (s *Server) redirectTier2(w http.ResponseWriter, r *http.Request, name, flash, flashErr string) {
|
||||
dest := "/stacks/" + url.PathEscape(name) + "/backup"
|
||||
|
||||
Reference in New Issue
Block a user