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
+39
View File
@@ -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 {