Build the Go API and React UI for managing domain denylist/allowlist/recipient deny and spam settings, plus shared lists that import/apply across domains with wildcard compaction, entry verification, and Migadu-friendly list encoding (including per-domain recipient filtering and rejection bisect on apply).
759 lines
23 KiB
Go
759 lines
23 KiB
Go
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")
|
|
}
|