Files
emcli/internal/mail/send_test.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

118 lines
3.3 KiB
Go

package mail
import (
"strings"
"testing"
"time"
)
func TestBuildMIMERoundTrip(t *testing.T) {
msg := OutgoingMessage{
From: "emcli@stevecliff.com",
To: []string{"me@stevecliff.com"},
Cc: []string{"cc@stevecliff.com"},
Subject: "hello from emcli",
BodyText: "this is the body\nwith two lines",
Date: time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC),
InReplyTo: "abc123@origin.example",
References: []string{"root@origin.example", "abc123@origin.example"},
Attachments: []Attachment{
{Name: "note.txt", MIME: "text/plain", Content: []byte("attached bytes")},
},
}
raw, err := BuildMIME(msg)
if err != nil {
t.Fatalf("BuildMIME: %v", err)
}
// Headers present in the raw bytes.
rawStr := string(raw)
for _, want := range []string{
"From:", "emcli@stevecliff.com",
"To:", "me@stevecliff.com",
"Cc:", "cc@stevecliff.com",
"Subject:", "hello from emcli",
"In-Reply-To:", "abc123@origin.example",
"References:", "root@origin.example",
} {
if !strings.Contains(rawStr, want) {
t.Fatalf("MIME missing %q in:\n%s", want, rawStr)
}
}
// Round-trips back through the parser: body and attachment survive.
parsed, err := ParseMessage(0, raw)
if err != nil {
t.Fatalf("ParseMessage: %v", err)
}
gotBody := strings.ReplaceAll(strings.TrimSpace(parsed.BodyText), "\r\n", "\n")
if gotBody != "this is the body\nwith two lines" {
t.Fatalf("body not preserved: %q", parsed.BodyText)
}
if len(parsed.Attachments) != 1 {
t.Fatalf("want 1 attachment, got %d", len(parsed.Attachments))
}
if parsed.Attachments[0].Name != "note.txt" || string(parsed.Attachments[0].Content) != "attached bytes" {
t.Fatalf("attachment not preserved: %+v", parsed.Attachments[0])
}
}
func TestBuildMIMENoAttachments(t *testing.T) {
msg := OutgoingMessage{
From: "emcli@stevecliff.com",
To: []string{"me@stevecliff.com"},
Subject: "plain",
BodyText: "just text",
Date: time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC),
}
raw, err := BuildMIME(msg)
if err != nil {
t.Fatalf("BuildMIME: %v", err)
}
parsed, err := ParseMessage(0, raw)
if err != nil {
t.Fatalf("ParseMessage: %v", err)
}
if strings.TrimSpace(parsed.BodyText) != "just text" {
t.Fatalf("body not preserved: %q", parsed.BodyText)
}
if len(parsed.Attachments) != 0 {
t.Fatalf("want 0 attachments, got %d", len(parsed.Attachments))
}
}
func TestRecipientsCombinesAllFields(t *testing.T) {
msg := OutgoingMessage{
To: []string{"a@x.com"},
Cc: []string{"b@x.com"},
Bcc: []string{"c@x.com"},
}
got := msg.Recipients()
want := []string{"a@x.com", "b@x.com", "c@x.com"}
if len(got) != len(want) {
t.Fatalf("Recipients()=%v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("Recipients()=%v want %v", got, want)
}
}
}
func TestReadHeaderParsesReferences(t *testing.T) {
raw := "From: a@x.com\r\n" +
"To: b@x.com\r\n" +
"Subject: re: hi\r\n" +
"Message-Id: <reply@x.com>\r\n" +
"References: <root@x.com> <mid@x.com>\r\n" +
"\r\nbody\r\n"
h, err := ParseHeaderBytes(7, []byte(raw))
if err != nil {
t.Fatalf("ParseHeaderBytes: %v", err)
}
if len(h.References) != 2 || h.References[0] != "root@x.com" || h.References[1] != "mid@x.com" {
t.Fatalf("References not parsed: %v", h.References)
}
}