feat(PROJ-28): SMTP-Out Relay — DB-Konfiguration + Admin-Tab
- smtpoutconfig.Store: AES-256-GCM verschlüsseltes Passwort in DB (id=1 Singleton) - Mailer: Reload() für Runtime-Konfigurationswechsel (sync.RWMutex) - API: GET/PUT/DELETE /api/admin/smtp-out + POST /api/admin/smtp-out/test - Admin-Tab: Host, Port, User, Passwort, TLS-Switch, From, Test-Button, Status-Badge - Startup: Lädt DB-Konfiguration und aktiviert Mailer ohne Restart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||
"github.com/archivmail/internal/mailer"
|
||||
pop3store "github.com/archivmail/internal/pop3"
|
||||
"github.com/archivmail/internal/smtpoutconfig"
|
||||
"github.com/archivmail/internal/smtpd"
|
||||
"github.com/archivmail/internal/storage"
|
||||
"github.com/archivmail/internal/tenantstore"
|
||||
@@ -86,6 +87,7 @@ type Server struct {
|
||||
mailer *mailer.Mailer
|
||||
tokenStore *tokenstore.Store
|
||||
fqdn string // from server.fqdn config (PROJ-28)
|
||||
smtpOutStore *smtpoutconfig.Store
|
||||
}
|
||||
|
||||
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
||||
@@ -137,6 +139,11 @@ func (s *Server) SetFQDN(fqdn string) {
|
||||
s.fqdn = fqdn
|
||||
}
|
||||
|
||||
// SetSMTPOutStore wires the SMTP-Out config store into the API server.
|
||||
func (s *Server) SetSMTPOutStore(store *smtpoutconfig.Store) {
|
||||
s.smtpOutStore = store
|
||||
}
|
||||
|
||||
// New creates and wires up a new API server.
|
||||
func New(
|
||||
cfg config.APIConfig,
|
||||
@@ -209,6 +216,12 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention)))
|
||||
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention)))
|
||||
|
||||
// SMTP-Out Relay Konfiguration — superadmin only
|
||||
s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut)))
|
||||
s.mux.HandleFunc("PUT /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSaveSMTPOut)))
|
||||
s.mux.HandleFunc("DELETE /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteSMTPOut)))
|
||||
s.mux.HandleFunc("POST /api/admin/smtp-out/test", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleTestSMTPOut)))
|
||||
|
||||
// PROJ-29: Quotas — superadmin only
|
||||
s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage)))
|
||||
s.mux.HandleFunc("GET /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage)))
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/archivmail/config"
|
||||
"github.com/archivmail/internal/audit"
|
||||
"github.com/archivmail/internal/smtpoutconfig"
|
||||
)
|
||||
|
||||
// handleGetSMTPOut returns the current SMTP-Out relay config (password masked).
|
||||
// GET /api/admin/smtp-out — superadmin only.
|
||||
func (s *Server) handleGetSMTPOut(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.smtpOutStore.Get(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"configured": false})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"configured": true, "config": cfg})
|
||||
}
|
||||
|
||||
// handleSaveSMTPOut upserts the SMTP-Out relay config and reloads the mailer.
|
||||
// PUT /api/admin/smtp-out — superadmin only.
|
||||
func (s *Server) handleSaveSMTPOut(w http.ResponseWriter, r *http.Request) {
|
||||
var body smtpoutconfig.SMTPOutConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if body.Port == 0 {
|
||||
body.Port = 587
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if err := s.smtpOutStore.Save(r.Context(), body, sess.Username); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reload the mailer at runtime — no restart needed
|
||||
if full, err := s.smtpOutStore.GetWithPassword(r.Context()); err == nil && full != nil {
|
||||
s.mailer.Reload(config.SMTPOutConfig{
|
||||
Host: full.Host,
|
||||
Port: full.Port,
|
||||
User: full.User,
|
||||
Password: full.Password,
|
||||
TLS: full.TLS,
|
||||
From: full.From,
|
||||
})
|
||||
}
|
||||
|
||||
if s.audlog != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "smtp_out_config_saved",
|
||||
Username: sess.Username,
|
||||
IPAddress: s.remoteIP(r),
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// handleDeleteSMTPOut removes the SMTP-Out config and disables the mailer.
|
||||
// DELETE /api/admin/smtp-out — superadmin only.
|
||||
func (s *Server) handleDeleteSMTPOut(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.smtpOutStore.Delete(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Disable mailer
|
||||
s.mailer.Reload(config.SMTPOutConfig{})
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if s.audlog != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "smtp_out_config_deleted",
|
||||
Username: sess.Username,
|
||||
IPAddress: s.remoteIP(r),
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// handleTestSMTPOut sends a test email to the logged-in admin.
|
||||
// POST /api/admin/smtp-out/test — superadmin only.
|
||||
func (s *Server) handleTestSMTPOut(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.mailer.IsConfigured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "SMTP-Out nicht konfiguriert")
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
u, err := s.users.GetByUsername(sess.Username)
|
||||
if err != nil || u.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "Keine E-Mail-Adresse für diesen Account")
|
||||
return
|
||||
}
|
||||
|
||||
html := `<p>Dies ist eine Test-E-Mail von <strong>archivmail</strong>.<br>Der SMTP-Relay ist korrekt konfiguriert.</p>`
|
||||
txt := "Dies ist eine Test-E-Mail von archivmail. Der SMTP-Relay ist korrekt konfiguriert."
|
||||
|
||||
if err := s.mailer.Send(u.Email, "archivmail – SMTP-Test", html, txt); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Versand fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"sent_to": u.Email,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user