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:
@@ -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] + "…"
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user