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).
137 lines
3.6 KiB
Go
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
|
|
}
|