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:
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user