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:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user