d023df1b4a
Fixes from testing email search (docs/enhancements-2026-07-07.md): - New `folders` agent command lists the account's mailboxes (name, delimiter, selectable), INBOX first, so agents can discover archived mail outside INBOX. - `search --all-folders` sweeps every selectable mailbox; each hit carries a `folder` field, `skipped_folders` reports mailboxes the server refused, and --limit caps visible results across the sweep. The sweep deliberately skips EnsureFolderBaseline so a read-only search never mutates list --new state. - Empty search results include a generic `data.hint` with next steps. The hint is a fixed constant per mode, so the invisibility invariant holds: absent and policy-filtered mail produce byte-identical envelopes (codified in TestSearchEmptyHintIndistinguishableFromFiltered). - Skill and user docs: document `--text` full-text search as best-effort (server-dependent); recommend --subject-contains/--from. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
473 lines
15 KiB
Go
473 lines
15 KiB
Go
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())
|
|
}
|
|
}
|