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