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