From ccf6fa05427a9d04bfa4408c1bd658ffd9c0acb3 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 22 Jun 2026 00:03:27 +0100 Subject: [PATCH] feat(cli): agent read commands (list/get/search/ack) with policy filtering --- internal/cli/agent.go | 217 +++++++++++++++++++++++++++++++++++++ internal/cli/agent_test.go | 169 +++++++++++++++++++++++++++++ internal/cli/dispatch.go | 18 +++ 3 files changed, 404 insertions(+) create mode 100644 internal/cli/agent.go create mode 100644 internal/cli/agent_test.go create mode 100644 internal/cli/dispatch.go diff --git a/internal/cli/agent.go b/internal/cli/agent.go new file mode 100644 index 0000000..24e0ab5 --- /dev/null +++ b/internal/cli/agent.go @@ -0,0 +1,217 @@ +package cli + +import ( + "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" +) + +// 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) + Logout() error +} + +type Deps struct { + Store *store.Store + Dial func(store.Account) (Mailer, error) + Now func() time.Time + Out io.Writer +} + +func (d Deps) emit(e Envelope) error { return e.Write(d.Out) } + +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, + }) +} + +// 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, func(), *Envelope) { + acc, err := d.Store.GetAccount(account) + if err != nil { + e := Failure(CodeNotFound, "account not found: "+account) + return acc, policy.InboundRule{}, nil, nil, &e + } + re, err := policy.CompileSubject(acc.SubjectRegex) + if err != nil { + e := Failure(CodeConfig, "invalid subject_regex: "+err.Error()) + return acc, policy.InboundRule{}, nil, 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, nil, &e + } + uidv, maxUID, err := m.SelectFolder(folder) + if err != nil { + m.Logout() + e := Failure(CodeNetwork, "select folder failed: "+err.Error()) + return acc, rule, nil, nil, &e + } + if err := d.Store.EnsureFolderBaseline(account, folder, uidv, maxUID); err != nil { + m.Logout() + e := Failure(CodeDB, err.Error()) + return acc, rule, nil, nil, &e + } + return acc, rule, m, 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, + })) +} + +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, limit) + 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) { + continue + } + out = append(out, headerMap(h)) + if limit > 0 && len(out) >= limit { + break + } + } + d.audit(account, "search", folder, "allowed", "") + return d.emit(Success(map[string]any{"messages": out})) +} + +func AckCmd(d Deps, account, folder string, uids []uint32) error { + _, rule, m, done, fail := d.setup(account, folder) + if fail != nil { + return d.emit(*fail) + } + defer done() + uidv, _, _ := m.SelectFolder(folder) + 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)})) +} diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go new file mode 100644 index 0000000..8821a25 --- /dev/null +++ b/internal/cli/agent_test.go @@ -0,0 +1,169 @@ +package cli + +import ( + "bytes" + "encoding/json" + "path/filepath" + "testing" + "time" + + "git.dcglab.co.uk/steve/emcli/internal/mail" + "git.dcglab.co.uk/steve/emcli/internal/store" +) + +type fakeMailer struct { + uidValidity uint32 + maxUID uint32 + headers []mail.Header + full map[uint32]mail.Message +} + +func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) { return f.uidValidity, f.maxUID, nil } +func (f *fakeMailer) FetchHeaders(_ string, uids []uint32) ([]mail.Header, error) { + if len(uids) == 0 { + return f.headers, nil + } + want := map[uint32]bool{} + for _, u := range uids { + want[u] = true + } + var out []mail.Header + for _, h := range f.headers { + if want[h.UID] { + out = append(out, h) + } + } + return out, nil +} +func (f *fakeMailer) FetchHeadersRange(string, uint32, uint32, int) ([]mail.Header, error) { + return f.headers, nil +} +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) { + return f.headers, nil +} +func (f *fakeMailer) Logout() error { return nil } + +func testKey() []byte { + k := make([]byte, 32) + for i := range k { + k[i] = byte(i) + } + return k +} + +func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { st.Close() }) + _, err = st.AddAccount(store.Account{ + Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", + AuthType: "password", Username: "me@example.com", Password: "pw", + WhitelistInEnabled: true, + }) + if err != nil { + t.Fatalf("AddAccount: %v", err) + } + _ = st.AddWhitelist("work", store.DirIn, "@trusted.com") + var buf bytes.Buffer + d := Deps{ + Store: st, + Dial: func(store.Account) (Mailer, error) { return fm, nil }, + Now: func() time.Time { return time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC) }, + Out: &buf, + } + return d, &buf +} + +func decode(t *testing.T, b []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal %q: %v", b, err) + } + return m +} + +func TestListNewFiltersBySenderAndState(t *testing.T) { + fm := &fakeMailer{ + uidValidity: 1, maxUID: 100, + headers: []mail.Header{ + {UID: 101, From: "bob@trusted.com", Subject: "hi"}, + {UID: 102, From: "eve@evil.com", Subject: "spam"}, // filtered out by whitelist + {UID: 103, From: "ann@trusted.com", Subject: "yo"}, + }, + } + d, buf := newDeps(t, fm) + if err := ListCmd(d, "work", "INBOX", true, 0, 0, 50); err != nil { + t.Fatalf("ListCmd: %v", err) + } + res := decode(t, buf.Bytes()) + if res["error"] != false { + t.Fatalf("unexpected error: %v", res) + } + data := res["data"].(map[string]any) + msgs := data["messages"].([]any) + if len(msgs) != 2 { // 101 and 103; 102 filtered + t.Fatalf("want 2 messages, got %d: %v", len(msgs), msgs) + } +} + +func TestGetFilteredReturnsNotFound(t *testing.T) { + fm := &fakeMailer{ + uidValidity: 1, maxUID: 100, + headers: []mail.Header{{UID: 102, From: "eve@evil.com", Subject: "spam"}}, + full: map[uint32]mail.Message{ + 102: {Header: mail.Header{UID: 102, From: "eve@evil.com", Subject: "spam"}, BodyText: "secret"}, + }, + } + d, buf := newDeps(t, fm) + if err := GetCmd(d, "work", "INBOX", 102); err != nil { + t.Fatalf("GetCmd: %v", err) + } + res := decode(t, buf.Bytes()) + if res["error"] != true { + t.Fatal("filtered get must return error envelope") + } + ed := res["error_detail"].(map[string]any) + if ed["code"] != "not_found" { + t.Fatalf("want not_found, got %v", ed["code"]) + } +} + +func TestAckAdvancesStateAndFiltered(t *testing.T) { + fm := &fakeMailer{ + uidValidity: 1, maxUID: 100, + headers: []mail.Header{ + {UID: 101, From: "bob@trusted.com", Subject: "hi"}, + {UID: 102, From: "eve@evil.com", Subject: "spam"}, + }, + } + d, buf := newDeps(t, fm) + // Acking a filtered uid (102) must be rejected as not-found. + if err := AckCmd(d, "work", "INBOX", []uint32{102}); err != nil { + t.Fatalf("AckCmd: %v", err) + } + if decode(t, buf.Bytes())["error"] != true { + t.Fatal("acking filtered uid must fail") + } + // Acking a visible uid (101) succeeds and removes it from list --new. + buf.Reset() + if err := AckCmd(d, "work", "INBOX", []uint32{101}); err != nil { + t.Fatalf("AckCmd 101: %v", err) + } + if decode(t, buf.Bytes())["error"] != false { + t.Fatal("ack of visible uid should succeed") + } + buf.Reset() + _ = ListCmd(d, "work", "INBOX", true, 0, 0, 50) + data := decode(t, buf.Bytes())["data"].(map[string]any) + msgs := data["messages"].([]any) + if len(msgs) != 0 { // 101 acked, 102 filtered + t.Fatalf("want 0 new messages, got %d", len(msgs)) + } +} diff --git a/internal/cli/dispatch.go b/internal/cli/dispatch.go new file mode 100644 index 0000000..56cacc6 --- /dev/null +++ b/internal/cli/dispatch.go @@ -0,0 +1,18 @@ +package cli + +import ( + "encoding/base64" + "strconv" +) + +func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + +func uitoa(u uint32) string { return strconv.FormatUint(uint64(u), 10) } + +func uintSlice(us []uint32) []uint64 { + out := make([]uint64, len(us)) + for i, u := range us { + out[i] = uint64(u) + } + return out +}