d69a1f8076
The literal size came off the wire with no cap, so the server chose the
allocation. A {2147483647} literal was a 2GB make before a byte arrived, and one
ordinary mail with a 60MB attachment was 60MB of peak RSS on a box already
holding a 1.7B model resident, all of it discarded afterwards by plaintextBody.
Literals are now capped at MaxMessageBytes, and a larger one is drained and
reported as ErrMessageTooLarge without being kept. Reads are chunked with a
deadline refresh, so the timeout is an idle timeout again rather than a budget
for the whole message.
FetchSince returned on the first fetch error, though its comment described a
continue. One oversized message at the top of the window hid every older message
behind it, on that poll and on every poll after it. Failures are now collected
and the rest of the mailbox is read. An oversized UID is retired as bulk, since
it will be the same size next time and the poller marks bulk seen.
Timeout zero was accepted and disabled the dial timeout and every socket
deadline, which parks the poller forever on a dead server with his credential
live in a TLS state. It is now rejected like an empty address.
A FETCH answered without a literal was indistinguishable from a vanished
message and dropped with no log line. Login now rejects a credential containing
a line break instead of stripping it and failing on the server's generic NO.
untagged matches the whole key, not a prefix. RunWith is gone: the dial seam is
an unexported field again, reachable only through export_test.go, so no code
outside the package can hand the reader a cleartext transport and the password.
Found in review of #63.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
129 lines
4.5 KiB
Go
129 lines
4.5 KiB
Go
package email
|
|
|
|
import (
|
|
"errors"
|
|
"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 — the connection seam, unexported on purpose: see dialer(). Tests
|
|
// inside this package set it through export_test.go; nothing outside can.
|
|
dial func(addr string, timeout time.Duration) (*Conn, error)
|
|
}
|
|
|
|
// dial is the connection seam. nil means Dial (implicit TLS); the tests set it
|
|
// through export_test.go. It is unexported and there is no exported wrapper
|
|
// that takes a dialer, so no code outside this package can point the reader at
|
|
// a non-TLS transport and hand it the password. That is a property the compiler
|
|
// enforces, not a claim about the callers that happen to exist today.
|
|
func (f FetchSince) dialer() func(addr string, timeout time.Duration) (*Conn, error) {
|
|
if f.dial != nil {
|
|
return f.dial
|
|
}
|
|
return Dial
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// One message that cannot be read does not abandon the poll: the rest of the
|
|
// mailbox is still worth reading, and returning early meant one oversized or
|
|
// unreadable message permanently hid every older message behind it, poll after
|
|
// poll. The returned error joins whatever failed, and the messages that did
|
|
// come back come back with it.
|
|
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)")
|
|
}
|
|
// Timeout is validated like the other three fields. Zero disables every
|
|
// deadline in the path — net.Dialer{Timeout: 0} and a Conn that never calls
|
|
// SetDeadline — so a dead server parks the poller forever on a socket read,
|
|
// with his credential live in a TLS state. That is the exact thing the
|
|
// connect-read-drop shape exists to avoid.
|
|
if f.Timeout <= 0 {
|
|
return nil, fmt.Errorf("email: timeout must be positive")
|
|
}
|
|
dial := f.dialer()
|
|
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))
|
|
var failed []error
|
|
for _, uid := range wanted {
|
|
raw, err := c.Fetch(uid)
|
|
if errors.Is(err, ErrMessageTooLarge) {
|
|
// Too big to read is a permanent verdict, not a failure to retry:
|
|
// the message will be the same size next poll. Carried as bulk so
|
|
// the poller marks it seen and stops fetching it, exactly like a
|
|
// newsletter. Nothing is sent to the model.
|
|
out = append(out, Message{UID: uid, Junk: true, JunkReason: "oversize"})
|
|
continue
|
|
}
|
|
if err != nil {
|
|
// The error names the UID, never the message. Collected rather than
|
|
// returned, so the messages behind this one are still read.
|
|
failed = append(failed, fmt.Errorf("email: fetch uid %d: %w", uid, err))
|
|
continue
|
|
}
|
|
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, errors.Join(failed...)
|
|
}
|