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
+110 -12
View File
@@ -21,6 +21,7 @@ type Mailer interface {
FetchHeadersRange(folder string, since, before uint32, limit int) ([]mail.Header, error)
FetchFull(folder string, uid uint32) (mail.Message, error)
Search(folder string, sc mail.SearchCriteria, limit int) ([]mail.Header, error)
ListFolders() ([]mail.FolderInfo, error)
Logout() error
}
@@ -50,18 +51,18 @@ func (d Deps) audit(account, action, target, result, reason string) {
})
}
// setup loads the account, builds the inbound rule, dials IMAP, and selects the
// folder (establishing the baseline). Returns a cleanup func.
func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule, Mailer, uint32, func(), *Envelope) {
// connect loads the account, builds the inbound rule, and dials IMAP — no
// folder selection and no baseline side effects.
func (d Deps) connect(account string) (store.Account, policy.InboundRule, Mailer, *Envelope) {
acc, err := d.Store.GetAccount(account)
if err != nil {
e := Failure(CodeNotFound, "account not found: "+account)
return acc, policy.InboundRule{}, nil, 0, nil, &e
return acc, policy.InboundRule{}, nil, &e
}
re, err := policy.CompileSubject(acc.SubjectRegex)
if err != nil {
e := Failure(CodeConfig, "invalid subject_regex: "+err.Error())
return acc, policy.InboundRule{}, nil, 0, nil, &e
return acc, policy.InboundRule{}, nil, &e
}
wlIn, _ := d.Store.ListWhitelist(account, store.DirIn)
rule := policy.InboundRule{
@@ -72,7 +73,17 @@ func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule,
m, err := d.Dial(acc)
if err != nil {
e := Failure(CodeNetwork, "imap connect failed: "+err.Error())
return acc, rule, nil, 0, nil, &e
return acc, rule, nil, &e
}
return acc, rule, m, nil
}
// setup loads the account, builds the inbound rule, dials IMAP, and selects the
// folder (establishing the baseline). Returns a cleanup func.
func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule, Mailer, uint32, func(), *Envelope) {
acc, rule, m, fail := d.connect(account)
if fail != nil {
return acc, rule, nil, 0, nil, fail
}
uidv, maxUID, err := m.SelectFolder(folder)
if err != nil {
@@ -184,6 +195,41 @@ func GetCmd(d Deps, account, folder string, uid uint32) error {
}))
}
// Empty-search hints. These MUST stay constant per command variant: the same
// text is emitted whether matching mail is absent, in another folder, or
// hidden by inbound policy, so an empty result reveals nothing about
// filtering (see the invisibility invariant in the skill docs).
const (
searchEmptyHint = "no matches in this folder; mail may be in another mailbox — " +
"run 'folders' to list mailboxes or retry with --all-folders; " +
"note the account's inbound policy may hide some messages " +
"(hidden and non-existent messages are indistinguishable)"
searchAllEmptyHint = "no matches in any folder; try adjusting criteria — " +
"--subject-contains is more reliable than --text on some servers; " +
"note the account's inbound policy may hide some messages " +
"(hidden and non-existent messages are indistinguishable)"
)
// appendVisible appends policy-visible headers to out, up to limit total
// visible results (0 = uncapped). A non-empty folder tags each message with
// its mailbox (needed when sweeping: UIDs are only unique per folder).
func appendVisible(out []map[string]any, rule policy.InboundRule, headers []mail.Header, limit int, folder string) []map[string]any {
for _, h := range headers {
if !rule.Allows(h.From, h.Subject) {
continue
}
m := headerMap(h)
if folder != "" {
m["folder"] = folder
}
out = append(out, m)
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int) error {
_, rule, m, _, done, fail := d.setup(account, folder)
if fail != nil {
@@ -194,18 +240,70 @@ func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
out := make([]map[string]any, 0, len(headers))
for _, h := range headers {
if !rule.Allows(h.From, h.Subject) {
out := appendVisible(make([]map[string]any, 0, len(headers)), rule, headers, limit, "")
d.audit(account, "search", folder, "allowed", "")
data := map[string]any{"messages": out}
if len(out) == 0 {
data["hint"] = searchEmptyHint
}
return d.emit(Success(data))
}
// SearchAllCmd sweeps every selectable folder. It deliberately skips
// EnsureFolderBaseline: baselining folders the agent never listed would mark
// their existing mail "not new" as a side effect of a read command.
func SearchAllCmd(d Deps, account string, sc mail.SearchCriteria, limit int) error {
_, rule, m, fail := d.connect(account)
if fail != nil {
return d.emit(*fail)
}
defer m.Logout()
folders, err := m.ListFolders()
if err != nil {
return d.emit(Failure(CodeNetwork, "list folders failed: "+err.Error()))
}
out := make([]map[string]any, 0)
skipped := make([]string, 0)
for _, f := range folders {
if !f.Selectable {
continue
}
out = append(out, headerMap(h))
if limit > 0 && len(out) >= limit {
break
}
headers, err := m.Search(f.Name, sc, 0)
if err != nil {
skipped = append(skipped, f.Name)
continue
}
out = appendVisible(out, rule, headers, limit, f.Name)
}
d.audit(account, "search", folder, "allowed", "")
return d.emit(Success(map[string]any{"messages": out}))
d.audit(account, "search", "*", "allowed", "")
data := map[string]any{"messages": out, "skipped_folders": skipped}
if len(out) == 0 {
data["hint"] = searchAllEmptyHint
}
return d.emit(Success(data))
}
func FoldersCmd(d Deps, account string) error {
_, _, m, fail := d.connect(account)
if fail != nil {
return d.emit(*fail)
}
defer m.Logout()
folders, err := m.ListFolders()
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
out := make([]map[string]any, 0, len(folders))
for _, f := range folders {
out = append(out, map[string]any{
"name": f.Name, "delimiter": f.Delimiter, "selectable": f.Selectable,
})
}
d.audit(account, "folders", "", "allowed", "")
return d.emit(Success(map[string]any{"folders": out}))
}
func AckCmd(d Deps, account, folder string, uids []uint32) error {