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:
+110
-12
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
} {
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user