Files
Squid a79cb61104 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).
2026-07-26 21:50:26 +02:00

137 lines
3.6 KiB
Go

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
}