From 8006e6d64c9eef3b3c3c8b68c8165428aa410915 Mon Sep 17 00:00:00 2001 From: thuanle Date: Tue, 28 Apr 2026 06:26:01 +0700 Subject: [PATCH] feat(rules): add Rule interface, Pipeline, and WhitelistRule Co-Authored-By: Claude Opus 4.7 --- internal/rules/pipeline.go | 30 +++++++++ internal/rules/pipeline_test.go | 106 ++++++++++++++++++++++++++++++++ internal/rules/types.go | 41 ++++++++++++ internal/rules/types_test.go | 42 +++++++++++++ internal/rules/whitelist.go | 30 +++++++++ 5 files changed, 249 insertions(+) create mode 100644 internal/rules/pipeline.go create mode 100644 internal/rules/pipeline_test.go create mode 100644 internal/rules/types.go create mode 100644 internal/rules/types_test.go create mode 100644 internal/rules/whitelist.go diff --git a/internal/rules/pipeline.go b/internal/rules/pipeline.go new file mode 100644 index 0000000..63f89bd --- /dev/null +++ b/internal/rules/pipeline.go @@ -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 +} \ No newline at end of file diff --git a/internal/rules/pipeline_test.go b/internal/rules/pipeline_test.go new file mode 100644 index 0000000..ac37a5f --- /dev/null +++ b/internal/rules/pipeline_test.go @@ -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() } \ No newline at end of file diff --git a/internal/rules/types.go b/internal/rules/types.go new file mode 100644 index 0000000..8c21fe8 --- /dev/null +++ b/internal/rules/types.go @@ -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 +} \ No newline at end of file diff --git a/internal/rules/types_test.go b/internal/rules/types_test.go new file mode 100644 index 0000000..2bc2be4 --- /dev/null +++ b/internal/rules/types_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/rules/whitelist.go b/internal/rules/whitelist.go new file mode 100644 index 0000000..0eaca8a --- /dev/null +++ b/internal/rules/whitelist.go @@ -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") +} \ No newline at end of file