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
+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"])
}
}