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).
193 lines
4.1 KiB
Go
193 lines
4.1 KiB
Go
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")
|
|
}
|