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