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:
sysops
2026-03-31 22:36:57 +02:00
parent 7371a73b3e
commit c1a9004720
7 changed files with 698 additions and 8 deletions
+118
View File
@@ -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,
})
}