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:
@@ -52,6 +52,45 @@ func Dial(cfg IMAPConfig) (*Client, error) {
|
||||
|
||||
func (c *Client) Logout() error { return c.c.Logout() }
|
||||
|
||||
// FolderInfo describes one mailbox as reported by the server. Selectable is
|
||||
// false for hierarchy-only entries (\Noselect) that cannot be passed to SELECT.
|
||||
type FolderInfo struct {
|
||||
Name string
|
||||
Delimiter string
|
||||
Selectable bool
|
||||
}
|
||||
|
||||
// ListFolders enumerates all mailboxes, INBOX first, then case-insensitive
|
||||
// name order.
|
||||
func (c *Client) ListFolders() ([]FolderInfo, error) {
|
||||
infoCh := make(chan *imap.MailboxInfo, 16)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.c.List("", "*", infoCh) }()
|
||||
|
||||
var out []FolderInfo
|
||||
for info := range infoCh {
|
||||
selectable := true
|
||||
for _, attr := range info.Attributes {
|
||||
if strings.EqualFold(attr, imap.NoSelectAttr) {
|
||||
selectable = false
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, FolderInfo{Name: info.Name, Delimiter: info.Delimiter, Selectable: selectable})
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ii, ji := out[i].Name == "INBOX", out[j].Name == "INBOX"
|
||||
if ii != ji {
|
||||
return ii
|
||||
}
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) SelectFolder(folder string) (uint32, uint32, error) {
|
||||
mbox, err := c.c.Select(folder, true) // read-only select
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,25 @@ func TestSelectAndFetch(t *testing.T) {
|
||||
t.Logf("inbox has %d messages, maxUID=%d", len(headers), maxUID)
|
||||
}
|
||||
|
||||
func TestListFolders(t *testing.T) {
|
||||
c, err := Dial(testCfg(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer c.Logout()
|
||||
|
||||
folders, err := c.ListFolders()
|
||||
if err != nil {
|
||||
t.Fatalf("ListFolders: %v", err)
|
||||
}
|
||||
if len(folders) == 0 {
|
||||
t.Fatal("expected at least one folder")
|
||||
}
|
||||
if folders[0].Name != "INBOX" || !folders[0].Selectable {
|
||||
t.Fatalf("expected selectable INBOX first, got %+v", folders[0])
|
||||
}
|
||||
}
|
||||
|
||||
func atoiEnv(t *testing.T, k string) int {
|
||||
t.Helper()
|
||||
n, err := strconv.Atoi(os.Getenv(k))
|
||||
|
||||
Reference in New Issue
Block a user