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