Add intake worker
Subsystem to monitor IMAP mailbox for new messages. Introduces: - intake: worker that uses IDLE or polling to detect new emails. - imap: client wrapper for connection management and IMAP commands. - filter: logic for IMAP search and sender allow-list. - tracker: concurrency control to prevent processing the same UID twice. - backoff: for handling connection retries with jitter.
This commit is contained in:
52
internal/filter/filter.go
Normal file
52
internal/filter/filter.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package filter provides message filtering configuration.
|
||||
// It is used to restrict which messages are processed by the answer workers.
|
||||
//
|
||||
// NOTE: IMAP Search Limitations
|
||||
// IMAP SEARCH (RFC 3501 §6.4.4) uses case-insensitive substring matching for
|
||||
// text fields.
|
||||
// A filter like From: "alice@example.com" would match:
|
||||
// - alice@example.com
|
||||
// - malice@example.com
|
||||
// - alice@example.com.evil.org
|
||||
//
|
||||
// For this reason, server-side IMAP filters should be considered
|
||||
// performance optimizations only, not security controls. Use the
|
||||
// [Filters.Senders] allowlist for exact sender matching.
|
||||
//
|
||||
// [RFC 3501 §6.4.4]:
|
||||
// https://datatracker.ietf.org/doc/html/rfc3501#section-6.4.4
|
||||
package filter
|
||||
|
||||
import "strings"
|
||||
|
||||
// Filters holds message filtering configuration for IMAP searches
|
||||
// and sender verification.
|
||||
type Filters struct {
|
||||
// Body contains keywords that must appear in the message body.
|
||||
Body []string `yaml:"body"`
|
||||
// From filters messages by the From header field.
|
||||
From string `yaml:"from"`
|
||||
// Senders is an allowlist of email addresses permitted to receive
|
||||
// replies. If empty, all senders are allowed.
|
||||
Senders []string `yaml:"allowed_senders"`
|
||||
// Subject filters messages by the Subject header field.
|
||||
Subject string `yaml:"subject"`
|
||||
// To filters messages by the To header field.
|
||||
To string `yaml:"to"`
|
||||
}
|
||||
|
||||
// MatchSender checks if the sender is in the allowed list.
|
||||
// Returns true if the allowlist is empty (allow all) or if the sender matches.
|
||||
// Comparison is case-insensitive and ignores leading/trailing whitespace.
|
||||
func (f *Filters) MatchSender(sender string) bool {
|
||||
if len(f.Senders) == 0 {
|
||||
return true
|
||||
}
|
||||
sender = strings.ToLower(strings.TrimSpace(sender))
|
||||
for _, allowed := range f.Senders {
|
||||
if strings.ToLower(strings.TrimSpace(allowed)) == sender {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
91
internal/filter/filter_test.go
Normal file
91
internal/filter/filter_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package filter
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchSender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
senders []string
|
||||
sender string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty allowlist allows all",
|
||||
senders: nil,
|
||||
sender: "anyone@example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match",
|
||||
senders: []string{"allowed@example.com"},
|
||||
sender: "allowed@example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
senders: []string{"Allowed@Example.COM"},
|
||||
sender: "allowed@example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "sender case insensitive",
|
||||
senders: []string{"allowed@example.com"},
|
||||
sender: "ALLOWED@EXAMPLE.COM",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed from sender",
|
||||
senders: []string{"allowed@example.com"},
|
||||
sender: " allowed@example.com ",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed from allowlist",
|
||||
senders: []string{" allowed@example.com "},
|
||||
sender: "allowed@example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not in allowlist",
|
||||
senders: []string{"allowed@example.com"},
|
||||
sender: "blocked@example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "multiple allowed senders",
|
||||
senders: []string{"one@example.com", "two@example.com", "three@example.com"},
|
||||
sender: "two@example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "partial match not allowed",
|
||||
senders: []string{"allowed@example.com"},
|
||||
sender: "allowed@example",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty sender with allowlist",
|
||||
senders: []string{"allowed@example.com"},
|
||||
sender: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty sender with empty allowlist",
|
||||
senders: nil,
|
||||
sender: "",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &Filters{Senders: tt.senders}
|
||||
if got := f.MatchSender(tt.sender); got != tt.want {
|
||||
t.Errorf(
|
||||
"MatchSender(%q) = %v, want %v",
|
||||
tt.sender, got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user