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:
2026-06-29 08:45:04 +02:00
parent 7cddb885e0
commit 0e20eb19c1
22 changed files with 1619 additions and 64 deletions
+58
View File
@@ -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()
+20
View File
@@ -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>&lt;alkalmazás&gt;@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"