Add configurable external rules pipeline for ingress #19

Merged
thuanle merged 8 commits from feat/external-rules-pipeline into main 2026-04-28 07:14:45 +07:00
5 changed files with 249 additions and 0 deletions
Showing only changes of commit 8006e6d64c - Show all commits
+30
View File
@@ -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
}
+106
View File
@@ -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() }
+41
View File
@@ -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
}
+42
View File
@@ -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")
}
}
+30
View File
@@ -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")
}