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") } }