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).
444 lines
12 KiB
Go
444 lines
12 KiB
Go
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] + "…"
|
|
}
|