c99eaedafd
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>
125 lines
3.3 KiB
Go
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
|
|
}
|