b4646155b4
internal/email is the reading half of the email reader: a ~200-line IMAP client (LOGIN, EXAMINE, UID SEARCH SINCE, UID FETCH BODY.PEEK, LOGOUT), a MIME-to-plaintext converter, and a header-only junk filter. Two protocol choices are the design, not shortcuts. EXAMINE instead of SELECT means the session is read-only at the protocol level, so no command in it can flip a flag or expunge anything by mistake. BODY.PEEK instead of BODY means reading a message does not mark it \Seen — Maven reads his mail and leaves no trace of having done so, and the unread state in his own client stays his. Hand-rolled rather than go-imap because this is the one path that holds his mailbox credential and reads his private mail: five commands with no dependencies is auditable in a sitting. No IDLE and no cleartext/STARTTLS either — an option to send his password over a plain socket is an option to get it wrong once. Junk is decided by headers alone, before any model is involved: List-Unsubscribe/List-Id, Precedence: bulk, Auto-Submitted, the spam headers, and Gmail's own category labels. Sender lists and subject keywords are deliberately absent — they age badly and they would put his contacts in a config file. A junk verdict only means "do not spend the model on this"; nothing is deleted and no server flag is touched. Nothing here logs a body, a subject or an address, the junk reason names a header rather than content, and an undecodable charset degrades to headers-only instead of feeding the model mojibake. Verified against recorded .eml fixtures and an in-process fake IMAP server.
97 lines
2.9 KiB
Go
97 lines
2.9 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
|
|
|
|
// dial is the connection seam. nil means Dial (implicit TLS); the tests set
|
|
// it to an in-process fake. Unexported so no configuration path can point
|
|
// the reader at a non-TLS transport.
|
|
dial func(addr string, timeout time.Duration) (*Conn, error)
|
|
}
|
|
|
|
// 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) {
|
|
if f.Addr == "" || f.User == "" || f.Mailbox == "" {
|
|
return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)")
|
|
}
|
|
dial := f.dial
|
|
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
|
|
}
|