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 {
+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())
}
}
+2 -1
View File
@@ -16,7 +16,8 @@ type cmdHelp struct {
var agentCmds = []cmdHelp{
{"list", "list --account <name> [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."},
{"get", "get --account <name> [--folder F] --uid <uid>", "Fetch one full message (body + attachments)."},
{"search", "search --account <name> [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
{"search", "search --account <name> [--folder F | --all-folders] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
{"folders", "folders --account <name>", "List the account's mailboxes/folders."},
{"ack", "ack --account <name> [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."},
{"send", "send --account <name> --to A… [--cc A…] [--bcc A…] --subject S --body B [--attach P]… [--reply-to U [--folder F]]", "Send or reply (RW accounts only)."},
}
+1 -1
View File
@@ -15,7 +15,7 @@ func TestMainHelpListsAllCommands(t *testing.T) {
t.Fatalf("%v: want exit 0, got %d\n%s", args, code, text)
}
for _, want := range []string{
"Usage", "list", "get", "search", "ack", "send",
"Usage", "list", "get", "search", "ack", "send", "folders",
"account", "whitelist", "config", "audit", "doctor", "version",
"EMCLI_KEY", "EMCLI_DB",
} {
+1 -1
View File
@@ -14,7 +14,7 @@ func TestCommandRole(t *testing.T) {
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
}
agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"}, {"folders"},
{"account", "list"},
}
for _, c := range adminCmds {
+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":
+44
View File
@@ -121,3 +121,47 @@ func TestAccountLsAliasAgentRole(t *testing.T) {
t.Fatalf("account ls leaked host/username:\n%s", out)
}
}
func TestSearchAllFoldersConflictsWithFolder(t *testing.T) {
adminEnv(t)
code, out, _ := run(t, "search", "--account", "x", "--folder", "F", "--all-folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
}
func TestAllFoldersRejectedForList(t *testing.T) {
adminEnv(t)
// --all-folders is only registered for search; list must reject it at
// flag-parse time with a JSON usage envelope.
code, out, _ := run(t, "list", "--account", "x", "--all-folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
}
func TestFoldersRequiresAccount(t *testing.T) {
adminEnv(t)
code, out, _ := run(t, "folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
}
+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 {
+19
View File
@@ -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))