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
+54 -1
View File
@@ -165,6 +165,8 @@ func Run(args []string, out, errOut io.Writer) int {
switch normalizeVerb(cmd) {
case "list", "get", "search", "ack":
return runAgent(normalizeVerb(cmd), rest, role, out, errOut)
case "folders":
return runFolders(rest, role, out, errOut)
case "send":
return runSend(rest, role, out, errOut)
case "account":
@@ -185,6 +187,37 @@ func Run(args []string, out, errOut io.Writer) int {
}
}
// runFolders handles `folders --account <name>` — list the account's mailboxes.
func runFolders(args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet("folders", flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, "folders", errOut)
account := fs.String("account", "", "account name")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
if *account == "" {
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
st, err := openStore(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
}
defer st.Close()
_, _ = st.PurgeAudit(time.Now())
d := newDepsLive(st, out)
if err := FoldersCmd(d, *account); err != nil {
return 1
}
return 0
}
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
@@ -203,6 +236,10 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
sinceDate := fs.String("since-date", "", "search: RFC3339 date lower bound")
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
allFolders := new(bool)
if cmd == "search" {
allFolders = fs.Bool("all-folders", false, "search: sweep all mailboxes")
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0 // usage already printed to stderr; help isn't an error
@@ -217,6 +254,18 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
if *allFolders {
folderSet := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "folder" {
folderSet = true
}
})
if folderSet {
_ = Failure(CodeUsage, "--folder and --all-folders are mutually exclusive").Write(out)
return 2
}
}
st, err := openStore(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
@@ -251,7 +300,11 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
sc.Before = tm
}
}
if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil {
if *allFolders {
if err := SearchAllCmd(d, *account, sc, *limit); err != nil {
return 1
}
} else if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil {
return 1
}
case "ack":