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