hub v0.18.0: app-email passthrough POST /api/v1/mail → Resend SMTP

Raw-MIME passthrough (STARTTLS, AUTH LOGIN) — separate from the notify HTTP-API
alert path (which drops inline CID images). Per-customer token-bucket rate limit,
From-header allowlist backstop. Resend key stays hub-side. No new external dep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 08:45:22 +02:00
parent 4b97855cdd
commit fa3c4f2657
8 changed files with 695 additions and 72 deletions
+38
View File
@@ -14,6 +14,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -65,6 +66,31 @@ type Config struct {
// with DEFAULT_MIN_CONTROLLER_VERSION.
DefaultMinVersion string `yaml:"default_min_version"`
} `yaml:"controller_updates"`
Mail MailConfig `yaml:"mail"`
}
// MailConfig tunes the app-email passthrough (POST /api/v1/mail).
type MailConfig struct {
// PerCustomerPerMinute caps a single customer's forwarded messages per minute (abuse
// containment — one box can't drain the shared Resend quota). Default 30.
PerCustomerPerMinute int `yaml:"per_customer_per_minute"`
// FromDomains is the From-header allowlist (backstop; Resend is the final backstop).
// Default ["felhom.eu"].
FromDomains []string `yaml:"from_domains"`
}
func (m MailConfig) effectivePerMinute() int {
if m.PerCustomerPerMinute <= 0 {
return 30
}
return m.PerCustomerPerMinute
}
func (m MailConfig) effectiveFromDomains() []string {
if len(m.FromDomains) == 0 {
return []string{"felhom.eu"}
}
return m.FromDomains
}
func main() {
@@ -205,6 +231,18 @@ func main() {
)
apiHandler.SetDispatcher(dispatcher)
// App-email passthrough (POST /api/v1/mail): a customer box's shim forwards a raw message
// here and the hub re-emits it to Resend over SMTP, UNCHANGED (raw passthrough — NOT the
// dispatcher's structured HTTP-API path, which drops inline CID images). The Resend key stays
// hub-side. Wired only when a key is present; otherwise the endpoint returns 503.
if cfg.Notifications.ResendAPIKey != "" {
mailSender := mailrelay.NewResendSMTP(cfg.Notifications.ResendAPIKey)
apiHandler.SetMailRelay(mailSender, cfg.Mail.PerCustomerPerMinute, cfg.Mail.FromDomains)
logger.Printf("[INFO] App-email relay enabled (limit %d/min/customer, From domains %v)", cfg.Mail.effectivePerMinute(), cfg.Mail.effectiveFromDomains())
} else {
logger.Printf("[INFO] App-email relay disabled (no Resend key)")
}
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
webServer.SetTemplateFetcher(templateFetcher)
webServer.SetAssetManager(assetsMgr)