Files
emcli/internal/cli/agent.go
T
steve c99eaedafd feat(send): Phase 2 send path — SMTP, MIME, reply threading, outbound policy
Adds the `send` agent command and everything behind it:

- store: Account carries SMTP host/port/security (NULL-safe scan/insert/select);
  admin `account add` gains --smtp-* flags (applied for RW accounts).
- policy: OutboundRule.Check(recipients) → (ok, reason); RO ⇒ ro_mode,
  whitelist-out blocks the whole send if any recipient fails (no partial send).
- mail: Header.References; OutgoingMessage + BuildMIME (plain text + attachments,
  In-Reply-To/References threading, Bcc envelope-only); SendSMTP (tls/starttls,
  SASL PLAIN, envelope send) via emersion/go-smtp.
- cli: SendCmd gates outbound, resolves --reply-to under the inbound filter
  (filtered/absent source ⇒ not_found), reads attachments, audits, emits the
  JSON envelope; repeatable --to/--cc/--bcc/--attach flags wired into the router.

Implemented test-first; full suite passes incl -race. Validated live against
friday.mxlogin.com: real send to me@stevecliff.com, RO + whitelist-out blocks,
and --reply-to threading off a live INBOX message. test-creds.md gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:39:07 +01:00

231 lines
6.8 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)
Logout() error
}
type Deps struct {
Store *store.Store
Dial func(store.Account) (Mailer, error)
Send func(store.Account, mail.OutgoingMessage) 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, 0)
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)}))
}