feat(rules): add Rule interface, Pipeline, and WhitelistRule
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
// Pipeline evaluates a sequence of rules, stopping on first rejection.
|
||||||
|
type Pipeline struct {
|
||||||
|
rules []Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPipeline creates a rules pipeline from the given rules.
|
||||||
|
func NewPipeline(rules []Rule) *Pipeline {
|
||||||
|
return &Pipeline{rules: rules}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate runs all rules in order. Returns the first rejection or a merged accept.
|
||||||
|
func (p *Pipeline) Evaluate(ctx EmailContext) RuleResult {
|
||||||
|
merged := Accept()
|
||||||
|
for _, r := range p.rules {
|
||||||
|
result := r.Evaluate(ctx)
|
||||||
|
if !result.Accepted {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for k, v := range result.Metadata {
|
||||||
|
if merged.Metadata == nil {
|
||||||
|
merged.Metadata = map[string]string{k: v}
|
||||||
|
} else {
|
||||||
|
merged.Metadata[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPipeline_NoRules_Accepts(t *testing.T) {
|
||||||
|
p := NewPipeline(nil)
|
||||||
|
result := p.Evaluate(EmailContext{Sender: "anyone@test.com"})
|
||||||
|
if !result.Accepted {
|
||||||
|
t.Fatal("expected accept with no rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPipeline_AllRulesAccept_Accepts(t *testing.T) {
|
||||||
|
allowAll := &stubRule{name: "allow-all", result: Accept()}
|
||||||
|
p := NewPipeline([]Rule{allowAll})
|
||||||
|
result := p.Evaluate(EmailContext{Sender: "a@b.com"})
|
||||||
|
if !result.Accepted {
|
||||||
|
t.Fatal("expected accept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPipeline_OneRuleRejects_Rejects(t *testing.T) {
|
||||||
|
rejector := &stubRule{name: "rejector", result: Reject("nope")}
|
||||||
|
p := NewPipeline([]Rule{rejector})
|
||||||
|
result := p.Evaluate(EmailContext{Sender: "a@b.com"})
|
||||||
|
if result.Accepted {
|
||||||
|
t.Fatal("expected reject")
|
||||||
|
}
|
||||||
|
if result.Reason != "nope" {
|
||||||
|
t.Fatalf("expected reason 'nope', got %s", result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPipeline_MergesMetadata(t *testing.T) {
|
||||||
|
r1 := &stubRule{name: "r1", result: Accept().WithMetadata("a", "1")}
|
||||||
|
r2 := &stubRule{name: "r2", result: Accept().WithMetadata("b", "2")}
|
||||||
|
p := NewPipeline([]Rule{r1, r2})
|
||||||
|
result := p.Evaluate(EmailContext{})
|
||||||
|
if result.Metadata["a"] != "1" {
|
||||||
|
t.Fatal("missing metadata a")
|
||||||
|
}
|
||||||
|
if result.Metadata["b"] != "2" {
|
||||||
|
t.Fatal("missing metadata b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPipeline_StopsOnFirstReject(t *testing.T) {
|
||||||
|
rejector := &stubRule{name: "rejector", result: Reject("stop")}
|
||||||
|
neverCalled := &trackingRule{}
|
||||||
|
p := NewPipeline([]Rule{rejector, neverCalled})
|
||||||
|
p.Evaluate(EmailContext{})
|
||||||
|
if neverCalled.called {
|
||||||
|
t.Fatal("expected second rule not to be called after rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhitelistRule_EmptyList_Accepts(t *testing.T) {
|
||||||
|
r := NewWhitelistRule(nil)
|
||||||
|
result := r.Evaluate(EmailContext{Sender: "anyone@test.com"})
|
||||||
|
if !result.Accepted {
|
||||||
|
t.Fatal("empty whitelist should accept all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhitelistRule_SenderInList_Accepts(t *testing.T) {
|
||||||
|
r := NewWhitelistRule([]string{"allowed@test.com"})
|
||||||
|
result := r.Evaluate(EmailContext{Sender: "allowed@test.com"})
|
||||||
|
if !result.Accepted {
|
||||||
|
t.Fatal("expected accept for whitelisted sender")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhitelistRule_SenderNotInList_Rejects(t *testing.T) {
|
||||||
|
r := NewWhitelistRule([]string{"allowed@test.com"})
|
||||||
|
result := r.Evaluate(EmailContext{Sender: "unknown@test.com"})
|
||||||
|
if result.Accepted {
|
||||||
|
t.Fatal("expected reject for non-whitelisted sender")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhitelistRule_CaseInsensitive(t *testing.T) {
|
||||||
|
r := NewWhitelistRule([]string{"Allowed@Test.com"})
|
||||||
|
result := r.Evaluate(EmailContext{Sender: "allowed@test.com"})
|
||||||
|
if !result.Accepted {
|
||||||
|
t.Fatal("whitelist should be case-insensitive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubs
|
||||||
|
|
||||||
|
type stubRule struct {
|
||||||
|
name string
|
||||||
|
result RuleResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRule) Name() string { return s.name }
|
||||||
|
func (s *stubRule) Evaluate(_ EmailContext) RuleResult { return s.result }
|
||||||
|
|
||||||
|
type trackingRule struct {
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingRule) Name() string { return "tracker" }
|
||||||
|
func (t *trackingRule) Evaluate(_ EmailContext) RuleResult { t.called = true; return Accept() }
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
// EmailContext contains the data available to rules for evaluation.
|
||||||
|
type EmailContext struct {
|
||||||
|
Sender string
|
||||||
|
Subject string
|
||||||
|
MessageID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleResult is the output of evaluating a single rule.
|
||||||
|
type RuleResult struct {
|
||||||
|
Accepted bool
|
||||||
|
Reason string
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept returns a passing RuleResult.
|
||||||
|
func Accept() RuleResult {
|
||||||
|
return RuleResult{Accepted: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject returns a failing RuleResult with a reason.
|
||||||
|
func Reject(reason string) RuleResult {
|
||||||
|
return RuleResult{Accepted: false, Reason: reason}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMetadata attaches a key-value pair to the result.
|
||||||
|
func (r RuleResult) WithMetadata(key, value string) RuleResult {
|
||||||
|
if r.Metadata == nil {
|
||||||
|
r.Metadata = map[string]string{key: value}
|
||||||
|
} else {
|
||||||
|
r.Metadata[key] = value
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule evaluates an email against operator-configurable policy.
|
||||||
|
type Rule interface {
|
||||||
|
Name() string
|
||||||
|
Evaluate(ctx EmailContext) RuleResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuleResult_Accept(t *testing.T) {
|
||||||
|
r := Accept()
|
||||||
|
if !r.Accepted {
|
||||||
|
t.Fatal("expected Accepted=true")
|
||||||
|
}
|
||||||
|
if r.Reason != "" {
|
||||||
|
t.Fatal("expected no reason for accept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleResult_Reject(t *testing.T) {
|
||||||
|
r := Reject("blocked sender")
|
||||||
|
if r.Accepted {
|
||||||
|
t.Fatal("expected Accepted=false")
|
||||||
|
}
|
||||||
|
if r.Reason != "blocked sender" {
|
||||||
|
t.Fatalf("expected reason 'blocked sender', got %s", r.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleResult_WithMetadata(t *testing.T) {
|
||||||
|
r := Accept().WithMetadata("routing", "high-priority")
|
||||||
|
if r.Metadata["routing"] != "high-priority" {
|
||||||
|
t.Fatalf("expected routing=high-priority, got %s", r.Metadata["routing"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailContext_Fields(t *testing.T) {
|
||||||
|
ctx := EmailContext{
|
||||||
|
Sender: "user@example.com",
|
||||||
|
Subject: "Test",
|
||||||
|
}
|
||||||
|
if ctx.Sender != "user@example.com" {
|
||||||
|
t.Fatal("sender mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// WhitelistRule accepts emails from configured senders.
|
||||||
|
// An empty list accepts all senders.
|
||||||
|
type WhitelistRule struct {
|
||||||
|
emails map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWhitelistRule creates a whitelist rule from a list of email addresses.
|
||||||
|
func NewWhitelistRule(emails []string) *WhitelistRule {
|
||||||
|
m := make(map[string]bool, len(emails))
|
||||||
|
for _, e := range emails {
|
||||||
|
m[strings.ToLower(e)] = true
|
||||||
|
}
|
||||||
|
return &WhitelistRule{emails: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WhitelistRule) Name() string { return "whitelist" }
|
||||||
|
|
||||||
|
func (r *WhitelistRule) Evaluate(ctx EmailContext) RuleResult {
|
||||||
|
if len(r.emails) == 0 {
|
||||||
|
return Accept()
|
||||||
|
}
|
||||||
|
if r.emails[strings.ToLower(ctx.Sender)] {
|
||||||
|
return Accept()
|
||||||
|
}
|
||||||
|
return Reject("not whitelisted")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user