package cli import ( "bytes" "encoding/json" "path/filepath" "testing" "time" "git.dcglab.co.uk/steve/emcli/internal/mail" "git.dcglab.co.uk/steve/emcli/internal/store" ) type fakeMailer struct { uidValidity uint32 maxUID uint32 headers []mail.Header full map[uint32]mail.Message // Multi-folder fields, used by folders/--all-folders tests. When // headersByFolder is non-nil Search consults it instead of headers. folders []mail.FolderInfo headersByFolder map[string][]mail.Header searchErr map[string]error searched []string } func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) { return f.uidValidity, f.maxUID, nil } func (f *fakeMailer) FetchHeaders(_ string, uids []uint32) ([]mail.Header, error) { if len(uids) == 0 { return f.headers, nil } want := map[uint32]bool{} for _, u := range uids { want[u] = true } var out []mail.Header for _, h := range f.headers { if want[h.UID] { out = append(out, h) } } return out, nil } func (f *fakeMailer) FetchHeadersRange(string, uint32, uint32, int) ([]mail.Header, error) { return f.headers, nil } func (f *fakeMailer) FetchFull(_ string, uid uint32) (mail.Message, error) { return f.full[uid], nil } func (f *fakeMailer) Search(folder string, _ mail.SearchCriteria, _ int) ([]mail.Header, error) { f.searched = append(f.searched, folder) if err := f.searchErr[folder]; err != nil { return nil, err } if f.headersByFolder != nil { return f.headersByFolder[folder], nil } return f.headers, nil } func (f *fakeMailer) ListFolders() ([]mail.FolderInfo, error) { return f.folders, nil } func (f *fakeMailer) Logout() error { return nil } func testKey() []byte { k := make([]byte, 32) for i := range k { k[i] = byte(i) } return k } func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatalf("store: %v", err) } if err := st.InitKeys(testKey(), testKey()); err != nil { t.Fatalf("InitKeys: %v", err) } t.Cleanup(func() { st.Close() }) _, err = st.AddAccount(store.Account{ Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", AuthType: "password", Username: "me@example.com", Password: "pw", WhitelistInEnabled: true, }) if err != nil { t.Fatalf("AddAccount: %v", err) } _ = st.AddWhitelist("work", store.DirIn, "@trusted.com") var buf bytes.Buffer d := Deps{ Store: st, Dial: func(store.Account) (Mailer, error) { return fm, nil }, Now: func() time.Time { return time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC) }, Out: &buf, } return d, &buf } func decode(t *testing.T, b []byte) map[string]any { t.Helper() var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatalf("unmarshal %q: %v", b, err) } return m } func TestListNewFiltersBySenderAndState(t *testing.T) { fm := &fakeMailer{ uidValidity: 1, maxUID: 100, headers: []mail.Header{ {UID: 101, From: "bob@trusted.com", Subject: "hi"}, {UID: 102, From: "eve@evil.com", Subject: "spam"}, // filtered out by whitelist {UID: 103, From: "ann@trusted.com", Subject: "yo"}, }, } d, buf := newDeps(t, fm) if err := ListCmd(d, "work", "INBOX", true, 0, 0, 50); err != nil { t.Fatalf("ListCmd: %v", err) } res := decode(t, buf.Bytes()) if res["error"] != false { t.Fatalf("unexpected error: %v", res) } data := res["data"].(map[string]any) msgs := data["messages"].([]any) if len(msgs) != 2 { // 101 and 103; 102 filtered t.Fatalf("want 2 messages, got %d: %v", len(msgs), msgs) } } func TestGetFilteredReturnsNotFound(t *testing.T) { fm := &fakeMailer{ uidValidity: 1, maxUID: 100, headers: []mail.Header{{UID: 102, From: "eve@evil.com", Subject: "spam"}}, full: map[uint32]mail.Message{ 102: {Header: mail.Header{UID: 102, From: "eve@evil.com", Subject: "spam"}, BodyText: "secret"}, }, } d, buf := newDeps(t, fm) _ = GetCmd(d, "work", "INBOX", 102) res := decode(t, buf.Bytes()) if res["error"] != true { t.Fatal("filtered get must return error envelope") } ed := res["error_detail"].(map[string]any) if ed["code"] != "not_found" { t.Fatalf("want not_found, got %v", ed["code"]) } } func TestGetFilteredReturnsErrorForExit(t *testing.T) { fm := &fakeMailer{ uidValidity: 1, maxUID: 100, headers: []mail.Header{{UID: 102, From: "eve@evil.com", Subject: "spam"}}, full: map[uint32]mail.Message{ 102: {Header: mail.Header{UID: 102, From: "eve@evil.com", Subject: "spam"}, BodyText: "secret"}, }, } d, buf := newDeps(t, fm) err := GetCmd(d, "work", "INBOX", 102) if err == nil { t.Fatal("GetCmd on filtered uid must return non-nil error so caller can exit non-zero") } res := decode(t, buf.Bytes()) if res["error"] != true { t.Fatalf("envelope must report error=true, got %v", res["error"]) } } func TestSearchLimitCountsVisibleOnly(t *testing.T) { // fakeMailer.Search returns all headers regardless of the limit passed in. // Headers: UIDs 1,3,5 are visible (@trusted.com); UIDs 2,4 are filtered. // With limit=2, SearchCmd must return 2 visible messages — not fewer, which // would happen if the mail layer truncated to limit=2 before filtering. fm := &fakeMailer{ uidValidity: 1, maxUID: 5, headers: []mail.Header{ {UID: 1, From: "a@trusted.com", Subject: "one"}, {UID: 2, From: "x@evil.com", Subject: "spam1"}, // filtered {UID: 3, From: "b@trusted.com", Subject: "two"}, {UID: 4, From: "y@evil.com", Subject: "spam2"}, // filtered {UID: 5, From: "c@trusted.com", Subject: "three"}, }, } d, buf := newDeps(t, fm) if err := SearchCmd(d, "work", "INBOX", mail.SearchCriteria{}, 2); err != nil { t.Fatalf("SearchCmd: %v", err) } res := decode(t, buf.Bytes()) if res["error"] != false { t.Fatalf("unexpected error envelope: %v", res) } data := res["data"].(map[string]any) msgs := data["messages"].([]any) // With pre-filter cap (old bug): limit=2 would have fetched UIDs 1,2 then // filtered, yielding only 1 visible. Correct behaviour: 2 visible (1,3). if len(msgs) != 2 { t.Fatalf("want 2 visible messages, got %d: %v", len(msgs), msgs) } } func TestAckAdvancesStateAndFiltered(t *testing.T) { fm := &fakeMailer{ uidValidity: 1, maxUID: 100, headers: []mail.Header{ {UID: 101, From: "bob@trusted.com", Subject: "hi"}, {UID: 102, From: "eve@evil.com", Subject: "spam"}, }, } d, buf := newDeps(t, fm) // Acking a filtered uid (102) must be rejected as not-found. _ = AckCmd(d, "work", "INBOX", []uint32{102}) if decode(t, buf.Bytes())["error"] != true { t.Fatal("acking filtered uid must fail") } // Acking a visible uid (101) succeeds and removes it from list --new. buf.Reset() if err := AckCmd(d, "work", "INBOX", []uint32{101}); err != nil { t.Fatalf("AckCmd 101: %v", err) } if decode(t, buf.Bytes())["error"] != false { t.Fatal("ack of visible uid should succeed") } buf.Reset() _ = ListCmd(d, "work", "INBOX", true, 0, 0, 50) data := decode(t, buf.Bytes())["data"].(map[string]any) msgs := data["messages"].([]any) if len(msgs) != 0 { // 101 acked, 102 filtered t.Fatalf("want 0 new messages, got %d", len(msgs)) } } func TestFoldersListsMailboxes(t *testing.T) { fm := &fakeMailer{ folders: []mail.FolderInfo{ {Name: "INBOX", Delimiter: ".", Selectable: true}, {Name: "INBOX.Archive", Delimiter: ".", Selectable: true}, {Name: "Public", Delimiter: ".", Selectable: false}, }, } d, buf := newDeps(t, fm) if err := FoldersCmd(d, "work"); err != nil { t.Fatalf("FoldersCmd: %v", err) } res := decode(t, buf.Bytes()) if res["error"] != false { t.Fatalf("unexpected error envelope: %v", res) } data := res["data"].(map[string]any) folders := data["folders"].([]any) if len(folders) != 3 { t.Fatalf("want 3 folders, got %d: %v", len(folders), folders) } first := folders[0].(map[string]any) if first["name"] != "INBOX" || first["delimiter"] != "." || first["selectable"] != true { t.Fatalf("unexpected first folder: %v", first) } last := folders[2].(map[string]any) if last["name"] != "Public" || last["selectable"] != false { t.Fatalf("unexpected last folder: %v", last) } } func TestSearchAllFoldersTagsFolderAndFilters(t *testing.T) { fm := &fakeMailer{ folders: []mail.FolderInfo{ {Name: "INBOX", Delimiter: ".", Selectable: true}, {Name: "INBOX.Archive", Delimiter: ".", Selectable: true}, }, headersByFolder: map[string][]mail.Header{ "INBOX": { {UID: 1, From: "a@trusted.com", Subject: "one"}, {UID: 2, From: "x@evil.com", Subject: "spam"}, // filtered }, "INBOX.Archive": { {UID: 7, From: "b@trusted.com", Subject: "two"}, }, }, } d, buf := newDeps(t, fm) if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchAllCmd: %v", err) } res := decode(t, buf.Bytes()) if res["error"] != false { t.Fatalf("unexpected error envelope: %v", res) } data := res["data"].(map[string]any) msgs := data["messages"].([]any) if len(msgs) != 2 { t.Fatalf("want 2 visible messages, got %d: %v", len(msgs), msgs) } m0 := msgs[0].(map[string]any) m1 := msgs[1].(map[string]any) if m0["folder"] != "INBOX" || m1["folder"] != "INBOX.Archive" { t.Fatalf("wrong folder tags: %v / %v", m0["folder"], m1["folder"]) } skipped := data["skipped_folders"].([]any) if len(skipped) != 0 { t.Fatalf("want no skipped folders, got %v", skipped) } } func TestSearchAllFoldersSkipsNoselectAndErrors(t *testing.T) { fm := &fakeMailer{ folders: []mail.FolderInfo{ {Name: "INBOX", Delimiter: ".", Selectable: true}, {Name: "Broken", Delimiter: ".", Selectable: true}, {Name: "Public", Delimiter: ".", Selectable: false}, }, headersByFolder: map[string][]mail.Header{ "INBOX": {{UID: 1, From: "a@trusted.com", Subject: "one"}}, }, searchErr: map[string]error{"Broken": errCommandFailed}, } d, buf := newDeps(t, fm) if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchAllCmd: %v", err) } res := decode(t, buf.Bytes()) if res["error"] != false { t.Fatalf("unexpected error envelope: %v", res) } for _, f := range fm.searched { if f == "Public" { t.Fatal("searched a \\Noselect folder") } } data := res["data"].(map[string]any) if got := len(data["messages"].([]any)); got != 1 { t.Fatalf("want 1 message, got %d", got) } skipped := data["skipped_folders"].([]any) if len(skipped) != 1 || skipped[0] != "Broken" { t.Fatalf("want skipped_folders [Broken], got %v", skipped) } } func TestSearchAllFoldersLimitCountsVisibleAcrossFolders(t *testing.T) { fm := &fakeMailer{ folders: []mail.FolderInfo{ {Name: "INBOX", Delimiter: ".", Selectable: true}, {Name: "INBOX.Archive", Delimiter: ".", Selectable: true}, {Name: "INBOX.Sent", Delimiter: ".", Selectable: true}, }, headersByFolder: map[string][]mail.Header{ "INBOX": { {UID: 1, From: "a@trusted.com", Subject: "one"}, {UID: 2, From: "x@evil.com", Subject: "spam"}, // filtered }, "INBOX.Archive": { {UID: 3, From: "y@evil.com", Subject: "spam"}, // filtered {UID: 4, From: "b@trusted.com", Subject: "two"}, }, "INBOX.Sent": { {UID: 5, From: "c@trusted.com", Subject: "three"}, }, }, } d, buf := newDeps(t, fm) if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 2); err != nil { t.Fatalf("SearchAllCmd: %v", err) } res := decode(t, buf.Bytes()) data := res["data"].(map[string]any) msgs := data["messages"].([]any) if len(msgs) != 2 { t.Fatalf("want exactly 2 visible messages, got %d: %v", len(msgs), msgs) } for _, f := range fm.searched { if f == "INBOX.Sent" { t.Fatal("searched a folder after the visible limit was reached") } } } func TestSearchAllFoldersDoesNotBaseline(t *testing.T) { fm := &fakeMailer{ folders: []mail.FolderInfo{ {Name: "INBOX.Archive", Delimiter: ".", Selectable: true}, }, headersByFolder: map[string][]mail.Header{ "INBOX.Archive": {{UID: 7, From: "b@trusted.com", Subject: "two"}}, }, } d, _ := newDeps(t, fm) if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchAllCmd: %v", err) } // IsNew errors (no folder_state row) iff the sweep did not baseline. if _, err := d.Store.IsNew("work", "INBOX.Archive", 7); err == nil { t.Fatal("sweep must not create a folder_state baseline for swept folders") } } func TestSearchEmptyEmitsHint(t *testing.T) { fm := &fakeMailer{uidValidity: 1, maxUID: 5} d, buf := newDeps(t, fm) if err := SearchCmd(d, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchCmd: %v", err) } data := decode(t, buf.Bytes())["data"].(map[string]any) if hint, ok := data["hint"].(string); !ok || hint == "" { t.Fatalf("want non-empty hint on empty result, got %v", data["hint"]) } // Non-empty results must omit the hint. fm2 := &fakeMailer{ uidValidity: 1, maxUID: 5, headers: []mail.Header{{UID: 1, From: "a@trusted.com", Subject: "one"}}, } d2, buf2 := newDeps(t, fm2) if err := SearchCmd(d2, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchCmd: %v", err) } data2 := decode(t, buf2.Bytes())["data"].(map[string]any) if _, ok := data2["hint"]; ok { t.Fatalf("hint must be absent when messages exist, got %v", data2["hint"]) } } func TestSearchAllFoldersEmptyEmitsHint(t *testing.T) { fm := &fakeMailer{ folders: []mail.FolderInfo{{Name: "INBOX", Delimiter: ".", Selectable: true}}, headersByFolder: map[string][]mail.Header{}, } d, buf := newDeps(t, fm) if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchAllCmd: %v", err) } data := decode(t, buf.Bytes())["data"].(map[string]any) if hint, ok := data["hint"].(string); !ok || hint == "" { t.Fatalf("want non-empty hint on empty sweep, got %v", data["hint"]) } } // TestSearchEmptyHintIndistinguishableFromFiltered codifies the invisibility // invariant for the empty-search hint: the envelope for "no mail at all" must // be byte-identical to the envelope for "mail exists but is all filtered", so // an empty result reveals nothing about inbound policy. Do not weaken this to // a structural comparison. func TestSearchEmptyHintIndistinguishableFromFiltered(t *testing.T) { absent := &fakeMailer{uidValidity: 1, maxUID: 5} dAbsent, bufAbsent := newDeps(t, absent) if err := SearchCmd(dAbsent, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchCmd (absent): %v", err) } filtered := &fakeMailer{ uidValidity: 1, maxUID: 5, headers: []mail.Header{ {UID: 1, From: "x@evil.com", Subject: "spam1"}, {UID: 2, From: "y@evil.com", Subject: "spam2"}, }, } dFiltered, bufFiltered := newDeps(t, filtered) if err := SearchCmd(dFiltered, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil { t.Fatalf("SearchCmd (filtered): %v", err) } if !bytes.Equal(bufAbsent.Bytes(), bufFiltered.Bytes()) { t.Fatalf("empty-vs-filtered envelopes differ:\nabsent: %s\nfiltered: %s", bufAbsent.Bytes(), bufFiltered.Bytes()) } }