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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user