Files
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

125 lines
3.3 KiB
Go

// Package mail provides IMAP reading and RFC822 message parsing.
package mail
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/emersion/go-message/mail"
)
type Header struct {
UID uint32
From string
To string
Subject string
Date string
MessageID string
References []string // parsed Message-IDs (no angle brackets), oldest first
HasAttachments bool
}
type Attachment struct {
Name string
Size int
MIME string
Content []byte
}
type Message struct {
Header Header
BodyText string
Attachments []Attachment
}
func readHeader(mr *mail.Reader, uid uint32) Header {
h := Header{UID: uid}
hd := mr.Header
h.Subject, _ = hd.Subject()
if addrs, err := hd.AddressList("From"); err == nil && len(addrs) > 0 {
h.From = addrs[0].String()
}
if addrs, err := hd.AddressList("To"); err == nil && len(addrs) > 0 {
h.To = addrs[0].String()
}
if d, err := hd.Date(); err == nil {
h.Date = d.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700")
}
if msgID, err := hd.MessageID(); err == nil {
h.MessageID = msgID
} 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
}
// ParseMessage decodes the full message including attachment contents.
func ParseMessage(uid uint32, raw []byte) (Message, error) {
mr, err := mail.CreateReader(bytes.NewReader(raw))
if err != nil {
return Message{}, err
}
m := Message{Header: readHeader(mr, uid)}
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return Message{}, err
}
switch hdr := part.Header.(type) {
case *mail.InlineHeader:
ct, _, _ := hdr.ContentType()
if strings.HasPrefix(ct, "text/plain") && m.BodyText == "" {
b, err := io.ReadAll(part.Body)
if err != nil {
return Message{}, fmt.Errorf("read message part: %w", err)
}
m.BodyText = string(b)
}
case *mail.AttachmentHeader:
name, _ := hdr.Filename()
ct, _, _ := hdr.ContentType()
b, err := io.ReadAll(part.Body)
if err != nil {
return Message{}, fmt.Errorf("read message part: %w", err)
}
m.Attachments = append(m.Attachments, Attachment{
Name: name, Size: len(b), MIME: ct, Content: b,
})
}
}
m.Header.HasAttachments = len(m.Attachments) > 0
return m, nil
}
// ParseHeaderOnly parses a full RFC822 message (raw bytes) and returns only the
// Header, with HasAttachments set. For IMAP BODY[HEADER]-only bytes (no body),
// use ParseHeaderBytes instead, which avoids downloading the body entirely.
func ParseHeaderOnly(uid uint32, raw []byte) (Header, error) {
m, err := ParseMessage(uid, raw)
if err != nil {
return Header{}, err
}
return m.Header, nil
}
// ParseHeaderBytes parses just a message's RFC822 header block (the bytes from
// an IMAP BODY[HEADER] fetch, with no body) into a Header, reusing the same
// RFC2047-decoding and formatting as ParseMessage. HasAttachments is left false:
// the caller, which holds the BODYSTRUCTURE, sets it. This lets list/search read
// headers without downloading message bodies.
func ParseHeaderBytes(uid uint32, raw []byte) (Header, error) {
mr, err := mail.CreateReader(bytes.NewReader(raw))
if err != nil {
return Header{}, err
}
return readHeader(mr, uid), nil
}