From 4d6ac3e7c642c2b4369445a55344674b55b9dcb4 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 21 Jun 2026 23:47:39 +0100 Subject: [PATCH] feat(policy): case-insensitive address and domain matching --- internal/policy/address_test.go | 30 ++++++++++++++++++++++ internal/policy/policy.go | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 internal/policy/address_test.go create mode 100644 internal/policy/policy.go diff --git a/internal/policy/address_test.go b/internal/policy/address_test.go new file mode 100644 index 0000000..9861c57 --- /dev/null +++ b/internal/policy/address_test.go @@ -0,0 +1,30 @@ +package policy + +import "testing" + +func TestMatchAddress(t *testing.T) { + wl := []string{"bob@example.com", "@trusted.com"} + cases := []struct { + addr string + want bool + }{ + {"bob@example.com", true}, + {"BOB@Example.com", true}, + {`"Bob" `, true}, + {"alice@trusted.com", true}, + {"alice@untrusted.com", false}, + {"eve@example.com", false}, + {"", false}, + } + for _, c := range cases { + if got := MatchAddress(wl, c.addr); got != c.want { + t.Fatalf("MatchAddress(%q)=%v want %v", c.addr, got, c.want) + } + } +} + +func TestNormalizeAddr(t *testing.T) { + if got := NormalizeAddr(`"Bob Smith" `); got != "bob@example.com" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..7c0e648 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,44 @@ +// Package policy holds pure enforcement functions: address matching, +// inbound filtering, and (in a later phase) outbound checks. +package policy + +import ( + "net/mail" + "strings" +) + +// NormalizeAddr lower-cases an address and strips any display name/brackets. +func NormalizeAddr(addr string) string { + addr = strings.TrimSpace(addr) + if a, err := mail.ParseAddress(addr); err == nil { + return strings.ToLower(a.Address) + } + return strings.ToLower(strings.Trim(addr, "<> ")) +} + +// MatchAddress reports whether addr matches any whitelist entry. +// Entries beginning with '@' match a whole domain; others match exactly. +func MatchAddress(entries []string, addr string) bool { + norm := NormalizeAddr(addr) + if norm == "" { + return false + } + at := strings.LastIndex(norm, "@") + domain := "" + if at >= 0 { + domain = norm[at:] // includes '@' + } + for _, e := range entries { + e = strings.ToLower(strings.TrimSpace(e)) + if strings.HasPrefix(e, "@") { + if e == domain { + return true + } + continue + } + if e == norm { + return true + } + } + return false +}