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) 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 { 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, }) } // 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, err := d.Store.GetAccount(account) if err != nil { e := Failure(CodeNotFound, "account not found: "+account) return acc, policy.InboundRule{}, nil, 0, 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 } 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, 0, 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, 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, })) } 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, 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)})) }