Add Migadu domain admin with shared lists, compaction, and safer apply.

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).
This commit is contained in:
2026-07-26 21:50:26 +02:00
parent 930f1f7b69
commit a79cb61104
55 changed files with 7600 additions and 1 deletions
+758
View File
@@ -0,0 +1,758 @@
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")
}
+320
View File
@@ -0,0 +1,320 @@
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/squid/MigaduAdmin/internal/config"
"github.com/squid/MigaduAdmin/internal/store"
"golang.org/x/crypto/bcrypt"
)
const (
SessionCookieName = "migaduadmin_session"
SessionTTL = 7 * 24 * time.Hour
bcryptCost = 12
)
type contextKey string
const userContextKey contextKey = "user"
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrClosedReg = errors.New("registration is closed")
)
type Service struct {
store *store.Store
config *config.Config
}
func NewService(st *store.Store, cfg *config.Config) *Service {
return &Service{store: st, config: cfg}
}
func (s *Service) HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", fmt.Errorf("password must be at least 8 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func (s *Service) CheckPassword(hash, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (s *Service) RegistrationAllowed(ctx context.Context) (bool, error) {
count, err := s.store.CountUsers(ctx)
if err != nil {
return false, err
}
if count == 0 {
return true, nil
}
return s.config.RegistrationOpen, nil
}
func (s *Service) Register(ctx context.Context, email, displayName, password string) (*store.User, error) {
allowed, err := s.RegistrationAllowed(ctx)
if err != nil {
return nil, err
}
if !allowed {
return nil, ErrClosedReg
}
count, err := s.store.CountUsers(ctx)
if err != nil {
return nil, err
}
role := store.RoleUser
if count == 0 {
role = store.RoleAdmin
}
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
return s.store.CreateUser(ctx, email, displayName, hash, role)
}
func (s *Service) CreateUserAsAdmin(ctx context.Context, email, displayName, password string, role store.Role) (*store.User, error) {
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
if role == "" {
role = store.RoleUser
}
return s.store.CreateUser(ctx, email, displayName, hash, role)
}
func (s *Service) AuthenticateLocal(ctx context.Context, email, password string) (*store.User, error) {
user, err := s.store.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
if !s.CheckPassword(user.PasswordHash, password) {
return nil, ErrUnauthorized
}
return user, nil
}
func (s *Service) CreateSession(ctx context.Context, w http.ResponseWriter, userID int64) error {
_ = s.store.DeleteExpiredSessions(ctx)
token, err := randomToken(32)
if err != nil {
return err
}
expires := time.Now().UTC().Add(SessionTTL)
if err := s.store.CreateSession(ctx, token, userID, expires); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.CookieSecure,
Expires: expires,
MaxAge: int(SessionTTL.Seconds()),
})
return nil
}
func (s *Service) ClearSession(ctx context.Context, w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
_ = s.store.DeleteSession(ctx, cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.CookieSecure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (*store.User, error) {
if s.config.AuthMode == config.AuthModePangolin || s.config.AuthMode == config.AuthModeBoth {
if user, err := s.userFromPangolin(ctx, r); err == nil {
return user, nil
} else if !errors.Is(err, ErrUnauthorized) {
return nil, err
}
}
if s.config.AuthMode == config.AuthModeLocal || s.config.AuthMode == config.AuthModeBoth {
return s.userFromSession(ctx, r)
}
return nil, ErrUnauthorized
}
func (s *Service) userFromSession(ctx context.Context, r *http.Request) (*store.User, error) {
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
return nil, ErrUnauthorized
}
session, err := s.store.GetSession(ctx, cookie.Value)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
if time.Now().UTC().After(session.ExpiresAt) {
_ = s.store.DeleteSession(ctx, session.ID)
return nil, ErrUnauthorized
}
user, err := s.store.GetUserByID(ctx, session.UserID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
return user, nil
}
func (s *Service) userFromPangolin(ctx context.Context, r *http.Request) (*store.User, error) {
if !s.isTrustedPeer(r) {
return nil, ErrUnauthorized
}
email := strings.TrimSpace(r.Header.Get("Remote-Email"))
if email == "" {
return nil, ErrUnauthorized
}
displayName := strings.TrimSpace(r.Header.Get("Remote-Name"))
if displayName == "" {
displayName = strings.TrimSpace(r.Header.Get("Remote-User"))
}
user, err := s.store.GetUserByEmail(ctx, email)
if err == nil {
return user, nil
}
if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
count, err := s.store.CountUsers(ctx)
if err != nil {
return nil, err
}
role := store.RoleUser
if count == 0 {
role = store.RoleAdmin
} else if !s.config.SSOAutoProvision {
return nil, ErrUnauthorized
}
return s.store.CreateUser(ctx, email, displayName, "", role)
}
func (s *Service) isTrustedPeer(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return s.config.IsTrustedProxy(ip)
}
// StripUntrustedIdentityHeaders removes Pangolin identity headers unless the peer is trusted.
func (s *Service) StripUntrustedIdentityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.isTrustedPeer(r) {
headers := r.Header.Clone()
headers.Del("Remote-Email")
headers.Del("Remote-User")
headers.Del("Remote-Name")
headers.Del("Remote-Role")
r = r.Clone(r.Context())
r.Header = headers
}
next.ServeHTTP(w, r)
})
}
func (s *Service) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := s.UserFromRequest(r.Context(), r)
if err != nil {
if errors.Is(err, ErrUnauthorized) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Service) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil || user.Role != store.RoleAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func UserFromContext(ctx context.Context) *store.User {
user, _ := ctx.Value(userContextKey).(*store.User)
return user
}
func ConstantTimeEquals(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func RandomSecret(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
+165
View File
@@ -0,0 +1,165 @@
package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type AuthMode string
const (
AuthModeLocal AuthMode = "local"
AuthModePangolin AuthMode = "pangolin"
AuthModeBoth AuthMode = "both"
)
type Config struct {
ListenAddr string
MigaduUser string
MigaduAPIKey string
MigaduBaseURL string
SessionSecret string
DatabasePath string
AuthMode AuthMode
TrustedProxies []*net.IPNet
RegistrationOpen bool
SSOAutoProvision bool
CookieSecure bool
}
func Load() (*Config, error) {
// Prefer project .env over stale exported shell vars (common after editing the key).
if err := godotenv.Overload(); err != nil {
// Missing .env is fine when vars are provided by the environment.
if !os.IsNotExist(err) {
// godotenv returns a plain error for missing file; ignore only that case.
if !strings.Contains(err.Error(), "no such file") {
return nil, fmt.Errorf("load .env: %w", err)
}
}
}
authMode := AuthMode(strings.ToLower(strings.TrimSpace(envOr("AUTH_MODE", "both"))))
switch authMode {
case AuthModeLocal, AuthModePangolin, AuthModeBoth:
default:
return nil, fmt.Errorf("invalid AUTH_MODE %q (use local, pangolin, or both)", authMode)
}
trusted, err := parseCIDRs(envOr("TRUSTED_PROXIES", "127.0.0.1/32,::1/128"))
if err != nil {
return nil, fmt.Errorf("TRUSTED_PROXIES: %w", err)
}
cfg := &Config{
ListenAddr: envOr("LISTEN_ADDR", ":8080"),
MigaduUser: cleanEnv(os.Getenv("MIGADU_USER")),
MigaduAPIKey: cleanEnv(os.Getenv("MIGADU_API_KEY")),
MigaduBaseURL: strings.TrimRight(envOr("MIGADU_BASE_URL", "https://api.migadu.com/v1"), "/"),
SessionSecret: cleanEnv(os.Getenv("SESSION_SECRET")),
DatabasePath: envOr("DATABASE_PATH", "data/migaduadmin.db"),
AuthMode: authMode,
TrustedProxies: trusted,
RegistrationOpen: envBool("REGISTRATION_OPEN", false),
SSOAutoProvision: envBool("SSO_AUTO_PROVISION", false),
CookieSecure: envBool("COOKIE_SECURE", false),
}
if cfg.MigaduUser == "" {
return nil, fmt.Errorf("MIGADU_USER is required")
}
if cfg.MigaduAPIKey == "" {
return nil, fmt.Errorf("MIGADU_API_KEY is required")
}
if strings.ContainsAny(cfg.MigaduAPIKey, " \t\n\r") {
return nil, fmt.Errorf("MIGADU_API_KEY contains whitespace; check .env quoting")
}
if cfg.SessionSecret == "" || len(cfg.SessionSecret) < 32 {
return nil, fmt.Errorf("SESSION_SECRET is required and must be at least 32 characters")
}
return cfg, nil
}
// MaskedMigaduKey returns a safe fingerprint for logs.
func (c *Config) MaskedMigaduKey() string {
key := c.MigaduAPIKey
if key == "" {
return "(empty)"
}
if len(key) <= 8 {
return "****"
}
return fmt.Sprintf("%s…%s (len=%d)", key[:4], key[len(key)-4:], len(key))
}
func cleanEnv(value string) string {
value = strings.TrimSpace(value)
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = strings.TrimSpace(value[1 : len(value)-1])
}
}
return value
}
func (c *Config) IsTrustedProxy(ip net.IP) bool {
if ip == nil {
return false
}
for _, network := range c.TrustedProxies {
if network.Contains(ip) {
return true
}
}
return false
}
func envOr(key, fallback string) string {
if value := cleanEnv(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envBool(key string, fallback bool) bool {
raw := cleanEnv(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return value
}
func parseCIDRs(raw string) ([]*net.IPNet, error) {
parts := strings.Split(raw, ",")
networks := make([]*net.IPNet, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, "/") {
if strings.Contains(part, ":") {
part += "/128"
} else {
part += "/32"
}
}
_, network, err := net.ParseCIDR(part)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", part, err)
}
networks = append(networks, network)
}
return networks, nil
}
+25
View File
@@ -0,0 +1,25 @@
package config
import (
"net"
"testing"
)
func TestIsTrustedProxy(t *testing.T) {
cfg := &Config{}
networks, err := parseCIDRs("10.0.0.0/8,127.0.0.1")
if err != nil {
t.Fatal(err)
}
cfg.TrustedProxies = networks
if !cfg.IsTrustedProxy(net.ParseIP("10.1.2.3")) {
t.Fatal("expected 10.1.2.3 trusted")
}
if !cfg.IsTrustedProxy(net.ParseIP("127.0.0.1")) {
t.Fatal("expected 127.0.0.1 trusted")
}
if cfg.IsTrustedProxy(net.ParseIP("192.168.1.1")) {
t.Fatal("expected 192.168.1.1 untrusted")
}
}
+136
View File
@@ -0,0 +1,136 @@
package lists
import "strings"
// Compact trims, lowercases, normalizes, dedupes, and drops entries covered by a
// broader wildcard in the same list (*@domain or *@*.suffix).
func Compact(entries []string) []string {
if len(entries) == 0 {
return []string{}
}
unique := make([]string, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, value := range entries {
entry := normalizeEntry(value)
if entry == "" {
continue
}
if _, ok := seen[entry]; ok {
continue
}
seen[entry] = struct{}{}
unique = append(unique, entry)
}
out := make([]string, 0, len(unique))
for i, entry := range unique {
covered := false
for j, other := range unique {
if i == j {
continue
}
if Covers(other, entry) && other != entry {
covered = true
break
}
}
if !covered {
out = append(out, entry)
}
}
return out
}
// normalizeEntry lowercases and repairs common wildcard typos:
// *domain.tld → *@domain.tld
// *@*rumble.com → *@*.rumble.com
func normalizeEntry(value string) string {
entry := strings.ToLower(strings.TrimSpace(value))
if entry == "" {
return ""
}
if strings.HasPrefix(entry, "*") && !strings.Contains(entry, "@") && strings.Contains(entry, ".") {
entry = "*@" + strings.TrimPrefix(entry, "*")
}
local, domain, ok := splitAddress(entry)
if ok && local == "*" && strings.HasPrefix(domain, "*") && !strings.HasPrefix(domain, "*.") {
entry = "*@*." + strings.TrimPrefix(domain, "*")
}
return entry
}
// Covers reports whether broader covers specific.
// Equal entries cover each other.
// *@domain covers any local@domain for that domain.
// *@*.suffix covers any local@host.suffix (domain glob).
func Covers(broader, specific string) bool {
broader = normalizeEntry(broader)
specific = normalizeEntry(specific)
if broader == "" || specific == "" {
return false
}
if broader == specific {
return true
}
broaderLocal, broaderDomain, broaderOK := splitAddress(broader)
_, specificDomain, specificOK := splitAddress(specific)
if !broaderOK || !specificOK {
return false
}
if broaderLocal != "*" {
return false
}
return domainMatches(broaderDomain, specificDomain)
}
func domainMatches(pattern, domain string) bool {
if pattern == domain {
return true
}
// *.suffix → any domain ending with .suffix (and longer than the suffix).
if strings.HasPrefix(pattern, "*.") {
suffix := pattern[1:] // ".shop", ".ac.in"
return strings.HasSuffix(domain, suffix) && len(domain) > len(suffix)
}
return false
}
func splitAddress(entry string) (local, domain string, ok bool) {
at := strings.LastIndex(entry, "@")
if at <= 0 || at == len(entry)-1 {
return "", "", false
}
return entry[:at], entry[at+1:], true
}
// FilterRecipientEntriesForDomain keeps recipient-deny entries that belong to
// the given domain. Migadu rejects addresses for other domains on PATCH.
// Bare local parts (no @) are kept and applied to every domain.
func FilterRecipientEntriesForDomain(domainName string, entries []string) []string {
domainName = strings.ToLower(strings.TrimSpace(domainName))
out := make([]string, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, value := range entries {
entry := normalizeEntry(value)
if entry == "" {
continue
}
local, entryDomain, hasAt := splitAddress(entry)
if hasAt {
if entryDomain != domainName {
continue
}
// Prefer local@domain form; keep as normalized.
entry = local + "@" + entryDomain
}
// Bare local-part applies to this domain as-is.
if _, ok := seen[entry]; ok {
continue
}
seen[entry] = struct{}{}
out = append(out, entry)
}
return out
}
+127
View File
@@ -0,0 +1,127 @@
package lists
import (
"reflect"
"testing"
)
func TestCompact(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input []string
want []string
}{
{
name: "empty",
input: nil,
want: []string{},
},
{
name: "exact duplicates case insensitive",
input: []string{"Lisa@Simpsons.com", "lisa@simpsons.com", " LISA@SIMPSONS.COM "},
want: []string{"lisa@simpsons.com"},
},
{
name: "wildcard covers specific",
input: []string{"lisa@simpsons.com", "*@simpsons.com"},
want: []string{"*@simpsons.com"},
},
{
name: "specific dropped when wildcard present first",
input: []string{"*@simpsons.com", "lisa@simpsons.com", "homer@simpsons.com"},
want: []string{"*@simpsons.com"},
},
{
name: "distinct locals kept",
input: []string{"lisa@simpsons.com", "homer@simpsons.com"},
want: []string{"lisa@simpsons.com", "homer@simpsons.com"},
},
{
name: "different domains kept",
input: []string{"*@simpsons.com", "lisa@flanders.com", "*@flanders.com"},
want: []string{"*@simpsons.com", "*@flanders.com"},
},
{
name: "bare hostname exact dedupe only",
input: []string{"simpsons.com", "Simpsons.com", "lisa@simpsons.com"},
want: []string{"simpsons.com", "lisa@simpsons.com"},
},
{
name: "blank entries dropped",
input: []string{"", " ", "a@b.com"},
want: []string{"a@b.com"},
},
{
name: "repair star without at",
input: []string{"*vsa.instalily.ai", "lisa@vsa.instalily.ai"},
want: []string{"*@vsa.instalily.ai"},
},
{
name: "suffix glob covers specific domains",
input: []string{"*@*.shop", "*@alth.shop", "spam@woowstars.shop", "*@other.com"},
want: []string{"*@*.shop", "*@other.com"},
},
{
name: "repair star-domain without dot",
input: []string{"*@*rumble.com", "user@foo.rumble.com"},
want: []string{"*@*.rumble.com"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Compact(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("Compact() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestFilterRecipientEntriesForDomain(t *testing.T) {
t.Parallel()
got := FilterRecipientEntriesForDomain("skaab.nu", []string{
"brittis@gronberg.info",
"brgr@tspse.net",
"abuse@skaab.nu",
"postmaster",
"ABUSE@SKAAB.NU",
})
want := []string{"abuse@skaab.nu", "postmaster"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestCovers(t *testing.T) {
t.Parallel()
tests := []struct {
broader string
specific string
want bool
}{
{"*@simpsons.com", "lisa@simpsons.com", true},
{"*@simpsons.com", "*@simpsons.com", true},
{"lisa@simpsons.com", "*@simpsons.com", false},
{"lisa@simpsons.com", "homer@simpsons.com", false},
{"*@simpsons.com", "lisa@flanders.com", false},
{"simpsons.com", "lisa@simpsons.com", false},
{"", "a@b.com", false},
{"*@*.shop", "*@alth.shop", true},
{"*@*.shop", "user@alth.shop", true},
{"*@*.shop", "*@other.com", false},
{"*@simpsons.com", "lisa@mail.simpsons.com", false},
{"*vsa.instalily.ai", "x@vsa.instalily.ai", true},
}
for _, tt := range tests {
got := Covers(tt.broader, tt.specific)
if got != tt.want {
t.Fatalf("Covers(%q, %q) = %v, want %v", tt.broader, tt.specific, got, tt.want)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package lists
import (
"fmt"
"strings"
"unicode"
)
// EntryIssue describes a single invalid list entry.
type EntryIssue struct {
Entry string `json:"entry"`
Reason string `json:"reason"`
}
// ValidateEntries checks each entry against Migadu-style address patterns.
// Empty input is valid. Entries are normalized before checking.
func ValidateEntries(entries []string) []EntryIssue {
issues := make([]EntryIssue, 0)
for _, value := range entries {
raw := strings.TrimSpace(value)
if raw == "" {
continue
}
entry := normalizeEntry(raw)
if reason := validateNormalizedEntry(entry); reason != "" {
issues = append(issues, EntryIssue{Entry: raw, Reason: reason})
}
}
return issues
}
// ValidateEntry returns an error reason for one entry, or "" if valid.
func ValidateEntry(value string) string {
raw := strings.TrimSpace(value)
if raw == "" {
return "entry is empty"
}
return validateNormalizedEntry(normalizeEntry(raw))
}
func validateNormalizedEntry(entry string) string {
if entry == "" {
return "entry is empty"
}
if strings.ContainsAny(entry, " \t\r\n") {
return "entry must not contain whitespace"
}
if strings.Count(entry, "@") != 1 {
return "entry must look like local@domain (e.g. user@example.com or *@example.com)"
}
local, domain, ok := splitAddress(entry)
if !ok {
return "entry must look like local@domain"
}
if local == "" {
return "local part before @ is empty"
}
if !validLocalPart(local) {
return "invalid local part (use user, *@domain, or *prefix)"
}
if domain == "" {
return "domain after @ is empty"
}
if !validDomainPart(domain) {
return "invalid domain (use example.com or *.com / *.co.uk)"
}
return ""
}
func validLocalPart(local string) bool {
if local == "*" {
return true
}
// *prefix or normal local-part characters commonly used in email addresses.
if strings.HasPrefix(local, "*") {
rest := local[1:]
if rest == "" {
return true
}
return validLocalChars(rest)
}
return validLocalChars(local)
}
func validLocalChars(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '.', '_', '+', '-', '%':
continue
default:
return false
}
}
return true
}
func validDomainPart(domain string) bool {
if strings.HasPrefix(domain, "*.") {
return validDNSSuffix(domain[2:])
}
return validDNSDomain(domain)
}
// validDNSSuffix allows "shop" or "ac.in" after a *. glob prefix.
func validDNSSuffix(suffix string) bool {
if suffix == "" || len(suffix) > 253 {
return false
}
if strings.HasPrefix(suffix, ".") || strings.HasSuffix(suffix, ".") {
return false
}
labels := strings.Split(suffix, ".")
if len(labels) < 1 {
return false
}
for _, label := range labels {
if !validDNSLabel(label) {
return false
}
}
tld := labels[len(labels)-1]
for _, r := range tld {
if !unicode.IsLetter(r) {
return false
}
}
return true
}
func validDNSDomain(domain string) bool {
if domain == "" || len(domain) > 253 {
return false
}
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
labels := strings.Split(domain, ".")
if len(labels) < 2 {
return false
}
for _, label := range labels {
if !validDNSLabel(label) {
return false
}
}
// TLD should be alphabetic (allows multi-char like com, uk, info).
tld := labels[len(labels)-1]
for _, r := range tld {
if !unicode.IsLetter(r) {
return false
}
}
return true
}
func validDNSLabel(label string) bool {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
if r == '-' && i > 0 && i < len(label)-1 {
continue
}
return false
}
return true
}
// FormatIssues returns a short multi-line summary for logs/UI.
func FormatIssues(issues []EntryIssue) string {
if len(issues) == 0 {
return ""
}
parts := make([]string, 0, len(issues))
for _, issue := range issues {
parts = append(parts, fmt.Sprintf("%s: %s", issue.Entry, issue.Reason))
}
return strings.Join(parts, "\n")
}
+45
View File
@@ -0,0 +1,45 @@
package lists
import "testing"
func TestValidateEntries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
entries []string
wantN int
}{
{name: "empty list", entries: nil, wantN: 0},
{name: "valid user", entries: []string{"user@example.com"}, wantN: 0},
{name: "valid star domain", entries: []string{"*@example.com"}, wantN: 0},
{name: "valid suffix glob", entries: []string{"*@*.shop", "*@*.ac.in"}, wantN: 0},
{name: "valid plus addressing", entries: []string{"a.b+c@mail-host.co.uk"}, wantN: 0},
{name: "repair then valid", entries: []string{"*vsa.instalily.ai"}, wantN: 0},
{name: "missing at", entries: []string{"not-an-email"}, wantN: 1},
{name: "bare domain", entries: []string{"example.com"}, wantN: 1},
{name: "spaces", entries: []string{"user @example.com"}, wantN: 1},
{name: "bad domain", entries: []string{"user@-example.com"}, wantN: 1},
{name: "single label domain", entries: []string{"*@localhost"}, wantN: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ValidateEntries(tt.entries)
if len(got) != tt.wantN {
t.Fatalf("ValidateEntries() issues=%v want %d", got, tt.wantN)
}
})
}
}
func TestValidateEntryReasons(t *testing.T) {
t.Parallel()
if got := ValidateEntry(""); got == "" {
t.Fatal("expected empty reason")
}
if got := ValidateEntry("lisa@simpsons.com"); got != "" {
t.Fatalf("unexpected: %s", got)
}
}
+443
View File
@@ -0,0 +1,443 @@
package migadu
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
baseURL string
username string
apiKey string
httpClient *http.Client
}
func NewClient(baseURL, username, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// StringList normalizes Migadu fields that may arrive as a string, string list, or null.
type StringList []string
func (s *StringList) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
*s = []string{}
return nil
}
if data[0] == '"' {
var single string
if err := json.Unmarshal(data, &single); err != nil {
return err
}
single = strings.TrimSpace(single)
if single == "" {
*s = []string{}
return nil
}
parts := strings.FieldsFunc(single, func(r rune) bool {
return r == ',' || r == '\n' || r == ';'
})
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
*s = out
return nil
}
var list []string
if err := json.Unmarshal(data, &list); err != nil {
return err
}
out := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
*s = out
return nil
}
func (s StringList) MarshalJSON() ([]byte, error) {
if s == nil {
return json.Marshal([]string{})
}
return json.Marshal([]string(s))
}
type DomainSummary struct {
Name string `json:"name"`
State string `json:"state"`
Description string `json:"description"`
SpamAggressiveness any `json:"spam_aggressiveness"`
CanSend bool `json:"can_send"`
CanReceive bool `json:"can_receive"`
}
type Domain struct {
Name string `json:"name"`
State string `json:"state"`
Description string `json:"description"`
Tags StringList `json:"tags"`
ActivatedAt *string `json:"activated_at"`
DeactivatedAt *string `json:"deactivated_at"`
CanSend bool `json:"can_send"`
CanReceive bool `json:"can_receive"`
CanAccess bool `json:"can_access"`
MxProxyEnabled bool `json:"mx_proxy_enabled"`
SpamAggressiveness any `json:"spam_aggressiveness"`
SubjectRewritingEnabled bool `json:"subject_rewriting_enabled"`
JunkSubjectKeywordSpam bool `json:"junk_subject_keyword_spam"`
SenderDenylist StringList `json:"sender_denylist"`
SenderAllowlist StringList `json:"sender_allowlist"`
RecipientDenylist StringList `json:"recipient_denylist"`
CatchallDestinations StringList `json:"catchall_destinations"`
HostedDNS bool `json:"hosted_dns"`
}
type DomainUpdate struct {
SenderDenylist *[]string `json:"-"`
SenderAllowlist *[]string `json:"-"`
RecipientDenylist *[]string `json:"-"`
SpamAggressiveness *string `json:"spam_aggressiveness,omitempty"`
JunkSubjectKeywordSpam *bool `json:"junk_subject_keyword_spam,omitempty"`
SubjectRewritingEnabled *bool `json:"subject_rewriting_enabled,omitempty"`
}
// MarshalJSON encodes denylist/allowlist fields as comma-separated strings.
// Migadu's API documents these as String/List and rejects JSON arrays on PATCH.
func (u DomainUpdate) MarshalJSON() ([]byte, error) {
raw := map[string]any{}
if u.SenderDenylist != nil {
raw["sender_denylist"] = joinListField(*u.SenderDenylist)
}
if u.SenderAllowlist != nil {
raw["sender_allowlist"] = joinListField(*u.SenderAllowlist)
}
if u.RecipientDenylist != nil {
raw["recipient_denylist"] = joinListField(*u.RecipientDenylist)
}
if u.SpamAggressiveness != nil {
raw["spam_aggressiveness"] = *u.SpamAggressiveness
}
if u.JunkSubjectKeywordSpam != nil {
raw["junk_subject_keyword_spam"] = *u.JunkSubjectKeywordSpam
}
if u.SubjectRewritingEnabled != nil {
raw["subject_rewriting_enabled"] = *u.SubjectRewritingEnabled
}
return json.Marshal(raw)
}
func joinListField(values []string) string {
if len(values) == 0 {
return ""
}
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return strings.Join(parts, ",")
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
if e.Body == "" {
return fmt.Sprintf("migadu api error: HTTP %d", e.StatusCode)
}
return fmt.Sprintf("migadu api error: HTTP %d: %s", e.StatusCode, e.Body)
}
func (c *Client) ListDomains(ctx context.Context) ([]DomainSummary, error) {
raw, err := c.doRaw(ctx, http.MethodGet, "/domains", nil, false)
if err != nil {
return nil, err
}
var wrapped struct {
Domains []DomainSummary `json:"domains"`
}
if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Domains != nil {
return wrapped.Domains, nil
}
var list []DomainSummary
if err := json.Unmarshal(raw, &list); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return list, nil
}
func (c *Client) GetDomain(ctx context.Context, name string) (*Domain, error) {
var domain Domain
path := "/domains/" + url.PathEscape(name)
if err := c.do(ctx, http.MethodGet, path, nil, &domain, false); err != nil {
return nil, err
}
return &domain, nil
}
func (c *Client) UpdateDomain(ctx context.Context, name string, update DomainUpdate) (*Domain, error) {
return c.updateDomain(ctx, name, update, false)
}
func (c *Client) updateDomain(ctx context.Context, name string, update DomainUpdate, quiet bool) (*Domain, error) {
update.normalizeListPointers()
var domain Domain
path := "/domains/" + url.PathEscape(name)
if err := c.do(ctx, http.MethodPatch, path, update, &domain, quiet); err != nil {
return nil, err
}
return &domain, nil
}
// ListWriteResult is the outcome of writing denylist/allowlist fields with
// rejection bisect when Migadu returns HTTP 400.
type ListWriteResult struct {
SenderDenylist []string
SenderAllowlist []string
RecipientDenylist []string
Rejected []string
}
// UpdateDomainLists writes list fields one at a time. On HTTP 400 it bisects to
// find entries Migadu rejects, skips them, and applies the rest.
func (c *Client) UpdateDomainLists(ctx context.Context, name string, denylist, allowlist, recipient []string) (*ListWriteResult, error) {
result := &ListWriteResult{}
acceptedDeny, rejectedDeny, err := c.writeListField(ctx, name, "sender_denylist", denylist)
if err != nil {
return nil, err
}
acceptedAllow, rejectedAllow, err := c.writeListField(ctx, name, "sender_allowlist", allowlist)
if err != nil {
return nil, err
}
acceptedRecipient, rejectedRecipient, err := c.writeListField(ctx, name, "recipient_denylist", recipient)
if err != nil {
return nil, err
}
result.SenderDenylist = acceptedDeny
result.SenderAllowlist = acceptedAllow
result.RecipientDenylist = acceptedRecipient
result.Rejected = append(result.Rejected, rejectedDeny...)
result.Rejected = append(result.Rejected, rejectedAllow...)
result.Rejected = append(result.Rejected, rejectedRecipient...)
if len(result.Rejected) > 0 {
log.Printf(
"migadu: %s list write skipped %d rejected entr(y/ies): %s",
name,
len(result.Rejected),
strings.Join(result.Rejected, ", "),
)
}
return result, nil
}
func (c *Client) writeListField(ctx context.Context, domainName, field string, entries []string) (accepted, rejected []string, err error) {
entries = append([]string{}, entries...)
_, err = c.updateDomain(ctx, domainName, domainUpdateForField(field, entries), false)
if err == nil {
return entries, nil, nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest {
return nil, nil, err
}
rejected = c.findRejectedEntries(ctx, domainName, field, entries)
rejectedSet := make(map[string]struct{}, len(rejected))
for _, entry := range rejected {
rejectedSet[entry] = struct{}{}
}
accepted = make([]string, 0, len(entries))
for _, entry := range entries {
if _, bad := rejectedSet[entry]; !bad {
accepted = append(accepted, entry)
}
}
if _, err = c.updateDomain(ctx, domainName, domainUpdateForField(field, accepted), false); err != nil {
return nil, rejected, err
}
return accepted, rejected, nil
}
func domainUpdateForField(field string, entries []string) DomainUpdate {
list := append([]string{}, entries...)
switch field {
case "sender_denylist":
return DomainUpdate{SenderDenylist: &list}
case "sender_allowlist":
return DomainUpdate{SenderAllowlist: &list}
case "recipient_denylist":
return DomainUpdate{RecipientDenylist: &list}
default:
return DomainUpdate{}
}
}
func (c *Client) findRejectedEntries(ctx context.Context, domainName, field string, entries []string) []string {
if len(entries) == 0 {
return nil
}
_, err := c.updateDomain(ctx, domainName, domainUpdateForField(field, entries), true)
if err == nil {
return nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest {
return append([]string{}, entries...)
}
if len(entries) == 1 {
log.Printf("migadu: %s rejected %s entry %q", domainName, field, entries[0])
return []string{entries[0]}
}
mid := len(entries) / 2
left := c.findRejectedEntries(ctx, domainName, field, entries[:mid])
right := c.findRejectedEntries(ctx, domainName, field, entries[mid:])
return append(left, right...)
}
// normalizeListPointers ensures list fields are non-nil when present so they
// encode as empty strings rather than null.
func (u *DomainUpdate) normalizeListPointers() {
u.SenderDenylist = nonNilStringListPtr(u.SenderDenylist)
u.SenderAllowlist = nonNilStringListPtr(u.SenderAllowlist)
u.RecipientDenylist = nonNilStringListPtr(u.RecipientDenylist)
}
func nonNilStringListPtr(list *[]string) *[]string {
if list == nil {
return nil
}
if *list == nil {
empty := []string{}
return &empty
}
return list
}
func (c *Client) do(ctx context.Context, method, path string, body any, out any, quiet bool) error {
raw, err := c.doRaw(ctx, method, path, body, quiet)
if err != nil {
return err
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
func (c *Client) doRaw(ctx context.Context, method, path string, body any, quiet bool) ([]byte, error) {
var reader io.Reader
var encoded []byte
if body != nil {
var err error
encoded, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
reader = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.SetBasicAuth(c.username, c.apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
log.Printf("migadu: %s %s failed for user=%q: %v", method, path, c.username, err)
return nil, fmt.Errorf("migadu request failed: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
responseBody := strings.TrimSpace(string(raw))
if !quiet {
log.Printf(
"migadu: %s %s → HTTP %d user=%q api_key=%s request_bytes=%d request=%s body=%q",
method,
c.baseURL+path,
resp.StatusCode,
c.username,
maskSecret(c.apiKey),
len(encoded),
stringOrEmpty(encoded),
truncate(responseBody, 500),
)
}
return nil, &APIError{StatusCode: resp.StatusCode, Body: responseBody}
}
if !quiet {
log.Printf("migadu: %s %s → HTTP %d ok", method, path, resp.StatusCode)
}
return raw, nil
}
func stringOrEmpty(value []byte) string {
if len(value) == 0 {
return "(none)"
}
return string(value)
}
func maskSecret(secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" {
return "(empty)"
}
if len(secret) <= 8 {
return "****"
}
return secret[:4] + "…" + secret[len(secret)-4:] + fmt.Sprintf(" (len=%d)", len(secret))
}
func truncate(value string, max int) string {
if len(value) <= max {
return value
}
return value[:max] + "…"
}
+64
View File
@@ -0,0 +1,64 @@
package migadu
import (
"encoding/json"
"testing"
)
func TestStringListUnmarshal(t *testing.T) {
cases := []struct {
name string
raw string
want []string
}{
{name: "null", raw: `null`, want: []string{}},
{name: "empty string", raw: `""`, want: []string{}},
{name: "csv string", raw: `"a@x.com, b@y.com"`, want: []string{"a@x.com", "b@y.com"}},
{name: "array", raw: `["a@x.com","b@y.com"]`, want: []string{"a@x.com", "b@y.com"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got StringList
if err := json.Unmarshal([]byte(tc.raw), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len=%d want %d (%v)", len(got), len(tc.want), got)
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Fatalf("got %v want %v", got, tc.want)
}
}
})
}
}
func TestDomainUpdateNilListMarshalsAsEmptyArray(t *testing.T) {
var nilList []string
update := DomainUpdate{
SenderDenylist: &nilList,
SenderAllowlist: &[]string{"a@b.com", "c@d.com"},
RecipientDenylist: &[]string{},
}
update.normalizeListPointers()
raw, err := json.Marshal(update)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode: %v", err)
}
if string(decoded["sender_denylist"]) != `""` {
t.Fatalf("sender_denylist=%s want empty string", decoded["sender_denylist"])
}
if string(decoded["sender_allowlist"]) != `"a@b.com,c@d.com"` {
t.Fatalf("sender_allowlist=%s", decoded["sender_allowlist"])
}
if string(decoded["recipient_denylist"]) != `""` {
t.Fatalf("recipient_denylist=%s want empty string", decoded["recipient_denylist"])
}
}
+469
View File
@@ -0,0 +1,469 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/squid/MigaduAdmin/internal/lists"
_ "modernc.org/sqlite"
)
var (
ErrNotFound = errors.New("not found")
ErrEmailTaken = errors.New("email already registered")
ErrInvalidInput = errors.New("invalid input")
ErrDomainExists = errors.New("domain already managed")
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role Role `json:"role"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Session struct {
ID string
UserID int64
ExpiresAt time.Time
CreatedAt time.Time
}
type ManagedDomain struct {
Name string `json:"name"`
AddedAt time.Time `json:"added_at"`
AddedBy *int64 `json:"added_by,omitempty"`
}
// SharedLists holds denylist/allowlist entries applied across managed domains.
type SharedLists struct {
SenderDenylist []string `json:"sender_denylist"`
SenderAllowlist []string `json:"sender_allowlist"`
RecipientDenylist []string `json:"recipient_denylist"`
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
store := &Store{db: db}
if err := store.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
const schema = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL,
password_hash TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS managed_domains (
name TEXT PRIMARY KEY COLLATE NOCASE,
added_at TEXT NOT NULL,
added_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS shared_lists (
id INTEGER PRIMARY KEY CHECK (id = 1),
sender_denylist TEXT NOT NULL DEFAULT '[]',
sender_allowlist TEXT NOT NULL DEFAULT '[]',
recipient_denylist TEXT NOT NULL DEFAULT '[]'
);
INSERT OR IGNORE INTO shared_lists (id, sender_denylist, sender_allowlist, recipient_denylist)
VALUES (1, '[]', '[]', '[]');
`
_, err := s.db.Exec(schema)
return err
}
func (s *Store) CountUsers(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
func (s *Store) CreateUser(ctx context.Context, email, displayName, passwordHash string, role Role) (*User, error) {
email = normalizeEmail(email)
displayName = strings.TrimSpace(displayName)
if email == "" {
return nil, ErrInvalidInput
}
if displayName == "" {
displayName = email
}
if role != RoleAdmin && role != RoleUser {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
result, err := s.db.ExecContext(ctx, `
INSERT INTO users (email, display_name, role, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)`,
email, displayName, string(role), passwordHash, now.Format(time.RFC3339Nano),
)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return nil, ErrEmailTaken
}
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &User{
ID: id,
Email: email,
DisplayName: displayName,
Role: role,
PasswordHash: passwordHash,
CreatedAt: now,
}, nil
}
func (s *Store) GetUserByID(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users WHERE id = ?`, id)
return scanUser(row)
}
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users WHERE email = ? COLLATE NOCASE`, normalizeEmail(email))
return scanUser(row)
}
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, *user)
}
return users, rows.Err()
}
func (s *Store) CreateSession(ctx context.Context, id string, userID int64, expiresAt time.Time) error {
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (id, user_id, expires_at, created_at)
VALUES (?, ?, ?, ?)`,
id, userID, expiresAt.UTC().Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
)
return err
}
func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?`, id)
var session Session
var expiresAt, createdAt string
if err := row.Scan(&session.ID, &session.UserID, &expiresAt, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
var err error
session.ExpiresAt, err = time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return nil, err
}
session.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, err
}
return &session, nil
}
func (s *Store) DeleteSession(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, id)
return err
}
func (s *Store) DeleteExpiredSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339Nano))
return err
}
func normalizeDomainName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func (s *Store) AddManagedDomain(ctx context.Context, name string, addedBy int64) (*ManagedDomain, error) {
name = normalizeDomainName(name)
if name == "" || !strings.Contains(name, ".") {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `
INSERT INTO managed_domains (name, added_at, added_by)
VALUES (?, ?, ?)`, name, now.Format(time.RFC3339Nano), addedBy)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return nil, ErrDomainExists
}
return nil, err
}
return &ManagedDomain{
Name: name,
AddedAt: now,
AddedBy: &addedBy,
}, nil
}
func (s *Store) ListManagedDomains(ctx context.Context) ([]ManagedDomain, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT name, added_at, added_by FROM managed_domains ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
domains := make([]ManagedDomain, 0)
for rows.Next() {
var domain ManagedDomain
var addedAt string
var addedBy sql.NullInt64
if err := rows.Scan(&domain.Name, &addedAt, &addedBy); err != nil {
return nil, err
}
parsed, err := time.Parse(time.RFC3339Nano, addedAt)
if err != nil {
return nil, err
}
domain.AddedAt = parsed
if addedBy.Valid {
value := addedBy.Int64
domain.AddedBy = &value
}
domains = append(domains, domain)
}
return domains, rows.Err()
}
func (s *Store) GetManagedDomain(ctx context.Context, name string) (*ManagedDomain, error) {
row := s.db.QueryRowContext(ctx, `
SELECT name, added_at, added_by FROM managed_domains WHERE name = ? COLLATE NOCASE`,
normalizeDomainName(name))
var domain ManagedDomain
var addedAt string
var addedBy sql.NullInt64
if err := row.Scan(&domain.Name, &addedAt, &addedBy); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
parsed, err := time.Parse(time.RFC3339Nano, addedAt)
if err != nil {
return nil, err
}
domain.AddedAt = parsed
if addedBy.Valid {
value := addedBy.Int64
domain.AddedBy = &value
}
return &domain, nil
}
func (s *Store) IsManagedDomain(ctx context.Context, name string) (bool, error) {
_, err := s.GetManagedDomain(ctx, name)
if err == nil {
return true, nil
}
if errors.Is(err, ErrNotFound) {
return false, nil
}
return false, err
}
func (s *Store) DeleteManagedDomain(ctx context.Context, name string) error {
result, err := s.db.ExecContext(ctx, `
DELETE FROM managed_domains WHERE name = ? COLLATE NOCASE`, normalizeDomainName(name))
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) GetSharedLists(ctx context.Context) (*SharedLists, error) {
row := s.db.QueryRowContext(ctx, `
SELECT sender_denylist, sender_allowlist, recipient_denylist
FROM shared_lists WHERE id = 1`)
var denylistJSON, allowlistJSON, recipientJSON string
if err := row.Scan(&denylistJSON, &allowlistJSON, &recipientJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &SharedLists{
SenderDenylist: []string{},
SenderAllowlist: []string{},
RecipientDenylist: []string{},
}, nil
}
return nil, err
}
lists := &SharedLists{}
if err := unmarshalStringList(denylistJSON, &lists.SenderDenylist); err != nil {
return nil, err
}
if err := unmarshalStringList(allowlistJSON, &lists.SenderAllowlist); err != nil {
return nil, err
}
if err := unmarshalStringList(recipientJSON, &lists.RecipientDenylist); err != nil {
return nil, err
}
return lists, nil
}
func (s *Store) PutSharedLists(ctx context.Context, lists SharedLists) error {
denylistJSON, err := marshalStringList(lists.SenderDenylist)
if err != nil {
return err
}
allowlistJSON, err := marshalStringList(lists.SenderAllowlist)
if err != nil {
return err
}
recipientJSON, err := marshalStringList(lists.RecipientDenylist)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO shared_lists (id, sender_denylist, sender_allowlist, recipient_denylist)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
sender_denylist = excluded.sender_denylist,
sender_allowlist = excluded.sender_allowlist,
recipient_denylist = excluded.recipient_denylist`,
denylistJSON, allowlistJSON, recipientJSON,
)
return err
}
func marshalStringList(values []string) (string, error) {
normalized := normalizeStringList(values)
data, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(data), nil
}
func unmarshalStringList(raw string, dest *[]string) error {
if strings.TrimSpace(raw) == "" {
*dest = []string{}
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return err
}
*dest = normalizeStringList(values)
return nil
}
func normalizeStringList(values []string) []string {
return lists.Compact(values)
}
type scannable interface {
Scan(dest ...any) error
}
func scanUser(row scannable) (*User, error) {
var user User
var role string
var createdAt string
if err := row.Scan(&user.ID, &user.Email, &user.DisplayName, &role, &user.PasswordHash, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
user.Role = Role(role)
parsed, err := time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, err
}
user.CreatedAt = parsed
return &user, nil
}
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}