// 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" "github.com/chimerical-llc/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 }