Files
Maven/internal/email/fetch_test.go
kami d69a1f8076 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
2026-08-01 14:01:04 +04:00

118 lines
3.9 KiB
Go

package email
import (
"net"
"strings"
"testing"
"time"
)
func TestFetchSinceRun(t *testing.T) {
mk := func(subject string) string {
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
}
f := &fakeIMAP{
uids: []uint32{1, 2, 3},
msgs: map[uint32]string{1: mk("one"), 2: mk("two"), 3: mk("three")},
}
fs := FetchSince{
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
Timeout: 5 * time.Second,
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
Max: 2,
Skip: func(uid uint32) bool { return uid == 3 },
}
msgs, err := fs.WithDial(dialer(t, f)).Run("secret")
if err != nil {
t.Fatalf("run: %v", err)
}
// Newest first, the already-seen UID skipped, Max respected.
if len(msgs) != 2 {
t.Fatalf("got %d messages, want 2: %+v", len(msgs), msgs)
}
if msgs[0].Subject != "two" || msgs[1].Subject != "one" {
t.Errorf("subjects = %q,%q, want two,one (newest first)", msgs[0].Subject, msgs[1].Subject)
}
if strings.Contains(strings.Join(f.cmds, " "), "UID FETCH 3") {
t.Error("a skipped UID must not be fetched again")
}
}
func TestFetchSinceRequiresConfig(t *testing.T) {
if _, err := (FetchSince{}).Run("secret"); err == nil {
t.Fatal("an unconfigured mailbox must not be read")
}
}
// Timeout zero disables the dial timeout AND every socket deadline, so a dead
// server parks the poller forever with his credential live in a TLS state.
func TestFetchSinceRejectsZeroTimeout(t *testing.T) {
fs := FetchSince{Addr: "mail.example:993", User: "kami", Mailbox: "INBOX"}
if _, err := fs.Run("secret"); err == nil {
t.Fatal("a zero timeout must be rejected like an empty address")
}
}
// dialer wires a client Conn to an in-process fake over net.Pipe.
func dialer(t *testing.T, f *fakeIMAP) func(string, time.Duration) (*Conn, error) {
t.Helper()
return func(addr string, timeout time.Duration) (*Conn, error) {
cli, srv := net.Pipe()
go f.serve(t, srv)
return NewConn(cli, timeout)
}
}
// One message that cannot be read must not hide the older ones behind it. The
// old code returned on the first failure, so an oversized or unreadable UID
// blocked every message below it on every poll, forever.
func TestFetchSinceContinuesPastABadMessage(t *testing.T) {
mk := func(subject string) string {
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
}
f := &fakeIMAP{
uids: []uint32{1, 2, 3},
msgs: map[uint32]string{1: mk("one"), 3: mk("three")},
quoted: map[uint32]bool{2: true},
}
fs := FetchSince{
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
Timeout: 5 * time.Second,
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
}
msgs, err := fs.WithDial(dialer(t, f)).Run("secret")
if err == nil {
t.Fatal("the unreadable UID must still be reported")
}
if !strings.Contains(err.Error(), "uid 2") {
t.Errorf("error should name the UID: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("got %d messages, want the two readable ones: %+v", len(msgs), msgs)
}
if msgs[0].Subject != "three" || msgs[1].Subject != "one" {
t.Errorf("subjects = %q,%q, want three,one", msgs[0].Subject, msgs[1].Subject)
}
}
// An oversized message is retired as bulk rather than retried: it will be the
// same size next poll, and the poller marks bulk seen without a model call.
func TestFetchSinceRetiresOversizedMessage(t *testing.T) {
f := &fakeIMAP{uids: []uint32{7}, oversize: map[uint32]int{7: MaxMessageBytes + 1}}
fs := FetchSince{
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
Timeout: 5 * time.Second,
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
}
msgs, err := fs.WithDial(dialer(t, f)).Run("secret")
if err != nil {
t.Fatalf("run: %v", err)
}
if len(msgs) != 1 || !msgs[0].Junk || msgs[0].JunkReason != "oversize" {
t.Fatalf("want one oversize-bulk message, got %+v", msgs)
}
if msgs[0].Body != "" || msgs[0].Subject != "" {
t.Error("nothing from an oversized message may be kept")
}
}