package api import ( "context" "encoding/json" "errors" "net/http" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" ) type smtpConfigResp struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int32 `json:"port"` Username string `json:"username"` Password string `json:"password"` // masked: "***" if non-empty, "" otherwise FromAddress string `json:"from_address"` FromName string `json:"from_name"` UseTLS bool `json:"use_tls"` } type updateSMTPConfigReq struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int32 `json:"port"` Username string `json:"username"` Password string `json:"password"` // empty string = preserve existing FromAddress string `json:"from_address"` FromName string `json:"from_name"` UseTLS bool `json:"use_tls"` } func (h *handlers) handleGetSMTPConfig(w http.ResponseWriter, r *http.Request) { q := dbq.New(h.pool) cfg, err := q.GetSMTPConfig(r.Context()) if err != nil { writeErrWithLog(w, h.logger, "admin smtp: get failed", apierror.Internal(err)) return } masked := "" if cfg.Password != "" { masked = "***" } writeJSON(w, http.StatusOK, smtpConfigResp{ Enabled: cfg.Enabled, Host: cfg.Host, Port: cfg.Port, Username: cfg.Username, Password: masked, FromAddress: cfg.FromAddress, FromName: cfg.FromName, UseTLS: cfg.UseTls, }) } func (h *handlers) handleUpdateSMTPConfig(w http.ResponseWriter, r *http.Request) { var req updateSMTPConfigReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, apierror.BadRequest("invalid_body", "")) return } if req.Enabled { if req.Host == "" || req.FromAddress == "" { writeErr(w, apierror.BadRequest("missing_fields", "host and from_address are required when enabled")) return } } q := dbq.New(h.pool) current, err := q.GetSMTPConfig(r.Context()) if err != nil { writeErrWithLog(w, h.logger, "admin smtp: get failed", apierror.Internal(err)) return } // Preserve existing password when the request sends "" (so the // admin doesn't have to re-enter it on every save). password := req.Password if password == "" { password = current.Password } if err := q.UpdateSMTPConfig(r.Context(), dbq.UpdateSMTPConfigParams{ Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: password, FromAddress: req.FromAddress, FromName: req.FromName, UseTls: req.UseTLS, }); err != nil { writeErrWithLog(w, h.logger, "admin smtp: update failed", apierror.Internal(err)) return } w.WriteHeader(http.StatusNoContent) } func (h *handlers) handleTestSMTPConfig(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } if caller.Email == nil || *caller.Email == "" { writeErr(w, apierror.BadRequest("no_email_on_file", "set your email in /settings before testing SMTP")) return } // Use a short-circuit context so the SMTP send doesn't hang the // HTTP request indefinitely on a misconfigured host. ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() sender := mailer.NewSMTPSender(h.pool, h.logger) textBody := "This is a test message from your Minstrel server.\n\nIf you got this, your SMTP config is working.\n\nā Minstrel" htmlBody := "
This is a test message from your Minstrel server.
If you got this, your SMTP config is working.
ā Minstrel
" if err := sender.Send(ctx, *caller.Email, "Minstrel SMTP test", textBody, htmlBody); err != nil { if errors.Is(err, mailer.ErrNotConfigured) { writeErr(w, apierror.BadRequest("not_configured", "SMTP is not enabled or required fields are empty")) return } h.logger.Error("admin smtp: test send failed", "err", err) writeErr(w, &apierror.Error{Status: http.StatusInternalServerError, Code: "send_failed", Message: err.Error()}) return } w.WriteHeader(http.StatusNoContent) }