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