feat(cli): folders command, search --all-folders, empty-search hint

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>
This commit is contained in:
2026-07-07 14:28:38 +01:00
parent 4c0c6b94db
commit d023df1b4a
14 changed files with 645 additions and 34 deletions
+252 -1
View File
@@ -16,6 +16,13 @@ type fakeMailer struct {
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) {
@@ -43,9 +50,19 @@ func (f *fakeMailer) FetchHeadersRange(string, uint32, uint32, int) ([]mail.Head
func (f *fakeMailer) FetchFull(_ string, uid uint32) (mail.Message, error) {
return f.full[uid], nil
}
func (f *fakeMailer) Search(string, mail.SearchCriteria, int) ([]mail.Header, error) {
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 {
@@ -219,3 +236,237 @@ func TestAckAdvancesStateAndFiltered(t *testing.T) {
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())
}
}