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:
94
internal/backoff/backoff.go
Normal file
94
internal/backoff/backoff.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Package backoff implements exponential backoff with jitter for retry logic.
|
||||
package backoff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the configuration for a Backoff instance.
|
||||
type Config struct {
|
||||
// Initial is the starting upper bound for the backoff duration.
|
||||
// Must be > 0.
|
||||
Initial time.Duration `yaml:"initial"`
|
||||
// Max is the absolute maximum upper bound for the backoff duration.
|
||||
// Must be >= Initial.
|
||||
Max time.Duration `yaml:"max"`
|
||||
// RNG is an optional source of randomness.
|
||||
// If nil, the global math/rand/v2 source is used.
|
||||
RNG *rand.Rand `yaml:"-"`
|
||||
}
|
||||
|
||||
// Validate checks that the configuration values are sensible.
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.Initial <= 0 {
|
||||
return fmt.Errorf("invalid initial backoff: %v", cfg.Initial)
|
||||
}
|
||||
if cfg.Max < cfg.Initial {
|
||||
return fmt.Errorf("invalid max duration: %v", cfg.Max)
|
||||
}
|
||||
// MaxInt64 will overflow when calculating jitter.
|
||||
if cfg.Max == math.MaxInt64 {
|
||||
return fmt.Errorf("max duration cannot be MaxInt64")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backoff implements exponential backoff with full jitter.
|
||||
// It is NOT safe for concurrent use.
|
||||
type Backoff struct {
|
||||
initial time.Duration
|
||||
max time.Duration
|
||||
current time.Duration
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// New creates a new Backoff instance with the provided configuration.
|
||||
func New(cfg Config) (*Backoff, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %v", err)
|
||||
}
|
||||
|
||||
return &Backoff{
|
||||
initial: cfg.Initial,
|
||||
max: cfg.Max,
|
||||
current: cfg.Initial,
|
||||
rng: cfg.RNG,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Next returns the next backoff delay.
|
||||
func (b *Backoff) Next() time.Duration {
|
||||
limit := b.current
|
||||
|
||||
// Update state for the next call.
|
||||
if b.current >= b.max {
|
||||
b.current = b.max
|
||||
} else if b.current > b.max/2 {
|
||||
// If doubling would exceed max, just clamp to max.
|
||||
b.current = b.max
|
||||
} else {
|
||||
b.current *= 2
|
||||
}
|
||||
|
||||
// Calculate jitter; return random in [0, limit].
|
||||
// Int64N(n) returns values in [0, n).
|
||||
// For [0, limit], we use limit + 1.
|
||||
var jitter int64
|
||||
if b.rng != nil {
|
||||
jitter = b.rng.Int64N(int64(limit) + 1)
|
||||
} else {
|
||||
jitter = rand.Int64N(int64(limit) + 1)
|
||||
}
|
||||
|
||||
return time.Duration(jitter)
|
||||
}
|
||||
|
||||
// Reset resets the current backoff cap to the initial value.
|
||||
// This should be called after a successful operation to restart
|
||||
// the backoff sequence for future retries.
|
||||
func (b *Backoff) Reset() {
|
||||
b.current = b.initial
|
||||
}
|
||||
196
internal/backoff/backoff_test.go
Normal file
196
internal/backoff/backoff_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package backoff
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
cfg Config
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "initial zero",
|
||||
cfg: Config{Initial: 0, Max: time.Second},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "initial negative",
|
||||
cfg: Config{Initial: -1, Max: time.Second},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "max less than initial",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: 500 * time.Millisecond,
|
||||
},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "max equals maxint64",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: math.MaxInt64,
|
||||
},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: time.Second,
|
||||
},
|
||||
shouldError: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if tt.shouldError && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tt.shouldError && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInvalidConfig(t *testing.T) {
|
||||
_, err := New(Config{Initial: 0, Max: time.Second})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid config") {
|
||||
t.Fatalf("expected invalid config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistribution uses a fixed seed to check the logic flow and jitter.
|
||||
func TestDistribution(t *testing.T) {
|
||||
// RNG with seed (1, 2)
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
initial := 100 * time.Millisecond
|
||||
maxDur := 400 * time.Millisecond
|
||||
|
||||
b, _ := New(Config{
|
||||
Initial: initial,
|
||||
Max: maxDur,
|
||||
RNG: rng,
|
||||
})
|
||||
|
||||
// Generate reference numbers using the same seed to predict outcomes.
|
||||
refRng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
expectedCaps := []time.Duration{
|
||||
100 * time.Millisecond,
|
||||
200 * time.Millisecond,
|
||||
400 * time.Millisecond,
|
||||
400 * time.Millisecond, // Clamped
|
||||
}
|
||||
|
||||
for i, cap := range expectedCaps {
|
||||
// Expect Next to pick rand(0, cap).
|
||||
expected := time.Duration(refRng.Int64N(int64(cap) + 1))
|
||||
got := b.Next()
|
||||
|
||||
if got != expected {
|
||||
t.Errorf(
|
||||
"step %d: expected %v, got %v (cap was %v)",
|
||||
i, expected, got, cap,
|
||||
)
|
||||
}
|
||||
if got > cap {
|
||||
t.Errorf(
|
||||
"step %d: got %v greater than cap %v",
|
||||
i, got, cap,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverflowProtection ensures that large Max values do not cause overflow
|
||||
// during the doubling phase.
|
||||
func TestOverflowProtection(t *testing.T) {
|
||||
// Use a Max that is near MaxInt64.
|
||||
max := time.Duration(math.MaxInt64 - 1)
|
||||
start := max / 4
|
||||
|
||||
b, err := New(Config{
|
||||
Initial: start,
|
||||
Max: max,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Current = start.
|
||||
// Next returns [0, start]. state becomes start*2 (max/2).
|
||||
_ = b.Next()
|
||||
|
||||
// 2. Current = max/2.
|
||||
// Next returns [0, max/2].
|
||||
_ = b.Next()
|
||||
|
||||
// 3. Current = max.
|
||||
// Next returns [0, max].
|
||||
// Logic check: current >= max. state stays max.
|
||||
val := b.Next()
|
||||
if val < 0 {
|
||||
t.Errorf("got negative duration %v, likely overflow", val)
|
||||
}
|
||||
|
||||
// Verify clamp.
|
||||
// No panic implies success.
|
||||
for range 5 {
|
||||
b.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRNG(t *testing.T) {
|
||||
b, err := New(Config{
|
||||
Initial: time.Millisecond,
|
||||
Max: 10 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
got := b.Next()
|
||||
if got < 0 || got > time.Millisecond {
|
||||
t.Errorf("out of bounds: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
b, _ := New(Config{
|
||||
Initial: 10 * time.Millisecond,
|
||||
Max: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Advance state
|
||||
b.Next() // 10
|
||||
b.Next() // 20
|
||||
b.Next() // 40
|
||||
|
||||
b.Reset()
|
||||
|
||||
// Verify that the cap is reset by checking the bounds of the next
|
||||
// call. If reset works, the next call is bounded by Initial (10ms).
|
||||
for range 10 {
|
||||
got := b.Next()
|
||||
if got > 10*time.Millisecond {
|
||||
t.Fatalf(
|
||||
"call after Reset returned %v; expected <= 10ms",
|
||||
got,
|
||||
)
|
||||
}
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
264
internal/imap/imap.go
Normal file
264
internal/imap/imap.go
Normal file
@@ -0,0 +1,264 @@
|
||||
// Package imap provides an IMAP client wrapper for monitoring and fetching
|
||||
// email messages. It handles connection management, IDLE support for push
|
||||
// notifications, and message operations like fetching and marking as seen.
|
||||
//
|
||||
// The client supports server-side filtering via [filter.Filters] to limit
|
||||
// which messages are returned by [Client.Unseen].
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"raven/internal/filter"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
)
|
||||
|
||||
// Config holds IMAP server connection parameters.
|
||||
type Config struct {
|
||||
// Host is the IMAP server hostname.
|
||||
Host string `yaml:"host"`
|
||||
// Password is the authentication password or app-specific password.
|
||||
Password string `yaml:"password"`
|
||||
// Port is the IMAP server port (typically "993" for TLS).
|
||||
Port string `yaml:"port"`
|
||||
// User is the authentication username, usually an email address.
|
||||
User string `yaml:"user"`
|
||||
}
|
||||
|
||||
// Address returns the host:port string for dialing.
|
||||
func (c *Config) Address() string {
|
||||
return fmt.Sprintf("%s:%s", c.Host, c.Port)
|
||||
}
|
||||
|
||||
// Validate checks that all required fields are present.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Host == "" {
|
||||
return fmt.Errorf("missing host")
|
||||
}
|
||||
if c.Password == "" {
|
||||
return fmt.Errorf("missing password")
|
||||
}
|
||||
if c.Port == "" {
|
||||
return fmt.Errorf("missing port")
|
||||
}
|
||||
if c.User == "" {
|
||||
return fmt.Errorf("missing user")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client wraps an IMAP client with connection management and filtering.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
filters filter.Filters
|
||||
client *imapclient.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewClient creates a new Client with the given configuration.
|
||||
// The client is not connected until [Client.Connect] is called.
|
||||
func NewClient(
|
||||
cfg Config,
|
||||
filters filter.Filters,
|
||||
log *slog.Logger,
|
||||
) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %v", err)
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
filters: filters,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Client returns the underlying imapclient.Client.
|
||||
// Returns nil if not connected.
|
||||
func (c *Client) Client() *imapclient.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
// Connect establishes a TLS connection to the IMAP server,
|
||||
// authenticates, and selects the INBOX.
|
||||
func (c *Client) Connect(
|
||||
ctx context.Context,
|
||||
log *slog.Logger,
|
||||
opts *imapclient.Options,
|
||||
) error {
|
||||
c.log = log
|
||||
|
||||
c.log.InfoContext(ctx, "connecting to IMAP server")
|
||||
client, err := imapclient.DialTLS(c.cfg.Address(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial TLS: %w", err)
|
||||
}
|
||||
c.client = client
|
||||
c.log.InfoContext(ctx, "connected to IMAP server")
|
||||
|
||||
c.log.InfoContext(ctx, "logging into to IMAP server")
|
||||
if err := client.Login(c.cfg.User, c.cfg.Password).Wait(); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
c.log.InfoContext(ctx, "logged into IMAP server")
|
||||
|
||||
c.log.InfoContext(ctx, "selecting INBOX")
|
||||
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
|
||||
return fmt.Errorf("select: %w", err)
|
||||
}
|
||||
c.log.InfoContext(ctx, "selected INBOX")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanIdle reports whether the server advertises IDLE capability (RFC 2177).
|
||||
func (c *Client) CanIdle() bool {
|
||||
return c.client.Caps().Has(imap.CapIdle)
|
||||
}
|
||||
|
||||
// Disconnect logs out from the server and closes the connection.
|
||||
// Safe to call on a nil or disconnected client.
|
||||
func (c *Client) Disconnect(ctx context.Context) {
|
||||
if c.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.log.InfoContext(ctx, "logging out")
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var done = make(chan error, 1)
|
||||
go func() {
|
||||
done <- c.client.Logout().Wait()
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.log.InfoContext(
|
||||
ctx,
|
||||
"context closed",
|
||||
slog.Any("reason", ctx.Err()),
|
||||
)
|
||||
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
c.log.Warn(
|
||||
"failed to logout from IMAP server",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.client.Close()
|
||||
c.client = nil
|
||||
}
|
||||
|
||||
// Fetch retrieves a message by UID. If peek is true, the message is
|
||||
// fetched without marking it as seen (using BODY.PEEK[]).
|
||||
func (c *Client) Fetch(uid imap.UID, peek bool) (
|
||||
*imapclient.FetchMessageBuffer, error,
|
||||
) {
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("client not connected")
|
||||
}
|
||||
|
||||
opts := &imap.FetchOptions{Envelope: true, UID: true}
|
||||
if peek {
|
||||
opts.BodySection = []*imap.FetchItemBodySection{{Peek: true}}
|
||||
}
|
||||
|
||||
res, err := c.client.Fetch(imap.UIDSetNum(uid), opts).Collect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, fmt.Errorf("message not found")
|
||||
}
|
||||
|
||||
return res[0], nil
|
||||
}
|
||||
|
||||
// Idle starts an IDLE command (RFC 2177) to wait for server notifications.
|
||||
// The caller must call Close on the returned command to end IDLE mode.
|
||||
func (c *Client) Idle() (*imapclient.IdleCommand, error) {
|
||||
return c.client.Idle()
|
||||
}
|
||||
|
||||
// MarkSeen adds the \Seen flag to the message with the given UID.
|
||||
func (c *Client) MarkSeen(uid imap.UID) error {
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("client not connected")
|
||||
}
|
||||
|
||||
if _, err := c.client.Store(
|
||||
imap.UIDSetNum(uid),
|
||||
&imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
},
|
||||
nil,
|
||||
).Collect(); err != nil {
|
||||
return fmt.Errorf("collect: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Noop sends a NOOP command to refresh mailbox state.
|
||||
func (c *Client) Noop() error {
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("client not connected")
|
||||
}
|
||||
return c.client.Noop().Wait()
|
||||
}
|
||||
|
||||
// Unseen returns UIDs of messages matching the client's filters that
|
||||
// do not have the \Seen flag. Filters are applied server-side.
|
||||
func (c *Client) Unseen(ctx context.Context) ([]imap.UID, error) {
|
||||
sc := &imap.SearchCriteria{
|
||||
NotFlag: []imap.Flag{imap.FlagSeen},
|
||||
}
|
||||
if len(c.filters.Body) > 0 {
|
||||
sc.Body = c.filters.Body
|
||||
}
|
||||
if c.filters.From != "" {
|
||||
sc.Header = append(
|
||||
sc.Header,
|
||||
imap.SearchCriteriaHeaderField{
|
||||
Key: "From",
|
||||
Value: c.filters.From,
|
||||
},
|
||||
)
|
||||
}
|
||||
if c.filters.Subject != "" {
|
||||
sc.Header = append(
|
||||
sc.Header,
|
||||
imap.SearchCriteriaHeaderField{
|
||||
Key: "Subject",
|
||||
Value: c.filters.Subject,
|
||||
},
|
||||
)
|
||||
}
|
||||
if c.filters.To != "" {
|
||||
sc.Header = append(
|
||||
sc.Header,
|
||||
imap.SearchCriteriaHeaderField{
|
||||
Key: "To",
|
||||
Value: c.filters.To,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
res, err := c.client.UIDSearch(sc, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %v", err)
|
||||
}
|
||||
uids := res.AllUIDs()
|
||||
|
||||
return uids, nil
|
||||
}
|
||||
224
internal/imap/imap_test.go
Normal file
224
internal/imap/imap_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"raven/internal/filter"
|
||||
)
|
||||
|
||||
func TestConfigAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "standard config",
|
||||
cfg: Config{Host: "imap.example.com", Port: "993"},
|
||||
want: "imap.example.com:993",
|
||||
},
|
||||
{
|
||||
name: "empty host",
|
||||
cfg: Config{Host: "", Port: "993"},
|
||||
want: ":993",
|
||||
},
|
||||
{
|
||||
name: "empty port",
|
||||
cfg: Config{Host: "imap.example.com", Port: ""},
|
||||
want: "imap.example.com:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.cfg.Address(); got != tt.want {
|
||||
t.Errorf(
|
||||
"Address() = %q, want %q",
|
||||
got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
cfg: Config{
|
||||
Host: "imap.example.com",
|
||||
Password: "secret",
|
||||
Port: "993",
|
||||
User: "user@example.com",
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "missing host",
|
||||
cfg: Config{
|
||||
Password: "secret",
|
||||
Port: "993",
|
||||
User: "user@example.com",
|
||||
},
|
||||
wantErr: "missing host",
|
||||
},
|
||||
{
|
||||
name: "missing password",
|
||||
cfg: Config{
|
||||
Host: "imap.example.com",
|
||||
Port: "993",
|
||||
User: "user@example.com",
|
||||
},
|
||||
wantErr: "missing password",
|
||||
},
|
||||
{
|
||||
name: "missing port",
|
||||
cfg: Config{
|
||||
Host: "imap.example.com",
|
||||
Password: "secret",
|
||||
User: "user@example.com",
|
||||
},
|
||||
wantErr: "missing port",
|
||||
},
|
||||
{
|
||||
name: "missing user",
|
||||
cfg: Config{
|
||||
Host: "imap.example.com",
|
||||
Password: "secret",
|
||||
Port: "993",
|
||||
},
|
||||
wantErr: "missing user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Errorf(
|
||||
"Validate() unexpected error: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf(
|
||||
"Validate() expected error containing %q, got nil",
|
||||
tt.wantErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
if got := err.Error(); got != tt.wantErr {
|
||||
t.Errorf(
|
||||
"Validate() error = %q, want %q",
|
||||
got, tt.wantErr,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
validCfg := Config{
|
||||
Host: "imap.example.com",
|
||||
Password: "secret",
|
||||
Port: "993",
|
||||
User: "user@example.com",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
filters filter.Filters
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
cfg: validCfg,
|
||||
filters: filter.Filters{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config with filters",
|
||||
cfg: validCfg,
|
||||
filters: filter.Filters{
|
||||
Subject: "test",
|
||||
To: "recipient@example.com",
|
||||
Body: []string{"keyword"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid config",
|
||||
cfg: Config{Host: "imap.example.com"},
|
||||
filters: filter.Filters{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, err := NewClient(
|
||||
tt.cfg, tt.filters, slog.Default(),
|
||||
)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error(
|
||||
"NewClient() expected error, got nil",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf(
|
||||
"NewClient() unexpected error: %v", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if client == nil {
|
||||
t.Error("NewClient() returned nil client")
|
||||
return
|
||||
}
|
||||
// Verify client is not connected.
|
||||
if client.Client() != nil {
|
||||
t.Error(
|
||||
"NewClient() client should not be connected",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientNotConnected(t *testing.T) {
|
||||
cfg := Config{
|
||||
Host: "imap.example.com",
|
||||
Password: "secret",
|
||||
Port: "993",
|
||||
User: "user@example.com",
|
||||
}
|
||||
client, err := NewClient(cfg, filter.Filters{}, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Fetch without connection", func(t *testing.T) {
|
||||
_, err := client.Fetch(1, true)
|
||||
if err == nil {
|
||||
t.Error("Fetch() expected error when not connected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MarkSeen without connection", func(t *testing.T) {
|
||||
err := client.MarkSeen(1)
|
||||
if err == nil {
|
||||
t.Error("MarkSeen() expected error when not connected")
|
||||
}
|
||||
})
|
||||
}
|
||||
398
internal/intake/intake.go
Normal file
398
internal/intake/intake.go
Normal file
@@ -0,0 +1,398 @@
|
||||
// Package intake monitors an IMAP mailbox for new messages and dispatches
|
||||
// them to answer workers for processing. It supports two modes: IDLE for
|
||||
// servers with real-time push notifications, and poll for periodic checking.
|
||||
package intake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"raven/internal/backoff"
|
||||
"raven/internal/filter"
|
||||
"raven/internal/imap"
|
||||
"raven/internal/tracker"
|
||||
|
||||
goimap "github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
)
|
||||
|
||||
// Config holds intake worker settings.
|
||||
type Config struct {
|
||||
// Mode selects IDLE or poll-based message retrieval.
|
||||
Mode Mode `yaml:"mode"`
|
||||
// PollInterval is the duration between checks in poll mode.
|
||||
// Ignored in IDLE mode. Examples: "5s", "1m", "24h".
|
||||
PollInterval string `yaml:"poll_interval"`
|
||||
}
|
||||
|
||||
// Validate checks that the configuration values are sensible.
|
||||
func (c *Config) Validate() error {
|
||||
if !c.Mode.Valid() {
|
||||
return fmt.Errorf("invalid mode")
|
||||
}
|
||||
d, err := time.ParseDuration(c.PollInterval)
|
||||
if d == 0 || err != nil {
|
||||
|
||||
return fmt.Errorf(
|
||||
"invalid poll_interval %q: %v",
|
||||
c.PollInterval, err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mode represents the message retrieval strategy.
|
||||
type Mode string
|
||||
|
||||
// Valid returns true if the mode is a recognized value.
|
||||
func (m Mode) Valid() bool {
|
||||
return slices.Contains([]Mode{ModeIdle, ModePoll}, m)
|
||||
}
|
||||
|
||||
// String returns the mode as a string.
|
||||
func (m Mode) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
const (
|
||||
// ModeIdle uses IMAP IDLE for real-time notifications.
|
||||
ModeIdle Mode = "idle"
|
||||
// ModePoll uses periodic polling at a configured interval.
|
||||
ModePoll Mode = "poll"
|
||||
)
|
||||
|
||||
// Worker monitors an IMAP mailbox and dispatches message UIDs to answer
|
||||
// workers. It maintains a persistent connection with automatic reconnection
|
||||
// and exponential backoff on errors.
|
||||
type Worker struct {
|
||||
// backoff controls retry delays after connection failures.
|
||||
backoff *backoff.Backoff
|
||||
// cfg holds the worker configuration.
|
||||
cfg Config
|
||||
// ic is the IMAP client for mailbox operations.
|
||||
ic *imap.Client
|
||||
// interval is the parsed poll interval duration.
|
||||
interval time.Duration
|
||||
// log is the worker's logger with worker context.
|
||||
log *slog.Logger
|
||||
// tracker prevents duplicate processing across workers.
|
||||
tracker *tracker.Tracker
|
||||
// update signals new messages during IDLE.
|
||||
update chan struct{}
|
||||
// work sends fetched message UIDs to answer workers.
|
||||
work chan<- goimap.UID
|
||||
}
|
||||
|
||||
// NewWorker creates an intake Worker with the provided configuration.
|
||||
// The tracker coordinates with answer workers to prevent duplicate processing.
|
||||
// The work channel receives UIDs for messages that need responses.
|
||||
func NewWorker(
|
||||
cfg Config,
|
||||
filters filter.Filters,
|
||||
imapConfig imap.Config,
|
||||
log *slog.Logger,
|
||||
tracker *tracker.Tracker,
|
||||
work chan<- goimap.UID,
|
||||
) (*Worker, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %v", err)
|
||||
}
|
||||
if tracker == nil {
|
||||
return nil, fmt.Errorf("missing tracker")
|
||||
}
|
||||
if work == nil {
|
||||
return nil, fmt.Errorf("missing work channel")
|
||||
}
|
||||
|
||||
b, err := backoff.New(backoff.Config{
|
||||
Initial: time.Second,
|
||||
Max: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create backoff: %v", err)
|
||||
}
|
||||
|
||||
ic, err := imap.NewClient(imapConfig, filters, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create imap client: %v", err)
|
||||
}
|
||||
|
||||
var w = &Worker{
|
||||
backoff: b,
|
||||
cfg: cfg,
|
||||
ic: ic,
|
||||
log: log.With(slog.String("worker", "intake")),
|
||||
tracker: tracker,
|
||||
work: work,
|
||||
}
|
||||
switch w.cfg.Mode {
|
||||
case ModeIdle:
|
||||
w.update = make(chan struct{}, 1)
|
||||
|
||||
case ModePoll:
|
||||
d, err := time.ParseDuration(cfg.PollInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parse poll interval: %v", err,
|
||||
)
|
||||
}
|
||||
w.interval = d
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Run starts the intake worker. It connects to the IMAP server and monitors
|
||||
// for new messages using either IDLE or poll mode based on configuration.
|
||||
// Handles reconnection automatically with exponential backoff on errors.
|
||||
// Returns nil when the context is canceled.
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
w.log.InfoContext(ctx, "running intake worker")
|
||||
defer w.log.InfoContext(ctx, "intake worker terminating")
|
||||
defer w.ic.Disconnect(ctx)
|
||||
|
||||
// For IDLE mode, set up handler to signal new messages.
|
||||
var opts *imapclient.Options
|
||||
if w.cfg.Mode == ModeIdle {
|
||||
opts = &imapclient.Options{
|
||||
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
|
||||
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
|
||||
if data.NumMessages != nil {
|
||||
select {
|
||||
case w.update <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
for {
|
||||
// Context closed -- terminate.
|
||||
if err := ctx.Err(); err != nil {
|
||||
w.log.InfoContext(
|
||||
ctx,
|
||||
"context closed",
|
||||
slog.Any("reason", ctx.Err()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attempt to connect.
|
||||
if w.ic.Client() == nil {
|
||||
if err := w.ic.Connect(
|
||||
ctx, w.log, opts,
|
||||
); err != nil {
|
||||
// Failed to connect; backoff and try again.
|
||||
w.log.ErrorContext(
|
||||
ctx,
|
||||
"failed to connect to IMAP server",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
wait := w.backoff.Next()
|
||||
w.log.InfoContext(
|
||||
ctx,
|
||||
"retrying after backoff",
|
||||
slog.Any("wait", wait),
|
||||
)
|
||||
// Wait for backoff, unless context closed.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
continue
|
||||
case <-time.After(wait):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Connected.
|
||||
// For IDLE mode, verify server capability.
|
||||
if w.cfg.Mode == ModeIdle && !w.ic.CanIdle() {
|
||||
return fmt.Errorf(
|
||||
"server lacks IDLE capability",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve messages with the appropriate mode.
|
||||
var err error
|
||||
switch w.cfg.Mode {
|
||||
case ModeIdle:
|
||||
err = w.idle(ctx)
|
||||
case ModePoll:
|
||||
err = w.poll(ctx)
|
||||
default:
|
||||
return fmt.Errorf("unrecognized mode: %q", w.cfg.Mode)
|
||||
}
|
||||
if err != nil {
|
||||
// Diagnose the error.
|
||||
// Context canceled: loop, log, return.
|
||||
if err := ctx.Err(); err != nil {
|
||||
continue
|
||||
}
|
||||
// Otherwise, error on intake or idle.
|
||||
w.log.ErrorContext(
|
||||
ctx,
|
||||
"IMAP error",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
wait := w.backoff.Next()
|
||||
w.log.InfoContext(
|
||||
ctx,
|
||||
"retrying after backoff",
|
||||
slog.Any("wait", wait),
|
||||
)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
continue
|
||||
case <-time.After(wait):
|
||||
}
|
||||
// Force reconnect on next loop.
|
||||
w.ic.Disconnect(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll runs the polling loop, checking for new messages at the configured
|
||||
// interval. The interval could be 5 seconds or 24 hours depending on use case.
|
||||
// Attempts to reuse the established connection, but the caller handles
|
||||
// reconnection if the server closes an idle connection.
|
||||
func (w *Worker) poll(ctx context.Context) error {
|
||||
for {
|
||||
// Refresh mailbox state before searching.
|
||||
if err := w.ic.Noop(); err != nil {
|
||||
return fmt.Errorf("noop: %v", err)
|
||||
}
|
||||
|
||||
if err := w.intake(ctx); err != nil {
|
||||
return fmt.Errorf("intake: %v", err)
|
||||
}
|
||||
// Connection is healthy. Reset backoff.
|
||||
w.backoff.Reset()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("context closed: %v", ctx.Err())
|
||||
case <-time.After(w.interval):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// idle runs the IDLE loop, using IMAP IDLE for real-time message notifications.
|
||||
// After each intake cycle, issues an IDLE command and waits for the server
|
||||
// to signal new messages. Handles IDLE termination and reconnection.
|
||||
func (w *Worker) idle(ctx context.Context) error {
|
||||
for {
|
||||
if err := w.intake(ctx); err != nil {
|
||||
return fmt.Errorf("intake: %v", err)
|
||||
}
|
||||
// Connection is healthy. Reset backoff.
|
||||
w.backoff.Reset()
|
||||
|
||||
// Issue the IDLE command.
|
||||
w.log.InfoContext(ctx, "entering IDLE mode")
|
||||
idleCmd, err := w.ic.Idle()
|
||||
if err != nil {
|
||||
return fmt.Errorf("idle: %v", err)
|
||||
}
|
||||
|
||||
// Monitor the IDLE command.
|
||||
idleDone := make(chan error, 1)
|
||||
go func() {
|
||||
idleDone <- idleCmd.Wait()
|
||||
}()
|
||||
|
||||
// Wait for: shutdown, connection death, or new message.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := idleCmd.Close(); err != nil {
|
||||
w.log.WarnContext(
|
||||
ctx,
|
||||
"failed to close IDLE command",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("context closed: %v", ctx.Err())
|
||||
|
||||
case err := <-idleDone:
|
||||
// Connection died or IDLE ended unexpectedly.
|
||||
if err != nil {
|
||||
return fmt.Errorf("idle wait: %v", err)
|
||||
}
|
||||
// IDLE ended without error (server closed).
|
||||
w.log.InfoContext(ctx, "IDLE ended; refreshing")
|
||||
return nil
|
||||
|
||||
case <-w.update:
|
||||
w.log.InfoContext(ctx, "IDLE: new message received")
|
||||
if err := idleCmd.Close(); err != nil {
|
||||
<-idleDone
|
||||
return fmt.Errorf("idle close: %v", err)
|
||||
}
|
||||
<-idleDone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// intake fetches unseen messages and sends their UIDs to the work channel.
|
||||
// Uses the tracker to skip messages already being processed by answer workers.
|
||||
// Returns early if the work queue is full, deferring remaining messages to
|
||||
// the next cycle.
|
||||
func (w *Worker) intake(ctx context.Context) error {
|
||||
w.log.InfoContext(ctx, "fetching unseen messages")
|
||||
|
||||
uids, err := w.ic.Unseen(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieve unseen messages: %v", err)
|
||||
}
|
||||
if len(uids) == 0 {
|
||||
w.log.InfoContext(ctx, "no new messages")
|
||||
return nil
|
||||
}
|
||||
w.log.InfoContext(
|
||||
ctx,
|
||||
"found unseen messages",
|
||||
slog.Int("count", len(uids)),
|
||||
)
|
||||
|
||||
for _, uid := range uids {
|
||||
if !w.tracker.TryAcquire(uid) {
|
||||
w.log.InfoContext(
|
||||
ctx,
|
||||
"skipping message; already acquired",
|
||||
slog.Any("uid", uid),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.tracker.Release(uid)
|
||||
w.log.InfoContext(
|
||||
ctx,
|
||||
"context closed",
|
||||
slog.Any("reason", ctx.Err()),
|
||||
)
|
||||
return nil
|
||||
|
||||
case w.work <- uid:
|
||||
w.log.InfoContext(
|
||||
ctx, "enqueued", slog.Any("uid", uid),
|
||||
)
|
||||
|
||||
default:
|
||||
// Queue full, release and defer to next cycle.
|
||||
w.tracker.Release(uid)
|
||||
w.log.InfoContext(
|
||||
ctx, "work queue full, deferring remaining",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
210
internal/intake/intake_test.go
Normal file
210
internal/intake/intake_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package intake
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"raven/internal/filter"
|
||||
"raven/internal/imap"
|
||||
"raven/internal/tracker"
|
||||
|
||||
goimap "github.com/emersion/go-imap/v2"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid idle mode",
|
||||
cfg: Config{
|
||||
Mode: ModeIdle,
|
||||
PollInterval: "30s",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid poll mode",
|
||||
cfg: Config{
|
||||
Mode: ModePoll,
|
||||
PollInterval: "5m",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid poll mode with long interval",
|
||||
cfg: Config{
|
||||
Mode: ModePoll,
|
||||
PollInterval: "24h",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid mode",
|
||||
cfg: Config{
|
||||
Mode: "invalid",
|
||||
PollInterval: "30s",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty mode",
|
||||
cfg: Config{
|
||||
Mode: "",
|
||||
PollInterval: "30s",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing poll interval",
|
||||
cfg: Config{
|
||||
Mode: ModeIdle,
|
||||
PollInterval: "",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid poll interval",
|
||||
cfg: Config{
|
||||
Mode: ModePoll,
|
||||
PollInterval: "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero poll interval",
|
||||
cfg: Config{
|
||||
Mode: ModePoll,
|
||||
PollInterval: "0s",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf(
|
||||
"Validate() error = %v, wantErr %v",
|
||||
err, tt.wantErr,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode Mode
|
||||
want bool
|
||||
}{
|
||||
{ModeIdle, true},
|
||||
{ModePoll, true},
|
||||
{"idle", true},
|
||||
{"poll", true},
|
||||
{"", false},
|
||||
{"invalid", false},
|
||||
{"IDLE", false},
|
||||
{"POLL", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.mode), func(t *testing.T) {
|
||||
if got := tt.mode.Valid(); got != tt.want {
|
||||
t.Errorf(
|
||||
"Mode(%q).Valid() = %v, want %v",
|
||||
tt.mode, got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode Mode
|
||||
want string
|
||||
}{
|
||||
{ModeIdle, "idle"},
|
||||
{ModePoll, "poll"},
|
||||
{"custom", "custom"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
if got := tt.mode.String(); got != tt.want {
|
||||
t.Errorf(
|
||||
"Mode.String() = %v, want %v",
|
||||
got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerValidation(t *testing.T) {
|
||||
validConfig := Config{
|
||||
Mode: ModeIdle,
|
||||
PollInterval: "30s",
|
||||
}
|
||||
validTracker := tracker.New()
|
||||
validWork := make(chan goimap.UID, 1)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
tracker *tracker.Tracker
|
||||
work chan goimap.UID
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil tracker",
|
||||
cfg: validConfig,
|
||||
tracker: nil,
|
||||
work: validWork,
|
||||
wantErr: "missing tracker",
|
||||
},
|
||||
{
|
||||
name: "nil work channel",
|
||||
cfg: validConfig,
|
||||
tracker: validTracker,
|
||||
work: nil,
|
||||
wantErr: "missing work channel",
|
||||
},
|
||||
{
|
||||
name: "invalid config",
|
||||
cfg: Config{
|
||||
Mode: "invalid",
|
||||
PollInterval: "30s",
|
||||
},
|
||||
tracker: validTracker,
|
||||
work: validWork,
|
||||
wantErr: "invalid config",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewWorker(
|
||||
tt.cfg,
|
||||
filter.Filters{},
|
||||
imap.Config{},
|
||||
nil, // log
|
||||
tt.tracker,
|
||||
tt.work,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("NewWorker() expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf(
|
||||
"NewWorker() error = %q, want containing %q",
|
||||
err.Error(), tt.wantErr,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
43
internal/tracker/tracker.go
Normal file
43
internal/tracker/tracker.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Package tracker prevents duplicate processing of IMAP messages by
|
||||
// maintaining a transient set of active, in-flight UIDs.
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
)
|
||||
|
||||
// Tracker ensures unique concurrent processing of messages.
|
||||
// This prevents race conditions where a message might be fetched
|
||||
// again before its initial processing completes.
|
||||
type Tracker struct {
|
||||
mu sync.Mutex
|
||||
ids map[imap.UID]struct{}
|
||||
}
|
||||
|
||||
// New returns a ready-to-use Tracker.
|
||||
func New() *Tracker {
|
||||
return &Tracker{
|
||||
ids: make(map[imap.UID]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// TryAcquire attempts to claim exclusive processing rights for a UID.
|
||||
// It returns true only if the UID is not currently being tracked.
|
||||
func (t *Tracker) TryAcquire(uid imap.UID) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, exists := t.ids[uid]; exists {
|
||||
return false
|
||||
}
|
||||
t.ids[uid] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
// Release relinquishes processing rights for a UID, allowing it to be acquired again.
|
||||
func (t *Tracker) Release(uid imap.UID) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
delete(t.ids, uid)
|
||||
}
|
||||
94
internal/tracker/tracker_test.go
Normal file
94
internal/tracker/tracker_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tr := New()
|
||||
if tr == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
if tr.ids == nil {
|
||||
t.Fatal("New() initialized with nil map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycle(t *testing.T) {
|
||||
tr := New()
|
||||
uid := imap.UID(123)
|
||||
|
||||
// 1. First acquire should succeed.
|
||||
if !tr.TryAcquire(uid) {
|
||||
t.Fatalf(
|
||||
"expected TryAcquire(%d) to return true, got false",
|
||||
uid,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Second acquire on same UID should fail.
|
||||
if tr.TryAcquire(uid) {
|
||||
t.Fatalf(
|
||||
"expected TryAcquire(%d) to return false when already held, got true",
|
||||
uid,
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Different UID should succeed.
|
||||
otherUID := imap.UID(456)
|
||||
if !tr.TryAcquire(otherUID) {
|
||||
t.Fatalf(
|
||||
"expected TryAcquire(%d) to return true, got false",
|
||||
otherUID,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Release first UID.
|
||||
tr.Release(uid)
|
||||
|
||||
// 5. First UID should be acquirable again.
|
||||
if !tr.TryAcquire(uid) {
|
||||
t.Fatalf(
|
||||
"expected TryAcquire(%d) to return true after Release, got false",
|
||||
uid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrency(t *testing.T) {
|
||||
tr := New()
|
||||
uid := imap.UID(1)
|
||||
routines := 16
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Counter for successful acquisitions.
|
||||
successCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
for range routines {
|
||||
wg.Go(func() {
|
||||
if tr.TryAcquire(uid) {
|
||||
mu.Lock()
|
||||
successCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if successCount != 1 {
|
||||
t.Errorf(
|
||||
"expected exactly 1 successful acquisition for concurrent access to same UID, got %d",
|
||||
successCount,
|
||||
)
|
||||
}
|
||||
|
||||
// Verify we can release safely after concurrent hammer.
|
||||
tr.Release(uid)
|
||||
if !tr.TryAcquire(uid) {
|
||||
t.Error("failed to acquire UID after concurrent test release")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user