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