email: bound the IMAP read and keep one bad message from blocking the poll
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
This commit is contained in:
+45
-15
@@ -1,6 +1,7 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
@@ -26,26 +27,46 @@ type FetchSince struct {
|
||||
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) {
|
||||
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
|
||||
// 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
|
||||
@@ -77,13 +98,22 @@ func (f FetchSince) RunWith(password string, dial func(addr string, timeout time
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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)
|
||||
// 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
|
||||
@@ -94,5 +124,5 @@ func (f FetchSince) RunWith(password string, dial func(addr string, timeout time
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, nil
|
||||
return out, errors.Join(failed...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user