package api import ( "encoding/json" "errors" "fmt" "log" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/squid/MigaduAdmin/internal/auth" "github.com/squid/MigaduAdmin/internal/config" "github.com/squid/MigaduAdmin/internal/lists" "github.com/squid/MigaduAdmin/internal/migadu" "github.com/squid/MigaduAdmin/internal/store" ) type Server struct { cfg *config.Config auth *auth.Service store *store.Store migadu *migadu.Client } func New(cfg *config.Config, authService *auth.Service, st *store.Store, migaduClient *migadu.Client) *Server { return &Server{ cfg: cfg, auth: authService, store: st, migadu: migaduClient, } } func (s *Server) Router(static http.Handler) http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Logger) r.Use(middleware.Recoverer) // Do not use RealIP before auth: trusted-proxy checks need the immediate peer. r.Use(s.auth.StripUntrustedIdentityHeaders) r.Route("/api", func(api chi.Router) { api.Get("/health", s.handleHealth) api.Route("/auth", func(ar chi.Router) { ar.Get("/status", s.handleAuthStatus) ar.Post("/register", s.handleRegister) ar.Post("/login", s.handleLogin) ar.Post("/logout", s.handleLogout) ar.With(s.auth.Middleware).Get("/me", s.handleMe) }) api.Group(func(protected chi.Router) { protected.Use(s.auth.Middleware) protected.Use(s.auth.RequireAdmin) protected.Get("/managed-domains", s.handleListManagedDomains) protected.Post("/managed-domains", s.handleAddManagedDomain) protected.Delete("/managed-domains/{domain}", s.handleDeleteManagedDomain) protected.Get("/shared-lists", s.handleGetSharedLists) protected.Put("/shared-lists", s.handlePutSharedLists) protected.Post("/shared-lists/apply", s.handleApplySharedLists) protected.Post("/shared-lists/import", s.handleImportSharedLists) protected.Get("/domains/{domain}", s.handleGetDomain) protected.Patch("/domains/{domain}", s.handleUpdateDomain) protected.Get("/users", s.handleListUsers) protected.Post("/users", s.handleCreateUser) }) }) if static != nil { r.Handle("/*", static) } return r } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { allowed, err := s.auth.RegistrationAllowed(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check registration status") return } writeJSON(w, http.StatusOK, map[string]any{ "registration_open": allowed, "auth_mode": s.cfg.AuthMode, }) } type credentialsRequest struct { Email string `json:"email"` Password string `json:"password"` DisplayName string `json:"display_name"` } func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { var req credentialsRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } user, err := s.auth.Register(r.Context(), req.Email, req.DisplayName, req.Password) if err != nil { switch { case errors.Is(err, auth.ErrClosedReg): writeError(w, http.StatusForbidden, "registration is closed") case errors.Is(err, store.ErrEmailTaken): writeError(w, http.StatusConflict, "email already registered") case errors.Is(err, store.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid email or role") default: if strings.Contains(err.Error(), "password") { writeError(w, http.StatusBadRequest, err.Error()) return } writeError(w, http.StatusInternalServerError, "registration failed") } return } if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to create session") return } writeJSON(w, http.StatusCreated, user) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if s.cfg.AuthMode == config.AuthModePangolin { writeError(w, http.StatusBadRequest, "local login disabled; use Pangolin SSO") return } var req credentialsRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } user, err := s.auth.AuthenticateLocal(r.Context(), req.Email, req.Password) if err != nil { if errors.Is(err, auth.ErrUnauthorized) { writeError(w, http.StatusUnauthorized, "invalid email or password") return } writeError(w, http.StatusInternalServerError, "login failed") return } if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to create session") return } writeJSON(w, http.StatusOK, user) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { s.auth.ClearSession(r.Context(), w, r) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) writeJSON(w, http.StatusOK, user) } func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { users, err := s.store.ListUsers(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list users") return } writeJSON(w, http.StatusOK, map[string]any{"users": users}) } type createUserRequest struct { Email string `json:"email"` Password string `json:"password"` DisplayName string `json:"display_name"` Role store.Role `json:"role"` } func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { var req createUserRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } user, err := s.auth.CreateUserAsAdmin(r.Context(), req.Email, req.DisplayName, req.Password, req.Role) if err != nil { switch { case errors.Is(err, store.ErrEmailTaken): writeError(w, http.StatusConflict, "email already registered") case errors.Is(err, store.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid email or role") default: if strings.Contains(err.Error(), "password") { writeError(w, http.StatusBadRequest, err.Error()) return } writeError(w, http.StatusInternalServerError, "failed to create user") } return } writeJSON(w, http.StatusCreated, user) } func (s *Server) handleListManagedDomains(w http.ResponseWriter, r *http.Request) { managed, err := s.store.ListManagedDomains(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list managed domains") return } type managedDomainResponse struct { Name string `json:"name"` AddedAt string `json:"added_at"` AddedBy *int64 `json:"added_by,omitempty"` State string `json:"state,omitempty"` Description string `json:"description,omitempty"` Accessible bool `json:"accessible"` Error string `json:"error,omitempty"` } out := make([]managedDomainResponse, 0, len(managed)) for _, item := range managed { entry := managedDomainResponse{ Name: item.Name, AddedAt: item.AddedAt.UTC().Format(time.RFC3339Nano), AddedBy: item.AddedBy, } domain, err := s.migadu.GetDomain(r.Context(), item.Name) if err != nil { entry.Accessible = false var apiErr *migadu.APIError if errors.As(err, &apiErr) { entry.Error = fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode) } else { entry.Error = "migadu unreachable" } } else { entry.Accessible = true entry.State = domain.State entry.Description = domain.Description } out = append(out, entry) } writeJSON(w, http.StatusOK, map[string]any{"domains": out}) } type addManagedDomainRequest struct { Name string `json:"name"` } func (s *Server) handleAddManagedDomain(w http.ResponseWriter, r *http.Request) { var req addManagedDomainRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } name := strings.ToLower(strings.TrimSpace(req.Name)) if name == "" { writeError(w, http.StatusBadRequest, "domain name is required") return } log.Printf("managed-domains: verifying access to %q via Migadu", name) domain, err := s.migadu.GetDomain(r.Context(), name) if err != nil { var apiErr *migadu.APIError if errors.As(err, &apiErr) { log.Printf( "managed-domains: access check failed for %q: HTTP %d body=%q", name, apiErr.StatusCode, apiErr.Body, ) // Migadu commonly returns 400 on failure; also treat auth/not-found as inaccessible. if apiErr.StatusCode == http.StatusBadRequest || apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusForbidden || apiErr.StatusCode == http.StatusUnauthorized { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "cannot access domain with configured API key", "migadu_status": apiErr.StatusCode, "migadu_body": apiErr.Body, "domain": name, }) return } writeMigaduError(w, err) return } log.Printf("managed-domains: access check failed for %q: %v", name, err) writeError(w, http.StatusBadGateway, "cannot verify domain with Migadu: "+err.Error()) return } log.Printf("managed-domains: access ok for %q (state=%q)", domain.Name, domain.State) user := auth.UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "unauthorized") return } managed, err := s.store.AddManagedDomain(r.Context(), domain.Name, user.ID) if err != nil { switch { case errors.Is(err, store.ErrDomainExists): writeError(w, http.StatusConflict, "domain already managed") case errors.Is(err, store.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid domain name") default: writeError(w, http.StatusInternalServerError, "failed to save domain") } return } writeJSON(w, http.StatusCreated, map[string]any{ "name": managed.Name, "added_at": managed.AddedAt.UTC().Format(time.RFC3339Nano), "added_by": managed.AddedBy, "state": domain.State, "description": domain.Description, "accessible": true, }) } func (s *Server) handleDeleteManagedDomain(w http.ResponseWriter, r *http.Request) { domainName := chi.URLParam(r, "domain") if err := s.store.DeleteManagedDomain(r.Context(), domainName); err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "domain not found in registry") return } writeError(w, http.StatusInternalServerError, "failed to remove domain") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleGetSharedLists(w http.ResponseWriter, r *http.Request) { lists, err := s.store.GetSharedLists(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load shared lists") return } writeJSON(w, http.StatusOK, lists) } func (s *Server) handlePutSharedLists(w http.ResponseWriter, r *http.Request) { var req store.SharedLists if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if req.SenderDenylist == nil { req.SenderDenylist = []string{} } if req.SenderAllowlist == nil { req.SenderAllowlist = []string{} } if req.RecipientDenylist == nil { req.RecipientDenylist = []string{} } if err := s.store.PutSharedLists(r.Context(), req); err != nil { writeError(w, http.StatusInternalServerError, "failed to save shared lists") return } lists, err := s.store.GetSharedLists(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load shared lists") return } writeJSON(w, http.StatusOK, lists) } type sharedListsTargetsRequest struct { All bool `json:"all"` Domains []string `json:"domains"` Replace bool `json:"replace"` } type sharedListsDomainResult struct { Domain string `json:"domain"` OK bool `json:"ok"` Error string `json:"error,omitempty"` Rejected []string `json:"rejected,omitempty"` } func (s *Server) handleApplySharedLists(w http.ResponseWriter, r *http.Request) { var req sharedListsTargetsRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if !req.All && len(req.Domains) == 0 { writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list") return } shared, err := s.store.GetSharedLists(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load shared lists") return } targets, err := s.resolveSharedListTargets(r, req) if err != nil { if strings.Contains(err.Error(), "failed to") { writeError(w, http.StatusInternalServerError, err.Error()) return } writeError(w, http.StatusBadRequest, err.Error()) return } sharedDenylist := lists.Compact(shared.SenderDenylist) sharedAllowlist := lists.Compact(shared.SenderAllowlist) sharedRecipient := lists.Compact(shared.RecipientDenylist) // Persist normalized/compacted shared lists (repairs typos, drops covered entries). if err := s.store.PutSharedLists(r.Context(), store.SharedLists{ SenderDenylist: sharedDenylist, SenderAllowlist: sharedAllowlist, RecipientDenylist: sharedRecipient, }); err != nil { writeError(w, http.StatusInternalServerError, "failed to save compacted shared lists") return } log.Printf( "shared-lists apply: replace=%v targets=%d denylist=%d allowlist=%d recipient=%d", req.Replace, len(targets), len(sharedDenylist), len(sharedAllowlist), len(sharedRecipient), ) results := make([]sharedListsDomainResult, 0, len(targets)) for _, domainName := range targets { result := sharedListsDomainResult{Domain: domainName} var denylist, allowlist, recipient []string if req.Replace { // Always use non-nil slices so JSON encodes [] not null (Migadu 400s on null). denylist = append([]string{}, sharedDenylist...) allowlist = append([]string{}, sharedAllowlist...) // Recipient deny is domain-scoped: only addresses for this domain. recipient = lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient) } else { domain, err := s.migadu.GetDomain(r.Context(), domainName) if err != nil { result.Error = migaduErrorMessage(err) results = append(results, result) continue } denylist = unionStringLists([]string(domain.SenderDenylist), sharedDenylist) allowlist = unionStringLists([]string(domain.SenderAllowlist), sharedAllowlist) recipient = unionStringLists( []string(domain.RecipientDenylist), lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient), ) } writeResult, err := s.migadu.UpdateDomainLists(r.Context(), domainName, denylist, allowlist, recipient) if err != nil { result.Error = migaduErrorMessage(err) results = append(results, result) continue } result.OK = true result.Rejected = writeResult.Rejected results = append(results, result) } writeJSON(w, http.StatusOK, map[string]any{"results": results}) } func (s *Server) handleImportSharedLists(w http.ResponseWriter, r *http.Request) { var req sharedListsTargetsRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if !req.All && len(req.Domains) == 0 { writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list") return } shared, err := s.store.GetSharedLists(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load shared lists") return } targets, err := s.resolveSharedListTargets(r, req) if err != nil { if strings.Contains(err.Error(), "failed to") { writeError(w, http.StatusInternalServerError, err.Error()) return } writeError(w, http.StatusBadRequest, err.Error()) return } denylist := append([]string(nil), shared.SenderDenylist...) allowlist := append([]string(nil), shared.SenderAllowlist...) recipient := append([]string(nil), shared.RecipientDenylist...) results := make([]sharedListsDomainResult, 0, len(targets)) for _, domainName := range targets { result := sharedListsDomainResult{Domain: domainName} domain, err := s.migadu.GetDomain(r.Context(), domainName) if err != nil { result.Error = migaduErrorMessage(err) results = append(results, result) continue } denylist = append(denylist, []string(domain.SenderDenylist)...) allowlist = append(allowlist, []string(domain.SenderAllowlist)...) recipient = append(recipient, []string(domain.RecipientDenylist)...) result.OK = true results = append(results, result) } merged := store.SharedLists{ SenderDenylist: lists.Compact(denylist), SenderAllowlist: lists.Compact(allowlist), RecipientDenylist: lists.Compact(recipient), } if err := s.store.PutSharedLists(r.Context(), merged); err != nil { writeError(w, http.StatusInternalServerError, "failed to save shared lists") return } saved, err := s.store.GetSharedLists(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load shared lists") return } writeJSON(w, http.StatusOK, map[string]any{ "lists": saved, "results": results, }) } func (s *Server) resolveSharedListTargets(r *http.Request, req sharedListsTargetsRequest) ([]string, error) { if req.All { managed, err := s.store.ListManagedDomains(r.Context()) if err != nil { return nil, fmt.Errorf("failed to list managed domains") } names := make([]string, 0, len(managed)) for _, item := range managed { names = append(names, item.Name) } return names, nil } seen := make(map[string]struct{}, len(req.Domains)) names := make([]string, 0, len(req.Domains)) for _, raw := range req.Domains { name := strings.ToLower(strings.TrimSpace(raw)) if name == "" { continue } if _, ok := seen[name]; ok { continue } ok, err := s.store.IsManagedDomain(r.Context(), name) if err != nil { return nil, fmt.Errorf("failed to check managed domain") } if !ok { return nil, fmt.Errorf("domain is not in the administered registry: %s", name) } seen[name] = struct{}{} names = append(names, name) } if len(names) == 0 { return nil, fmt.Errorf("provide all=true or a non-empty domains list") } return names, nil } func unionStringLists(existing, shared []string) []string { out := make([]string, 0, len(existing)+len(shared)) out = append(out, existing...) out = append(out, shared...) return lists.Compact(out) } func migaduErrorMessage(err error) string { var apiErr *migadu.APIError if errors.As(err, &apiErr) { if apiErr.Body != "" { return fmt.Sprintf("migadu HTTP %d: %s", apiErr.StatusCode, apiErr.Body) } return fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode) } return err.Error() } func (s *Server) requireManagedDomain(w http.ResponseWriter, r *http.Request, domainName string) bool { ok, err := s.store.IsManagedDomain(r.Context(), domainName) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check managed domain") return false } if !ok { writeError(w, http.StatusForbidden, "domain is not in the administered registry") return false } return true } func (s *Server) handleGetDomain(w http.ResponseWriter, r *http.Request) { domainName := chi.URLParam(r, "domain") if !s.requireManagedDomain(w, r, domainName) { return } domain, err := s.migadu.GetDomain(r.Context(), domainName) if err != nil { writeMigaduError(w, err) return } writeJSON(w, http.StatusOK, domain) } func (s *Server) handleUpdateDomain(w http.ResponseWriter, r *http.Request) { domainName := chi.URLParam(r, "domain") if !s.requireManagedDomain(w, r, domainName) { return } var raw map[string]json.RawMessage if err := decodeJSON(r, &raw); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } allowed := map[string]struct{}{ "sender_denylist": {}, "sender_allowlist": {}, "recipient_denylist": {}, "spam_aggressiveness": {}, "junk_subject_keyword_spam": {}, "subject_rewriting_enabled": {}, } for key := range raw { if _, ok := allowed[key]; !ok { writeError(w, http.StatusBadRequest, "unsupported field: "+key) return } } var update migadu.DomainUpdate if value, ok := raw["sender_denylist"]; ok { var list []string if err := json.Unmarshal(value, &list); err != nil { writeError(w, http.StatusBadRequest, "sender_denylist must be a string array") return } list = lists.Compact(list) update.SenderDenylist = &list } if value, ok := raw["sender_allowlist"]; ok { var list []string if err := json.Unmarshal(value, &list); err != nil { writeError(w, http.StatusBadRequest, "sender_allowlist must be a string array") return } list = lists.Compact(list) update.SenderAllowlist = &list } if value, ok := raw["recipient_denylist"]; ok { var list []string if err := json.Unmarshal(value, &list); err != nil { writeError(w, http.StatusBadRequest, "recipient_denylist must be a string array") return } list = lists.Compact(list) update.RecipientDenylist = &list } if value, ok := raw["spam_aggressiveness"]; ok { var aggressiveness string if err := json.Unmarshal(value, &aggressiveness); err != nil { writeError(w, http.StatusBadRequest, "spam_aggressiveness must be a string") return } update.SpamAggressiveness = &aggressiveness } if value, ok := raw["junk_subject_keyword_spam"]; ok { var flag bool if err := json.Unmarshal(value, &flag); err != nil { writeError(w, http.StatusBadRequest, "junk_subject_keyword_spam must be a boolean") return } update.JunkSubjectKeywordSpam = &flag } if value, ok := raw["subject_rewriting_enabled"]; ok { var flag bool if err := json.Unmarshal(value, &flag); err != nil { writeError(w, http.StatusBadRequest, "subject_rewriting_enabled must be a boolean") return } update.SubjectRewritingEnabled = &flag } domain, err := s.migadu.UpdateDomain(r.Context(), domainName, update) if err != nil { writeMigaduError(w, err) return } writeJSON(w, http.StatusOK, domain) } func decodeJSON(r *http.Request, dest any) error { decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() return decoder.Decode(dest) } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } func writeMigaduError(w http.ResponseWriter, err error) { var apiErr *migadu.APIError if errors.As(err, &apiErr) { status := http.StatusBadGateway if apiErr.StatusCode == http.StatusNotFound { status = http.StatusNotFound } else if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { status = apiErr.StatusCode } writeJSON(w, status, map[string]any{ "error": "migadu request failed", "migadu_status": apiErr.StatusCode, "migadu_body": apiErr.Body, }) return } writeError(w, http.StatusBadGateway, "migadu request failed") }