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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user