d023df1b4a
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>
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"time"
|
|
|
|
"git.dcglab.co.uk/steve/emcli/internal/mail"
|
|
"git.dcglab.co.uk/steve/emcli/internal/policy"
|
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
|
)
|
|
|
|
// errCommandFailed signals that a command emitted an error envelope, so the
|
|
// process should exit non-zero. The JSON envelope is the authoritative detail.
|
|
var errCommandFailed = errors.New("command reported error")
|
|
|
|
// Mailer is the subset of the IMAP client the agent commands use.
|
|
type Mailer interface {
|
|
SelectFolder(folder string) (uint32, uint32, error)
|
|
FetchHeaders(folder string, uids []uint32) ([]mail.Header, error)
|
|
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
|
|
}
|
|
|
|
type Deps struct {
|
|
Store *store.Store
|
|
Dial func(store.Account) (Mailer, error)
|
|
Send func(store.Account, mail.OutgoingMessage) error
|
|
CheckIMAP func(store.Account) error
|
|
CheckSMTP func(store.Account) error
|
|
Now func() time.Time
|
|
Out io.Writer
|
|
}
|
|
|
|
func (d Deps) emit(e Envelope) error {
|
|
if err := e.Write(d.Out); err != nil {
|
|
return err // write failure (rare) — propagate as-is
|
|
}
|
|
if e.Error {
|
|
return errCommandFailed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d Deps) audit(account, action, target, result, reason string) {
|
|
_ = d.Store.Audit(d.Now(), store.AuditEntry{
|
|
Account: account, Action: action, Target: target, Result: result, Reason: reason,
|
|
})
|
|
}
|
|
|
|
// 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, &e
|
|
}
|
|
re, err := policy.CompileSubject(acc.SubjectRegex)
|
|
if err != nil {
|
|
e := Failure(CodeConfig, "invalid subject_regex: "+err.Error())
|
|
return acc, policy.InboundRule{}, nil, &e
|
|
}
|
|
wlIn, _ := d.Store.ListWhitelist(account, store.DirIn)
|
|
rule := policy.InboundRule{
|
|
WhitelistInEnabled: acc.WhitelistInEnabled,
|
|
WhitelistIn: wlIn,
|
|
SubjectRegex: re,
|
|
}
|
|
m, err := d.Dial(acc)
|
|
if err != nil {
|
|
e := Failure(CodeNetwork, "imap connect failed: "+err.Error())
|
|
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 {
|
|
m.Logout()
|
|
e := Failure(CodeNetwork, "select folder failed: "+err.Error())
|
|
return acc, rule, nil, 0, nil, &e
|
|
}
|
|
if err := d.Store.EnsureFolderBaseline(account, folder, uidv, maxUID); err != nil {
|
|
m.Logout()
|
|
e := Failure(CodeDB, err.Error())
|
|
return acc, rule, nil, 0, nil, &e
|
|
}
|
|
return acc, rule, m, uidv, func() { m.Logout() }, nil
|
|
}
|
|
|
|
func headerMap(h mail.Header) map[string]any {
|
|
return map[string]any{
|
|
"uid": h.UID, "from": h.From, "to": h.To, "subject": h.Subject,
|
|
"date": h.Date, "message_id": h.MessageID, "has_attachments": h.HasAttachments,
|
|
}
|
|
}
|
|
|
|
func ListCmd(d Deps, account, folder string, onlyNew bool, beforeUID, sinceUID uint32, limit int) error {
|
|
_, rule, m, _, done, fail := d.setup(account, folder)
|
|
if fail != nil {
|
|
return d.emit(*fail)
|
|
}
|
|
defer done()
|
|
|
|
var headers []mail.Header
|
|
var err error
|
|
if beforeUID > 0 || sinceUID > 0 {
|
|
headers, err = m.FetchHeadersRange(folder, sinceUID, beforeUID, limit)
|
|
} else {
|
|
headers, err = m.FetchHeaders(folder, nil)
|
|
}
|
|
if err != nil {
|
|
d.audit(account, "list", folder, "blocked", "imap_error")
|
|
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) {
|
|
continue // invisible
|
|
}
|
|
if onlyNew {
|
|
isNew, err := d.Store.IsNew(account, folder, h.UID)
|
|
if err != nil {
|
|
return d.emit(Failure(CodeDB, err.Error()))
|
|
}
|
|
if !isNew {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, headerMap(h))
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
d.audit(account, "list", folder, "allowed", "")
|
|
return d.emit(Success(map[string]any{"messages": out}))
|
|
}
|
|
|
|
// visible fetches a UID's header and reports whether policy allows it.
|
|
func (d Deps) visible(m Mailer, rule policy.InboundRule, folder string, uid uint32) (bool, error) {
|
|
hs, err := m.FetchHeaders(folder, []uint32{uid})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(hs) == 0 {
|
|
return false, nil
|
|
}
|
|
return rule.Allows(hs[0].From, hs[0].Subject), nil
|
|
}
|
|
|
|
func GetCmd(d Deps, account, folder string, uid uint32) error {
|
|
_, rule, m, _, done, fail := d.setup(account, folder)
|
|
if fail != nil {
|
|
return d.emit(*fail)
|
|
}
|
|
defer done()
|
|
|
|
ok, err := d.visible(m, rule, folder, uid)
|
|
if err != nil {
|
|
return d.emit(Failure(CodeNetwork, err.Error()))
|
|
}
|
|
if !ok {
|
|
d.audit(account, "get", uitoa(uid), "blocked", "filtered")
|
|
return d.emit(Failure(CodeNotFound, "message not found"))
|
|
}
|
|
msg, err := m.FetchFull(folder, uid)
|
|
if err != nil {
|
|
return d.emit(Failure(CodeNetwork, err.Error()))
|
|
}
|
|
atts := make([]map[string]any, 0, len(msg.Attachments))
|
|
for _, a := range msg.Attachments {
|
|
atts = append(atts, map[string]any{
|
|
"name": a.Name, "size": a.Size, "mime": a.MIME,
|
|
"content_b64": b64(a.Content),
|
|
})
|
|
}
|
|
d.audit(account, "get", uitoa(uid), "allowed", "")
|
|
return d.emit(Success(map[string]any{
|
|
"uid": msg.Header.UID, "from": msg.Header.From, "to": msg.Header.To,
|
|
"subject": msg.Header.Subject, "date": msg.Header.Date,
|
|
"message_id": msg.Header.MessageID, "body_text": msg.BodyText,
|
|
"attachments": atts,
|
|
}))
|
|
}
|
|
|
|
// 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 {
|
|
return d.emit(*fail)
|
|
}
|
|
defer done()
|
|
headers, err := m.Search(folder, sc, 0)
|
|
if err != nil {
|
|
return d.emit(Failure(CodeNetwork, err.Error()))
|
|
}
|
|
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
|
|
}
|
|
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", "*", "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 {
|
|
_, rule, m, uidv, done, fail := d.setup(account, folder)
|
|
if fail != nil {
|
|
return d.emit(*fail)
|
|
}
|
|
defer done()
|
|
for _, uid := range uids {
|
|
ok, err := d.visible(m, rule, folder, uid)
|
|
if err != nil {
|
|
return d.emit(Failure(CodeNetwork, err.Error()))
|
|
}
|
|
if !ok {
|
|
d.audit(account, "ack", uitoa(uid), "blocked", "filtered")
|
|
return d.emit(Failure(CodeNotFound, "message not found"))
|
|
}
|
|
}
|
|
if err := d.Store.Ack(account, folder, uidv, uids...); err != nil {
|
|
return d.emit(Failure(CodeDB, err.Error()))
|
|
}
|
|
d.audit(account, "ack", folder, "allowed", "")
|
|
return d.emit(Success(map[string]any{"acked": uintSlice(uids)}))
|
|
}
|