ee7bec11e3
The extraction seam landed on the previous branch but nothing fed it. This adds the daemon that does: every interval it opens one mailbox read-only (EXAMINE + BODY.PEEK, so reading leaves no \Seen behind), fetches the UIDs it has not handed over yet, and posts each message to core over ingest_mail. Core runs the model and writes task candidates; this daemon writes nothing and cannot create a reminder. It is a separate daemon because of the credential. mavpoll set the precedent with the zenmoney token (#125): the module talking to the third party holds the secret, reads it from a file so it never lands in argv, in docker-compose.yml or in shell history, and core never sees it. There is deliberately no -password flag, and a test asserts that. Off unless configured at both ends: without -password-file the daemon refuses to start, and if core has no email block the first ingest returns ErrUnknownMethod, which disables the reader instead of hammering a socket that will keep refusing. A seen-UID state file (0600, atomic write) keeps a restart from re-extracting the whole lookback window; correctness does not depend on it, since capture dedupes on normalised text. Logs are counts and UIDs — no subject, sender or body. Verified with an in-process IMAP server and a fake core: bulk mail is filtered before core is asked, seen UIDs are not re-fetched, a failed ingest is retried next poll, ErrUnknownMethod stops at the first message, and state survives a restart. The live half is untested by design — no IMAP credential exists on this box; setup is written up as QA steps. Vikunja #246
99 lines
3.1 KiB
Go
99 lines
3.1 KiB
Go
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
|
|
}
|