package email import ( "fmt" "time" ) // FetchSince is the whole read path in one call: connect, log in, examine the // mailbox read-only, list what arrived since a date, fetch and parse the ones // the caller has not seen, log out. // // It is a function rather than a long-lived object because a mail poller should // not hold an authenticated session (and therefore his credential in a live TLS // state) between polls. Connect, read, drop. // // skip decides which UIDs are already known — the poller's seen-set. max bounds // one poll: a mailbox that received 400 messages overnight must not turn into // 400 LLM calls, and the newest max are the ones a task could still be hiding // in. Junk messages are returned too, flagged, so the caller can mark them seen // without a second protocol round. type FetchSince struct { Addr string // host or host:993 User string Mailbox string // e.g. "INBOX" Timeout time.Duration Since time.Time Max int Skip func(uid uint32) bool } // Run performs one read. password is passed here, not stored in the struct, so // the configuration of a mailbox and the secret for it are never the same value // sitting in the same place. func (f FetchSince) Run(password string) ([]Message, error) { return f.RunWith(password, nil) } // RunWith is Run with an explicit connection function, which is how the reader // daemon and the tests substitute an in-process server. nil ⇒ Dial, i.e. // implicit TLS with certificate verification; there is no configuration path // that reaches this, so no deployment can end up talking cleartext IMAP. func (f FetchSince) RunWith(password string, dial func(addr string, timeout time.Duration) (*Conn, error)) ([]Message, error) { if f.Addr == "" || f.User == "" || f.Mailbox == "" { return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)") } if dial == nil { dial = Dial } c, err := dial(f.Addr, f.Timeout) if err != nil { return nil, err } defer c.Close() if err := c.Login(f.User, password); err != nil { return nil, err } defer c.Logout() if err := c.Select(f.Mailbox); err != nil { return nil, err } uids, err := c.SearchSince(f.Since) if err != nil { return nil, err } // Newest UIDs first — IMAP hands them back ascending, and when Max clips the // list the recent mail is what matters. wanted := make([]uint32, 0, len(uids)) for i := len(uids) - 1; i >= 0; i-- { if f.Skip != nil && f.Skip(uids[i]) { continue } wanted = append(wanted, uids[i]) if f.Max > 0 && len(wanted) >= f.Max { break } } out := make([]Message, 0, len(wanted)) for _, uid := range wanted { raw, err := c.Fetch(uid) if err != nil { // One unreadable message does not abandon the poll; the rest of the // mailbox is still worth reading. The error names the UID, not the // message. return out, fmt.Errorf("email: fetch uid %d: %w", uid, err) } if len(raw) == 0 { continue // vanished between SEARCH and FETCH } msg, err := ParseMessage(uid, raw) if err != nil { continue // unparsable headers — nothing to review, skip silently } out = append(out, msg) } return out, nil }