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>
This commit is contained in:
2026-06-22 17:39:07 +01:00
parent 3224a87b6e
commit c99eaedafd
17 changed files with 923 additions and 15 deletions
+9 -2
View File
@@ -31,6 +31,9 @@ func runAccount(args []string, out, errOut io.Writer) int {
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 993, "IMAP port")
sec := fs.String("imap-security", "tls", "tls|starttls")
smtpHost := fs.String("smtp-host", "", "SMTP host (RW accounts)")
smtpPort := fs.Int("smtp-port", 465, "SMTP port")
smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password")
subj := fs.String("subject-regex", "", "inbound subject filter")
@@ -44,12 +47,16 @@ func runAccount(args []string, out, errOut io.Writer) int {
fmt.Fprintln(errOut, "name, imap-host, and username are required")
return 2
}
_, err := st.AddAccount(store.Account{
acc := store.Account{
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
AuthType: "password", Username: *user, Password: *pass,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
ProcessBacklog: *backlog,
})
}
if *mode == "RW" {
acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec
}
_, err := st.AddAccount(acc)
if err != nil {
fmt.Fprintf(errOut, "add account: %v\n", err)
return 1
+1
View File
@@ -27,6 +27,7 @@ type Mailer interface {
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
}
+60 -1
View File
@@ -37,8 +37,15 @@ func openStore() (*store.Store, error) {
return store.Open(path, key)
}
func realSender(acc store.Account, m mail.OutgoingMessage) error {
return mail.SendSMTP(mail.SMTPConfig{
Host: acc.SMTPHost, Port: acc.SMTPPort, Security: acc.SMTPSecurity,
Username: acc.Username, Password: acc.Password,
}, m)
}
func newDepsLive(st *store.Store, out io.Writer) Deps {
return Deps{Store: st, Dial: realMailer, Now: time.Now, Out: out}
return Deps{Store: st, Dial: realMailer, Send: realSender, Now: time.Now, Out: out}
}
// Run routes a command line and returns an exit code.
@@ -51,6 +58,8 @@ func Run(args []string, out, errOut io.Writer) int {
switch cmd {
case "list", "get", "search", "ack":
return runAgent(cmd, rest, out, errOut)
case "send":
return runSend(rest, out, errOut)
case "account":
return runAccount(rest, out, errOut)
case "whitelist":
@@ -139,6 +148,56 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
return 0
}
// stringSlice is a repeatable string flag that also splits comma-separated
// values, so `--to a@x --to b@x` and `--to a@x,b@x` both yield two recipients.
type stringSlice []string
func (s *stringSlice) String() string { return strings.Join(*s, ",") }
func (s *stringSlice) Set(v string) error {
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
*s = append(*s, p)
}
}
return nil
}
// runSend handles the `send` agent command (JSON envelope output).
func runSend(args []string, out, errOut io.Writer) int {
fs := flag.NewFlagSet("send", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "account name")
var to, cc, bcc, attach stringSlice
fs.Var(&to, "to", "recipient (repeatable / comma-separated)")
fs.Var(&cc, "cc", "cc recipient (repeatable / comma-separated)")
fs.Var(&bcc, "bcc", "bcc recipient (repeatable / comma-separated)")
fs.Var(&attach, "attach", "attachment file path (repeatable)")
subject := fs.String("subject", "", "subject")
body := fs.String("body", "", "plain-text body")
replyTo := fs.Uint("reply-to", 0, "source UID to reply to (threading)")
folder := fs.String("folder", "INBOX", "folder of the reply source")
if err := fs.Parse(args); err != nil {
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
if *account == "" {
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
st, err := openStore()
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
}
defer st.Close()
_, _ = st.PurgeAudit(time.Now())
d := newDepsLive(st, out)
if err := SendCmd(d, *account, to, cc, bcc, *subject, *body, attach, u32(*replyTo), *folder); err != nil {
return 1
}
return 0
}
func u32(u uint) uint32 { return uint32(u) }
func parseUIDList(s string) ([]uint32, error) {
+92
View File
@@ -0,0 +1,92 @@
package cli
import (
"mime"
"os"
"path/filepath"
"strings"
"git.dcglab.co.uk/steve/emcli/internal/mail"
"git.dcglab.co.uk/steve/emcli/internal/policy"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// SendCmd sends a plain-text message (with optional attachments) via the
// account's SMTP endpoint, enforcing outbound policy (RO rejection +
// whitelist-out). When replyToUID > 0, the source message is read from
// replyFolder (subject to inbound filtering) and its Message-ID/References are
// used to thread the reply.
func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string, attachPaths []string, replyToUID uint32, replyFolder string) error {
acc, err := d.Store.GetAccount(account)
if err != nil {
return d.emit(Failure(CodeNotFound, "account not found: "+account))
}
msg := mail.OutgoingMessage{
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body,
}
recipients := msg.Recipients()
if len(recipients) == 0 {
return d.emit(Failure(CodeUsage, "at least one recipient is required"))
}
// Outbound enforcement first — block before any network I/O.
wlOut, _ := d.Store.ListWhitelist(account, store.DirOut)
rule := policy.OutboundRule{
Mode: acc.Mode,
WhitelistOutEnabled: acc.WhitelistOutEnabled,
WhitelistOut: wlOut,
}
if ok, reason := rule.Check(recipients); !ok {
d.audit(account, "send", strings.Join(recipients, ","), "blocked", reason)
return d.emit(Failure(CodePolicy, "send blocked: "+reason))
}
// Reply threading: resolve the source's Message-ID/References, applying the
// inbound filter so a hidden source cannot be replied to.
if replyToUID > 0 {
_, irule, m, _, done, fail := d.setup(account, replyFolder)
if fail != nil {
return d.emit(*fail)
}
defer done()
hs, err := m.FetchHeaders(replyFolder, []uint32{replyToUID})
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
if len(hs) == 0 || !irule.Allows(hs[0].From, hs[0].Subject) {
d.audit(account, "send", uitoa(replyToUID), "blocked", "filtered")
return d.emit(Failure(CodeNotFound, "reply source not found"))
}
src := hs[0]
if src.MessageID != "" {
msg.InReplyTo = src.MessageID
msg.References = append(append([]string{}, src.References...), src.MessageID)
}
}
// Read attachments from disk.
for _, p := range attachPaths {
content, err := os.ReadFile(p)
if err != nil {
return d.emit(Failure(CodeUsage, "read attachment: "+err.Error()))
}
mimeType := mime.TypeByExtension(filepath.Ext(p))
if mimeType == "" {
mimeType = "application/octet-stream"
}
msg.Attachments = append(msg.Attachments, mail.Attachment{
Name: filepath.Base(p), Size: len(content), MIME: mimeType, Content: content,
})
}
if err := d.Send(acc, msg); err != nil {
d.audit(account, "send", strings.Join(recipients, ","), "blocked", "smtp_error")
return d.emit(Failure(CodeNetwork, err.Error()))
}
d.audit(account, "send", strings.Join(recipients, ","), "allowed", "")
return d.emit(Success(map[string]any{
"sent": true, "recipients": recipients,
}))
}
+162
View File
@@ -0,0 +1,162 @@
package cli
import (
"path/filepath"
"testing"
"time"
"git.dcglab.co.uk/steve/emcli/internal/mail"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// sendDeps builds Deps wired to a recording fake sender and an optional fake
// mailer (for reply-to). The named account is created per the supplied template.
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
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() })
if _, err := st.AddAccount(acc); err != nil {
t.Fatalf("AddAccount: %v", err)
}
var sent []mail.OutgoingMessage
buf := &[]byte{}
d := Deps{
Store: st,
Dial: func(store.Account) (Mailer, error) { return fm, nil },
Now: func() time.Time { return time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC) },
Out: bufWriter{buf},
Send: func(_ store.Account, m mail.OutgoingMessage) error {
sent = append(sent, m)
return nil
},
}
return d, &sent, buf
}
type bufWriter struct{ b *[]byte }
func (w bufWriter) Write(p []byte) (int, error) { *w.b = append(*w.b, p...); return len(p), nil }
func rwAccount() store.Account {
return store.Account{
Name: "send", Mode: "RW", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
SMTPHost: "h", SMTPPort: 465, SMTPSecurity: "tls",
AuthType: "password", Username: "emcli@stevecliff.com", Password: "pw",
}
}
func TestSendHappyPath(t *testing.T) {
d, sent, buf := sendDeps(t, rwAccount(), nil)
err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX")
if err != nil {
t.Fatalf("SendCmd: %v", err)
}
res := decode(t, *buf)
if res["error"] != false {
t.Fatalf("unexpected error: %v", res)
}
if len(*sent) != 1 {
t.Fatalf("want 1 send, got %d", len(*sent))
}
m := (*sent)[0]
if len(m.To) != 1 || m.To[0] != "me@stevecliff.com" || m.Subject != "hi" {
t.Fatalf("wrong outgoing message: %+v", m)
}
}
func TestSendROBlocked(t *testing.T) {
acc := rwAccount()
acc.Mode = "RO"
d, sent, buf := sendDeps(t, acc, nil)
_ = SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX")
res := decode(t, *buf)
if res["error"] != true {
t.Fatal("RO send must be blocked")
}
if len(*sent) != 0 {
t.Fatal("sender must not be called when blocked")
}
}
func TestSendWhitelistOutBlocked(t *testing.T) {
acc := rwAccount()
acc.WhitelistOutEnabled = true
d, sent, buf := sendDeps(t, acc, nil)
_ = d.Store.AddWhitelist("send", store.DirOut, "@stevecliff.com")
// recipient not on the whitelist -> blocked
_ = SendCmd(d, "send", []string{"stranger@elsewhere.com"}, nil, nil, "hi", "body", nil, 0, "INBOX")
res := decode(t, *buf)
if res["error"] != true {
t.Fatal("non-whitelisted recipient must be blocked")
}
if len(*sent) != 0 {
t.Fatal("sender must not be called when blocked")
}
}
func TestSendWhitelistOutAllowed(t *testing.T) {
acc := rwAccount()
acc.WhitelistOutEnabled = true
d, sent, _ := sendDeps(t, acc, nil)
_ = d.Store.AddWhitelist("send", store.DirOut, "@stevecliff.com")
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if len(*sent) != 1 {
t.Fatalf("whitelisted recipient should be sent, got %d", len(*sent))
}
}
func TestSendReplyToThreadsHeaders(t *testing.T) {
acc := rwAccount()
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{
{UID: 42, From: "me@stevecliff.com", Subject: "orig",
MessageID: "orig@stevecliff.com", References: []string{"root@stevecliff.com"}},
},
}
d, sent, _ := sendDeps(t, acc, fm)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "re: orig", "body", nil, 42, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if len(*sent) != 1 {
t.Fatalf("want 1 send, got %d", len(*sent))
}
m := (*sent)[0]
if m.InReplyTo != "orig@stevecliff.com" {
t.Fatalf("InReplyTo=%q", m.InReplyTo)
}
// References should chain the source's references then the source itself.
if len(m.References) != 2 || m.References[0] != "root@stevecliff.com" || m.References[1] != "orig@stevecliff.com" {
t.Fatalf("References=%v", m.References)
}
}
func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
acc := rwAccount()
acc.WhitelistInEnabled = true // inbound filter active
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{
{UID: 42, From: "eve@evil.com", Subject: "spam", MessageID: "spam@evil.com"},
},
}
d, sent, buf := sendDeps(t, acc, fm)
_ = d.Store.AddWhitelist("send", store.DirIn, "@stevecliff.com") // 42 is from evil.com => filtered
_ = SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "re", "body", nil, 42, "INBOX")
res := decode(t, *buf)
if res["error"] != true {
t.Fatal("reply to a filtered source must fail")
}
ed := res["error_detail"].(map[string]any)
if ed["code"] != "not_found" {
t.Fatalf("want not_found, got %v", ed["code"])
}
if len(*sent) != 0 {
t.Fatal("must not send when reply source is filtered")
}
}