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
+26
View File
@@ -0,0 +1,26 @@
package policy
// OutboundRule captures one account's send-side enforcement.
type OutboundRule struct {
Mode string // RO | RW
WhitelistOutEnabled bool
WhitelistOut []string
}
// Check reports whether a send to the given recipient set is permitted. On a
// block it returns a stable reason: "ro_mode" (account is read-only) or
// "whitelist_out" (a recipient is not whitelisted). The whole send is blocked
// if any single recipient fails — there is no partial send.
func (r OutboundRule) Check(recipients []string) (bool, string) {
if r.Mode == "RO" {
return false, "ro_mode"
}
if r.WhitelistOutEnabled {
for _, addr := range recipients {
if !MatchAddress(r.WhitelistOut, addr) {
return false, "whitelist_out"
}
}
}
return true, ""
}
+64
View File
@@ -0,0 +1,64 @@
package policy
import "testing"
func TestOutboundRuleCheck(t *testing.T) {
cases := []struct {
name string
rule OutboundRule
recipients []string
wantOK bool
wantReason string
}{
{
name: "RO mode always blocked",
rule: OutboundRule{Mode: "RO"},
recipients: []string{"a@x.com"},
wantOK: false,
wantReason: "ro_mode",
},
{
name: "RW no whitelist allows anything",
rule: OutboundRule{Mode: "RW"},
recipients: []string{"anyone@anywhere.com"},
wantOK: true,
},
{
name: "whitelist-out all match allows",
rule: OutboundRule{Mode: "RW", WhitelistOutEnabled: true, WhitelistOut: []string{"bob@x.com", "@trusted.com"}},
recipients: []string{"bob@x.com", "ann@trusted.com"},
wantOK: true,
},
{
name: "whitelist-out domain match",
rule: OutboundRule{Mode: "RW", WhitelistOutEnabled: true, WhitelistOut: []string{"@trusted.com"}},
recipients: []string{"ANN@Trusted.com"},
wantOK: true,
},
{
name: "one bad recipient blocks whole send",
rule: OutboundRule{Mode: "RW", WhitelistOutEnabled: true, WhitelistOut: []string{"@trusted.com"}},
recipients: []string{"ann@trusted.com", "eve@evil.com"},
wantOK: false,
wantReason: "whitelist_out",
},
{
name: "RO takes precedence over whitelist pass",
rule: OutboundRule{Mode: "RO", WhitelistOutEnabled: true, WhitelistOut: []string{"@trusted.com"}},
recipients: []string{"ann@trusted.com"},
wantOK: false,
wantReason: "ro_mode",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ok, reason := c.rule.Check(c.recipients)
if ok != c.wantOK {
t.Fatalf("Check ok=%v want %v", ok, c.wantOK)
}
if !ok && reason != c.wantReason {
t.Fatalf("reason=%q want %q", reason, c.wantReason)
}
})
}
}