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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user