package signals import ( "context" "errors" "fmt" "net" "sync" "testing" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/backend/memory" imapserver "github.com/emersion/go-imap/server" ) // validityBackend wraps the in-memory backend so tests can force the // UIDVALIDITY the server reports (the stock memory backend hardcodes 1) and // capture the SEARCH criteria every pass issues (review CR-15/CR-19 // verification). type validityBackend struct { backend.Backend validity uint32 mu sync.Mutex criteria []*imap.SearchCriteria // failFetchAfter makes the fixture fail ListMessages calls after the // given count, simulating a mailbox that dies partway through a pass. failFetchAfter int fetches int } func (b *validityBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) { u, e := b.Backend.Login(connInfo, username, password) if e != nil { return nil, e } return &validityUser{User: u, be: b}, nil } func (b *validityBackend) captured() []*imap.SearchCriteria { b.mu.Lock() defer b.mu.Unlock() return append([]*imap.SearchCriteria(nil), b.criteria...) } type validityUser struct { backend.User be *validityBackend } func (u *validityUser) GetMailbox(name string) (backend.Mailbox, error) { m, e := u.User.GetMailbox(name) if e != nil { return nil, e } return &validityMailbox{Mailbox: m, be: u.be}, nil } type validityMailbox struct { backend.Mailbox be *validityBackend } func (m *validityMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { st, e := m.Mailbox.Status(items) if e == nil && m.be.validity != 0 { st.UidValidity = m.be.validity } return st, e } func (m *validityMailbox) SearchMessages(uid bool, c *imap.SearchCriteria) ([]uint32, error) { if uid { m.be.mu.Lock() m.be.criteria = append(m.be.criteria, c) m.be.mu.Unlock() } return m.Mailbox.SearchMessages(uid, c) } func (m *validityMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { m.be.mu.Lock() m.be.fetches++ fail := m.be.failFetchAfter > 0 && m.be.fetches > m.be.failFetchAfter m.be.mu.Unlock() if fail { close(ch) return errors.New("fixture fetch failure") } return m.Mailbox.ListMessages(uid, seqset, items, ch) } // newTestMailbox stands up the in-memory IMAP fixture the integration suite // uses, reseeded with n messages in INBOX (the backend ships one). The server // enforces real protocol semantics, so SearchConnFrom exercises // UidSearch/UidFetch against genuine UIDVALIDITY/UIDNEXT responses. func newTestMailbox(t *testing.T, n int) (Mailbox, func()) { m, _, cleanup := newTestMailboxValidity(t, n, 0) return m, cleanup } // newTestMailboxValidity is newTestMailbox with a forced UIDVALIDITY (0 keeps // the backend default) and access to the criteria capture. func newTestMailboxValidity(t *testing.T, n int, validity uint32) (Mailbox, *validityBackend, func()) { t.Helper() mb := memory.New() // Reach the fixture's own user/mailbox through a refused login, which is // how the backend exposes them. _, err := mb.Login(nil, "username", "bad") if err == nil { t.Fatal("fixture login unexpectedly succeeded") } user, err := mb.Login(nil, "username", "password") if err != nil { t.Fatal(err) } mbox, err := user.GetMailbox("INBOX") if err != nil { t.Fatal(err) } memMbox := mbox.(*memory.Mailbox) memMbox.Messages = memMbox.Messages[:0] for i := 0; i < n; i++ { body := "From: sender@computer.test\r\n" + "Subject: subject " + fmt.Sprint(i) + "\r\n" + "Message-Id: \r\n" + "\r\n" + "body" memMbox.Messages = append(memMbox.Messages, &memory.Message{ Uid: uint32(i + 1), Date: time.Now(), Size: uint32(len(body)), Body: []byte(body), }) } listener, e := net.Listen("tcp", "127.0.0.1:0") if e != nil { t.Fatal(e) } be := &validityBackend{Backend: mb, validity: validity} mail := imapserver.New(be) mail.AllowInsecureAuth = true go mail.Serve(listener) host, port, _ := net.SplitHostPort(listener.Addr().String()) var p int fmt.Sscanf(port, "%d", &p) m := Mailbox{Host: host, Username: "username", Password: "password", Port: p, TLS: false, Folder: "INBOX"} t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1") return m, be, func() { mail.Close() } } // TestTruncatedPassKeepsRemainingMatches pins review COR-1: when the // per-pass cap truncates the matched UID list, the checkpoint must advance // only to the last fetched UID — never to the mailbox high-water mark — or // matches past the cap are dropped forever. func TestTruncatedPassKeepsRemainingMatches(t *testing.T) { // Connect dials loopback through netguard; the fixture flag unblocks it // (same escape hatch the integration harness uses). t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") cap := searchBatch * maxIngestBatches m, cleanup := newTestMailbox(t, cap+7) defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() c, e := Connect(ctx, m) if e != nil { t.Fatal(e) } defer c.Logout() events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{}) if e != nil { t.Fatal(e) } if len(events) != cap { t.Fatalf("fetched %d events, want %d (per-pass cap)", len(events), cap) } if next.LastUID != uint32(cap) { t.Fatalf("checkpoint advanced to %d, want %d (high-water shortcut must stay off after truncation)", next.LastUID, cap) } // A follow-up pass from the new checkpoint picks up every remaining match. rest, final, e := SearchConnFrom(c, m, "", "subject", next) if e != nil { t.Fatal(e) } if len(rest) != 7 { t.Fatalf("second pass fetched %d events, want 7", len(rest)) } if final.LastUID != uint32(cap+7) { t.Fatalf("final checkpoint %d, want %d", final.LastUID, cap+7) } } // Review follow-up: a mailbox that fails after an earlier batch succeeded // still returns the events already fetched. The caller persists them but must // not advance the checkpoint past the failed batch. func TestPartialFetchFailureReturnsEarlierEvents(t *testing.T) { t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") m, be, cleanup := newTestMailboxValidity(t, searchBatch*2+5, 0) defer cleanup() be.mu.Lock() be.failFetchAfter = 1 be.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() c, e := Connect(ctx, m) if e != nil { t.Fatal(e) } defer c.Logout() events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{}) if e == nil { t.Fatal("fixture fetch failure was not reported") } if len(events) != searchBatch { t.Fatalf("partial pass returned %d events, want the first batch (%d)", len(events), searchBatch) } if next.LastUID != searchBatch { t.Fatalf("partial checkpoint %d, want %d (never past the failed batch)", next.LastUID, searchBatch) } } func TestCheckpointHighWaterShortcutAppliesWhenComplete(t *testing.T) { t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") // When the SEARCH considered every message (no truncation), the // checkpoint may advance to the mailbox high-water mark. m, cleanup := newTestMailbox(t, searchBatch+1) defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() c, e := Connect(ctx, m) if e != nil { t.Fatal(e) } defer c.Logout() events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{}) if e != nil { t.Fatal(e) } if len(events) != searchBatch+1 { t.Fatalf("fetched %d events, want %d", len(events), searchBatch+1) } if next.LastUID != uint32(searchBatch+1) { t.Fatalf("checkpoint %d, want %d", next.LastUID, searchBatch+1) } } // TestUIDValidityResetReingests pins review CR-15 with a fixture-backed // ingestion pass: when UIDVALIDITY changes, the new UID space is scanned from // zero — even when every new UID sits BELOW the old checkpoint's LastUID. // The old behavior filtered against the stale high-water mark and skipped the // whole new space while advancing the checkpoint past it. func TestUIDValidityResetReingests(t *testing.T) { t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") // New UID space: UIDs 1..20 under UIDVALIDITY 8. m, _, cleanup := newTestMailboxValidity(t, 20, 8) defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() c, e := Connect(ctx, m) if e != nil { t.Fatal(e) } defer c.Logout() // A checkpoint from the PREVIOUS UID space whose LastUID (100) is above // every UID that now exists. stale := Checkpoint{UIDValidity: 7, LastUID: 100} events, next, e := SearchConnFrom(c, m, "", "subject", stale) if e != nil { t.Fatal(e) } if len(events) != 20 { t.Fatalf("reset pass ingested %d events, want all 20 of the new UID space", len(events)) } if next.UIDValidity != 8 || next.LastUID != 20 { t.Fatalf("checkpoint after reset = %+v, want {UIDValidity:8 LastUID:20}", next) } // A subsequent pass from the reset checkpoint finds nothing new and does // not regress the checkpoint. again, final, e := SearchConnFrom(c, m, "", "subject", next) if e != nil { t.Fatal(e) } if len(again) != 0 { t.Fatalf("follow-up pass ingested %d events, want 0", len(again)) } if final.UIDValidity != 8 || final.LastUID != 20 { t.Fatalf("follow-up checkpoint = %+v, want {UIDValidity:8 LastUID:20}", final) } } // TestIncrementalPassConstrainsUIDSearch pins review CR-19: once a source is // caught up, the server-side SEARCH carries a UID window derived from the // normalized checkpoint instead of re-matching the whole history. The reset // pass (CR-15) must NOT constrain — the whole new UID space is fair game. func TestIncrementalPassConstrainsUIDSearch(t *testing.T) { t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1") m, be, cleanup := newTestMailboxValidity(t, 10, 5) defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() c, e := Connect(ctx, m) if e != nil { t.Fatal(e) } defer c.Logout() // Pass 1: mismatched UIDVALIDITY forces a reset; no UID constraint. if _, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{UIDValidity: 4, LastUID: 99}); e != nil { t.Fatal(e) } else if next.LastUID != 10 { t.Fatalf("reset pass checkpoint %d, want 10", next.LastUID) } // Pass 2: caught up at UID 4 of validity 5 — only UIDs 5..10 ingest. events, next2, e := SearchConnFrom(c, m, "", "subject", Checkpoint{UIDValidity: 5, LastUID: 4}) if e != nil { t.Fatal(e) } if len(events) != 6 { t.Fatalf("incremental pass ingested %d events, want 6", len(events)) } if next2.LastUID != 10 { t.Fatalf("incremental checkpoint %d, want 10", next2.LastUID) } got := be.captured() if len(got) != 2 { t.Fatalf("captured %d UID searches, want 2", len(got)) } if got[0].Uid != nil { t.Fatalf("reset pass constrained the SEARCH to %v; it must rescan the whole UID space", got[0].Uid) } if got[1].Uid == nil { t.Fatal("incremental pass did not constrain the SEARCH by UID window") } if !got[1].Uid.Contains(5) || got[1].Uid.Contains(4) { t.Fatalf("incremental SEARCH UID window %v does not start at 5", got[1].Uid) } }