feat(cli): agent read commands (list/get/search/ack) with policy filtering
This commit is contained in:
@@ -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)}))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user