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:
@@ -17,6 +17,7 @@ type Header struct {
|
||||
Subject string
|
||||
Date string
|
||||
MessageID string
|
||||
References []string // parsed Message-IDs (no angle brackets), oldest first
|
||||
HasAttachments bool
|
||||
}
|
||||
|
||||
@@ -51,6 +52,9 @@ func readHeader(mr *mail.Reader, uid uint32) Header {
|
||||
} else {
|
||||
h.MessageID = strings.Trim(hd.Get("Message-Id"), "<> ")
|
||||
}
|
||||
if refs, err := hd.MsgIDList("References"); err == nil && len(refs) > 0 {
|
||||
h.References = refs
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
gomail "github.com/emersion/go-message/mail"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// SMTPConfig is the connection/auth detail for an account's send endpoint.
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Security string // tls | starttls
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// OutgoingMessage is a plain-text message (with optional attachments) to send.
|
||||
// InReplyTo and References hold bare Message-IDs (no angle brackets) and, when
|
||||
// set, produce threading headers so a reply chains onto its source.
|
||||
type OutgoingMessage struct {
|
||||
From string
|
||||
To []string
|
||||
Cc []string
|
||||
Bcc []string
|
||||
Subject string
|
||||
BodyText string
|
||||
Attachments []Attachment
|
||||
InReplyTo string
|
||||
References []string
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// Recipients returns the full envelope recipient set: to + cc + bcc.
|
||||
func (m OutgoingMessage) Recipients() []string {
|
||||
out := make([]string, 0, len(m.To)+len(m.Cc)+len(m.Bcc))
|
||||
out = append(out, m.To...)
|
||||
out = append(out, m.Cc...)
|
||||
out = append(out, m.Bcc...)
|
||||
return out
|
||||
}
|
||||
|
||||
func addrList(addrs []string) []*gomail.Address {
|
||||
out := make([]*gomail.Address, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
if parsed, err := gomail.ParseAddress(a); err == nil {
|
||||
out = append(out, parsed)
|
||||
} else {
|
||||
out = append(out, &gomail.Address{Address: a})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildMIME renders an OutgoingMessage to RFC822 bytes: a plain-text inline part
|
||||
// plus any attachments. Bcc recipients are intentionally omitted from the headers
|
||||
// (they travel only in the SMTP envelope).
|
||||
func BuildMIME(m OutgoingMessage) ([]byte, error) {
|
||||
var h gomail.Header
|
||||
if m.Date.IsZero() {
|
||||
m.Date = time.Now()
|
||||
}
|
||||
h.SetDate(m.Date)
|
||||
h.SetAddressList("From", addrList([]string{m.From}))
|
||||
if len(m.To) > 0 {
|
||||
h.SetAddressList("To", addrList(m.To))
|
||||
}
|
||||
if len(m.Cc) > 0 {
|
||||
h.SetAddressList("Cc", addrList(m.Cc))
|
||||
}
|
||||
h.SetSubject(m.Subject)
|
||||
if m.InReplyTo != "" {
|
||||
h.SetMsgIDList("In-Reply-To", []string{m.InReplyTo})
|
||||
}
|
||||
if len(m.References) > 0 {
|
||||
h.SetMsgIDList("References", m.References)
|
||||
}
|
||||
if err := h.GenerateMessageID(); err != nil {
|
||||
return nil, fmt.Errorf("generate message-id: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w, err := gomail.CreateWriter(&buf, h)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create mail writer: %w", err)
|
||||
}
|
||||
|
||||
tw, err := w.CreateInline()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create inline: %w", err)
|
||||
}
|
||||
var th gomail.InlineHeader
|
||||
th.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
pw, err := tw.CreatePart(th)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create text part: %w", err)
|
||||
}
|
||||
if _, err := io.WriteString(pw, m.BodyText); err != nil {
|
||||
return nil, fmt.Errorf("write body: %w", err)
|
||||
}
|
||||
if err := pw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, a := range m.Attachments {
|
||||
var ah gomail.AttachmentHeader
|
||||
if a.MIME != "" {
|
||||
ah.Set("Content-Type", a.MIME)
|
||||
}
|
||||
ah.SetFilename(a.Name)
|
||||
aw, err := w.CreateAttachment(ah)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create attachment %q: %w", a.Name, err)
|
||||
}
|
||||
if _, err := aw.Write(a.Content); err != nil {
|
||||
return nil, fmt.Errorf("write attachment %q: %w", a.Name, err)
|
||||
}
|
||||
if err := aw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// SendSMTP builds the MIME body and delivers it via the account's SMTP endpoint,
|
||||
// authenticating with SASL PLAIN. The envelope sender is m.From and the envelope
|
||||
// recipients are m.Recipients() (to + cc + bcc).
|
||||
func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error {
|
||||
raw, err := BuildMIME(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
tlsConf := &tls.Config{ServerName: cfg.Host}
|
||||
|
||||
var c *smtp.Client
|
||||
switch cfg.Security {
|
||||
case "tls":
|
||||
c, err = smtp.DialTLS(addr, tlsConf)
|
||||
case "starttls":
|
||||
c, err = smtp.DialStartTLS(addr, tlsConf)
|
||||
default:
|
||||
return fmt.Errorf("unknown smtp security %q", cfg.Security)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp connect: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
auth := sasl.NewPlainClient("", cfg.Username, cfg.Password)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {
|
||||
return fmt.Errorf("smtp send: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user