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:
dwrz
2026-01-04 20:59:26 +00:00
parent ce3943cc1d
commit c53ee5f6ad
10 changed files with 1666 additions and 0 deletions

View 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
}

View 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()
}
}