Phase 2: Gmail IMAP fetch (IPv4), FR-2 source address, real mail in dashboard (AUD-67)

- internal/mailfetch: stdlib-only read-only IMAP client (LOGIN, SELECT,
  UID SEARCH, UID FETCH) that always dials tcp4; IPv6 to Gmail is broken
  from .13 and caused a socket EOF.
- MIME parsing to text, RFC2047 header decoding, FR-2 source address:
  X-Pm-Original-To -> To -> Received 'for <addr>' -> Return-Path, never
  Delivered-To. The matched header is recorded for audit.
- store: Message upsert/list/count + LogAction; schema migration v2 adds
  imap_uid and imap_uidvalidity.
- server: dashboard and /partials/inbox render real mail grouped by
  category with a recommended action; POST /actions/fetch runs a scan
  (CSRF-checked); nav counts; 'Check mail' button.
- config: GMAIL_USER, GMAIL_APP_PASSWORD, IMAP host/port/lookback/max.
- cmd: -fetch runs one scan and exits (n8n/CLI entry point, FR-1).
- compose/.env.example expose the Gmail + IMAP settings.
- tests: FR-2 precedence, MIME bodies, IMAP literal/parser, store
  upsert/migration, inbox rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 20:58:32 +11:00
parent e846400338
commit 1d88f962f6
23 changed files with 2067 additions and 52 deletions

160
internal/mailfetch/fetch.go Normal file
View File

@@ -0,0 +1,160 @@
package mailfetch
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// ErrNoCredentials means IMAP login cannot be attempted.
var ErrNoCredentials = errors.New("GMAIL_USER and GMAIL_APP_PASSWORD are not configured")
// ErrAlreadyRunning means a scan is already in progress.
var ErrAlreadyRunning = errors.New("a mail scan is already running")
// Fetcher performs an IMAP scan and stores the results.
type Fetcher struct {
cfg *config.Config
store *store.Store
logger *slog.Logger
mu sync.Mutex
}
// NewFetcher builds a Fetcher.
func NewFetcher(cfg *config.Config, st *store.Store, logger *slog.Logger) *Fetcher {
return &Fetcher{cfg: cfg, store: st, logger: logger}
}
// Summary describes the outcome of one scan.
type Summary struct {
UIDValidity uint32
Since time.Time
Fetched int
Stored int
Duration time.Duration
}
// Fetch performs one bounded IMAP scan of INBOX and upserts the results.
func (f *Fetcher) Fetch(ctx context.Context) (Summary, error) {
if !f.cfg.GmailConfigured() {
return Summary{}, ErrNoCredentials
}
if !f.mu.TryLock() {
return Summary{}, ErrAlreadyRunning
}
defer f.mu.Unlock()
start := time.Now()
client, err := Dial(ctx, f.cfg.IMAPHost, f.cfg.IMAPPort, DefaultTimeout)
if err != nil {
return Summary{}, err
}
defer func() { _ = client.Logout() }()
if err := client.Login(f.cfg.GmailUser, f.cfg.GmailAppPassword); err != nil {
return Summary{}, err
}
uidValidity, err := client.Select("INBOX")
if err != nil {
return Summary{}, err
}
since := time.Now().UTC().AddDate(0, 0, -f.cfg.IMAPLookbackDays)
uids, err := client.SearchSince(since)
if err != nil {
return Summary{}, err
}
if max := f.cfg.IMAPMaxMessages; max > 0 && len(uids) > max {
// Keep the newest; SEARCH returns ascending UIDs.
uids = uids[len(uids)-max:]
}
results, err := client.Fetch(uids)
if err != nil {
return Summary{}, err
}
sum := Summary{
UIDValidity: uidValidity,
Since: since,
Fetched: len(results),
}
now := time.Now().UTC()
headersSeen := map[string]struct{}{}
for _, r := range results {
parsed, perr := ParseMessage(r.Raw)
if perr != nil {
f.logger.Warn("skip unparsable message", "uid", r.UID, "error", perr)
continue
}
if parsed.SourceHeader != "" {
headersSeen[parsed.SourceHeader] = struct{}{}
}
msg := &store.Message{
GmailID: gmailID(r, uidValidity),
ThreadID: r.GmailThreadID,
FromAddr: parsed.From,
ToAddr: parsed.To,
Subject: parsed.Subject,
BodyText: parsed.BodyText,
SourceAddress: parsed.SourceAddress,
DateSent: parsed.Date,
ReceivedAt: now,
IMAPUID: int64(r.UID),
IMAPUIDValidity: int64(uidValidity),
}
if _, err := f.store.UpsertMessage(ctx, msg); err != nil {
return sum, err
}
sum.Stored++
}
sum.Duration = time.Since(start)
if err := f.store.LogAction(ctx, "fetch", "agent",
fmt.Sprintf("uidvalidity=%d since=%s fetched=%d stored=%d source_headers=%s",
uidValidity, since.Format("2006-01-02"), sum.Fetched, sum.Stored, joinKeys(headersSeen))); err != nil {
f.logger.Warn("record fetch action", "error", err)
}
f.logger.Info("mail scan complete",
"uidvalidity", uidValidity,
"since", since.Format("2006-01-02"),
"fetched", sum.Fetched,
"stored", sum.Stored,
"duration_ms", sum.Duration.Milliseconds(),
)
return sum, nil
}
// gmailID prefers Gmail's stable X-GM-MSGID and falls back to a UID key that is
// scoped to the UIDVALIDITY it was issued under.
func gmailID(r FetchResult, uidValidity uint32) string {
if r.GmailMsgID != "" {
return r.GmailMsgID
}
return "uid:" + strconv.FormatUint(uint64(uidValidity), 10) + ":" + strconv.FormatUint(uint64(r.UID), 10)
}
// joinKeys returns the sorted keys of a string set, for a log line.
func joinKeys(set map[string]struct{}) string {
if len(set) == 0 {
return "none"
}
parts := make([]string, 0, len(set))
for k := range set {
parts = append(parts, k)
}
slices.Sort(parts)
return strings.Join(parts, ",")
}

View File

@@ -0,0 +1,40 @@
package mailfetch
import (
"context"
"errors"
"log/slog"
"testing"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
)
func TestGmailIDPrefersXGMMsgID(t *testing.T) {
if got := gmailID(FetchResult{UID: 7, GmailMsgID: "555"}, 42); got != "555" {
t.Errorf("gmailID() = %q, want 555", got)
}
}
func TestGmailIDFallbackIsScopedToUIDValidity(t *testing.T) {
if got := gmailID(FetchResult{UID: 7}, 42); got != "uid:42:7" {
t.Errorf("gmailID() = %q, want uid:42:7", got)
}
}
func TestJoinKeys(t *testing.T) {
if got := joinKeys(map[string]struct{}{}); got != "none" {
t.Errorf("joinKeys(empty) = %q, want none", got)
}
got := joinKeys(map[string]struct{}{"To": {}, "X-Pm-Original-To": {}})
if got != "To,X-Pm-Original-To" {
t.Errorf("joinKeys() = %q, want sorted keys", got)
}
}
func TestFetchWithoutCredentials(t *testing.T) {
cfg := &config.Config{Env: "test", IMAPHost: config.DefaultIMAPHost, IMAPPort: config.DefaultIMAPPort}
f := NewFetcher(cfg, nil, slog.New(slog.DiscardHandler))
if _, err := f.Fetch(context.Background()); !errors.Is(err, ErrNoCredentials) {
t.Fatalf("Fetch() error = %v, want ErrNoCredentials", err)
}
}

328
internal/mailfetch/imap.go Normal file
View File

@@ -0,0 +1,328 @@
// Package mailfetch reads mail from Gmail over IMAP and turns it into stored
// messages.
//
// The IMAP client is intentionally dependency-free: it implements only the
// subset the Email Agent needs (LOGIN, SELECT, UID SEARCH, UID FETCH) and it
// always dials IPv4. IPv6 to Gmail is broken from .13 and previously caused a
// socket EOF (AGENTS.md §9).
package mailfetch
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
)
// DefaultTimeout bounds a single IMAP read/write.
const DefaultTimeout = 60 * time.Second
// Client is a minimal read-only IMAP client.
type Client struct {
conn net.Conn
br *bufio.Reader
tagSeq int
timeout time.Duration
preAuth bool
}
// response is one IMAP response: the assembled text (literals removed) plus any
// literal payloads in order of appearance.
type response struct {
line string
literals [][]byte
}
// ServerError is a tagged NO/BAD completion from the IMAP server.
type ServerError struct {
Command string
Response string
}
func (e *ServerError) Error() string {
return fmt.Sprintf("imap %s failed: %s", e.Command, e.Response)
}
// Dial opens an implicit-TLS IMAP connection to host:port, forcing IPv4.
func Dial(ctx context.Context, host string, port int, timeout time.Duration) (*Client, error) {
if timeout <= 0 {
timeout = DefaultTimeout
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
dialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second}
raw, err := dialer.DialContext(ctx, "tcp4", addr)
if err != nil {
return nil, fmt.Errorf("dial %s over IPv4: %w", addr, err)
}
tlsConn := tls.Client(raw, &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
})
hsCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := tlsConn.HandshakeContext(hsCtx); err != nil {
_ = raw.Close()
return nil, fmt.Errorf("tls handshake with %s: %w", addr, err)
}
c := &Client{conn: tlsConn, br: bufio.NewReader(tlsConn), timeout: timeout}
greeting, err := c.readResponse()
if err != nil {
_ = tlsConn.Close()
return nil, fmt.Errorf("read IMAP greeting: %w", err)
}
upper := strings.ToUpper(greeting.line)
if strings.HasPrefix(upper, "* PREAUTH") {
c.preAuth = true
return c, nil
}
if !strings.HasPrefix(upper, "* OK") {
_ = tlsConn.Close()
return nil, fmt.Errorf("unexpected IMAP greeting: %s", greeting.line)
}
return c, nil
}
// Login authenticates with an app password.
func (c *Client) Login(user, pass string) error {
if c.preAuth {
return nil
}
return c.exec("LOGIN "+imapQuote(user)+" "+imapQuote(pass), nil)
}
// Select opens a mailbox and returns its UIDVALIDITY.
func (c *Client) Select(mailbox string) (uint32, error) {
var uidValidity uint32
err := c.exec("SELECT "+imapQuote(mailbox), func(r *response) error {
if v, ok := parseUIDValidity(r.line); ok {
uidValidity = v
}
return nil
})
return uidValidity, err
}
// SearchSince returns the UIDs of messages with an internal date on or after t.
func (c *Client) SearchSince(t time.Time) ([]uint32, error) {
date := t.UTC().Format("02-Jan-2006")
var uids []uint32
err := c.exec("UID SEARCH SINCE "+date, func(r *response) error {
if !strings.HasPrefix(strings.ToUpper(r.line), "* SEARCH") {
return nil
}
fields := strings.Fields(r.line)
for _, f := range fields[2:] {
if n, err := strconv.ParseUint(f, 10, 32); err == nil {
uids = append(uids, uint32(n))
}
}
return nil
})
return uids, err
}
// FetchResult is one message returned by Fetch.
type FetchResult struct {
UID uint32
GmailMsgID string
GmailThreadID string
Raw []byte
}
// fetchChunk bounds how many UIDs go into a single FETCH command.
const fetchChunk = 20
// Fetch retrieves the full RFC822 source for each UID, in chunks.
func (c *Client) Fetch(uids []uint32) ([]FetchResult, error) {
var out []FetchResult
for start := 0; start < len(uids); start += fetchChunk {
end := start + fetchChunk
if end > len(uids) {
end = len(uids)
}
set := uidSet(uids[start:end])
var batch []FetchResult
err := c.exec("UID FETCH "+set+" (UID X-GM-MSGID X-GM-THRID BODY.PEEK[])", func(r *response) error {
if fr, ok := parseFetch(r); ok {
batch = append(batch, fr)
}
return nil
})
if err != nil {
return out, err
}
out = append(out, batch...)
}
return out, nil
}
// Logout sends LOGOUT and closes the connection.
func (c *Client) Logout() error {
err := c.exec("LOGOUT", nil)
_ = c.conn.Close()
return err
}
// Close closes the connection without a graceful logout.
func (c *Client) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
// --- protocol plumbing ------------------------------------------------------
func (c *Client) exec(command string, onUntagged func(*response) error) error {
c.tagSeq++
tag := fmt.Sprintf("A%04d", c.tagSeq)
if err := c.writeLine(tag + " " + command); err != nil {
return err
}
for {
resp, err := c.readResponse()
if err != nil {
return err
}
switch {
case strings.HasPrefix(resp.line, tag+" "):
status := strings.TrimSpace(strings.TrimPrefix(resp.line, tag+" "))
if strings.HasPrefix(strings.ToUpper(status), "OK") {
return nil
}
return &ServerError{Command: command, Response: status}
case strings.HasPrefix(resp.line, "* "):
if onUntagged != nil {
if err := onUntagged(resp); err != nil {
return err
}
}
case strings.HasPrefix(resp.line, "+ "):
return fmt.Errorf("imap server requested a continuation, which this client does not support: %s", resp.line)
default:
// Ignore unexpected non-tagged lines rather than desynchronising.
}
}
}
func (c *Client) writeLine(s string) error {
if err := c.conn.SetWriteDeadline(time.Now().Add(c.timeout)); err != nil {
return err
}
if _, err := c.conn.Write([]byte(s + "\r\n")); err != nil {
return fmt.Errorf("imap write: %w", err)
}
return nil
}
// readResponse reads one logical IMAP response, resolving literals.
func (c *Client) readResponse() (*response, error) {
var sb strings.Builder
var lits [][]byte
for {
line, err := c.readLine()
if err != nil {
return nil, err
}
if n, ok := literalLen(line); ok {
sb.WriteString(line[:strings.LastIndexByte(line, '{')])
buf := make([]byte, n)
if _, err := io.ReadFull(c.br, buf); err != nil {
return nil, fmt.Errorf("read literal: %w", err)
}
lits = append(lits, buf)
continue
}
sb.WriteString(line)
return &response{line: sb.String(), literals: lits}, nil
}
}
func (c *Client) readLine() (string, error) {
if err := c.conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
return "", err
}
line, err := c.br.ReadString('\n')
if err != nil && !(err == io.EOF && line != "") {
return "", fmt.Errorf("imap read: %w", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
// literalLen reports the byte count of a trailing `{n}` literal marker.
func literalLen(line string) (int, bool) {
if !strings.HasSuffix(line, "}") {
return 0, false
}
i := strings.LastIndexByte(line, '{')
if i < 0 {
return 0, false
}
n, err := strconv.Atoi(line[i+1 : len(line)-1])
if err != nil || n < 0 {
return 0, false
}
return n, true
}
var (
uidValidityRE = regexp.MustCompile(`(?i)\[UIDVALIDITY (\d+)\]`)
fetchUIDRE = regexp.MustCompile(`(?i)(?:^|[\s(])UID (\d+)`)
fetchMsgIDRE = regexp.MustCompile(`(?i)(?:^|[\s(])X-GM-MSGID (\d+)`)
fetchThrIDRE = regexp.MustCompile(`(?i)(?:^|[\s(])X-GM-THRID (\d+)`)
)
func parseUIDValidity(line string) (uint32, bool) {
m := uidValidityRE.FindStringSubmatch(line)
if m == nil {
return 0, false
}
n, err := strconv.ParseUint(m[1], 10, 32)
if err != nil {
return 0, false
}
return uint32(n), true
}
func parseFetch(r *response) (FetchResult, bool) {
if len(r.literals) == 0 || !strings.Contains(strings.ToUpper(r.line), "FETCH") {
return FetchResult{}, false
}
fr := FetchResult{Raw: r.literals[0]}
if m := fetchUIDRE.FindStringSubmatch(r.line); m != nil {
if n, err := strconv.ParseUint(m[1], 10, 32); err == nil {
fr.UID = uint32(n)
}
}
if m := fetchMsgIDRE.FindStringSubmatch(r.line); m != nil {
fr.GmailMsgID = m[1]
}
if m := fetchThrIDRE.FindStringSubmatch(r.line); m != nil {
fr.GmailThreadID = m[1]
}
return fr, true
}
func uidSet(uids []uint32) string {
parts := make([]string, len(uids))
for i, u := range uids {
parts[i] = strconv.FormatUint(uint64(u), 10)
}
return strings.Join(parts, ",")
}
// imapQuote quotes a string as an IMAP quoted-string.
func imapQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}

View File

@@ -0,0 +1,167 @@
package mailfetch
import (
"bufio"
"bytes"
"errors"
"net"
"strings"
"testing"
"time"
)
// fakeConn is an in-memory net.Conn for protocol tests.
type fakeConn struct {
r *bytes.Reader
w bytes.Buffer
}
func newFakeConn(script string) *fakeConn {
return &fakeConn{r: bytes.NewReader([]byte(script))}
}
func (c *fakeConn) Read(p []byte) (int, error) { return c.r.Read(p) }
func (c *fakeConn) Write(p []byte) (int, error) { return c.w.Write(p) }
func (c *fakeConn) Close() error { return nil }
func (c *fakeConn) LocalAddr() net.Addr { return dummyAddr{} }
func (c *fakeConn) RemoteAddr() net.Addr { return dummyAddr{} }
func (c *fakeConn) SetDeadline(time.Time) error { return nil }
func (c *fakeConn) SetReadDeadline(time.Time) error {
return nil
}
func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil }
type dummyAddr struct{}
func (dummyAddr) Network() string { return "fake" }
func (dummyAddr) String() string { return "fake" }
func newFakeClient(script string) (*Client, *fakeConn) {
fc := newFakeConn(script)
return &Client{conn: fc, br: bufio.NewReader(fc), timeout: time.Second}, fc
}
func TestReadResponseSingleLiteral(t *testing.T) {
script := "* 1 FETCH (UID 10 BODY[] {5}\r\nHELLO)\r\n"
c, _ := newFakeClient(script)
resp, err := c.readResponse()
if err != nil {
t.Fatalf("readResponse() error = %v", err)
}
if len(resp.literals) != 1 || string(resp.literals[0]) != "HELLO" {
t.Fatalf("literals = %q, want [HELLO]", resp.literals)
}
if !strings.Contains(resp.line, "UID 10") || !strings.Contains(resp.line, ")") {
t.Errorf("assembled line = %q, want UID and closing paren", resp.line)
}
}
func TestReadResponseLiteralAtEnd(t *testing.T) {
script := "* 1 FETCH (BODY[] {5}\r\nHELLO\r\n\r\n"
c, _ := newFakeClient(script)
resp, err := c.readResponse()
if err != nil {
t.Fatalf("readResponse() error = %v", err)
}
if len(resp.literals) != 1 || string(resp.literals[0]) != "HELLO" {
t.Fatalf("literals = %q, want [HELLO]", resp.literals)
}
}
func TestSearchSince(t *testing.T) {
script := "* SEARCH 10 11 12\r\nA0001 OK SEARCH completed\r\n"
c, fc := newFakeClient(script)
uids, err := c.SearchSince(time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("SearchSince() error = %v", err)
}
want := []uint32{10, 11, 12}
if len(uids) != len(want) {
t.Fatalf("uids = %v, want %v", uids, want)
}
for i := range want {
if uids[i] != want[i] {
t.Errorf("uids[%d] = %d, want %d", i, uids[i], want[i])
}
}
if !strings.Contains(fc.w.String(), "UID SEARCH SINCE 02-Jan-2026") {
t.Errorf("command sent = %q, want UID SEARCH SINCE 02-Jan-2026", fc.w.String())
}
}
func TestFetchParsesGmailIDs(t *testing.T) {
script := "* 1 FETCH (UID 10 X-GM-MSGID 1234000000000000000 X-GM-THRID 999 BODY[] {5}\r\nHELLO)\r\n" +
"A0001 OK FETCH completed\r\n"
c, _ := newFakeClient(script)
results, err := c.Fetch([]uint32{10})
if err != nil {
t.Fatalf("Fetch() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("results = %d, want 1", len(results))
}
if results[0].UID != 10 {
t.Errorf("UID = %d, want 10", results[0].UID)
}
if results[0].GmailMsgID != "1234000000000000000" {
t.Errorf("GmailMsgID = %q", results[0].GmailMsgID)
}
if results[0].GmailThreadID != "999" {
t.Errorf("GmailThreadID = %q", results[0].GmailThreadID)
}
if string(results[0].Raw) != "HELLO" {
t.Errorf("Raw = %q, want HELLO", results[0].Raw)
}
}
func TestLoginFailureIsServerError(t *testing.T) {
script := "* OK greeting\r\nA0001 NO [AUTHENTICATIONFAILED] Invalid credentials\r\n"
c, _ := newFakeClient(script)
err := c.Login("user@example.test", "wrong")
if err == nil {
t.Fatal("Login() error = nil, want failure")
}
var se *ServerError
if !errors.As(err, &se) {
t.Fatalf("Login() error = %T, want *ServerError", err)
}
if !strings.Contains(se.Response, "AUTHENTICATIONFAILED") {
t.Errorf("ServerError.Response = %q", se.Response)
}
}
func TestLiteralLen(t *testing.T) {
cases := []struct {
in string
n int
ok bool
}{
{"* 1 FETCH (BODY[] {12}", 12, true},
{"* 1 FETCH (BODY[] {abc}", 0, false},
{"* 1 FETCH (BODY[])", 0, false},
{"* SEARCH 1 2", 0, false},
}
for _, tc := range cases {
n, ok := literalLen(tc.in)
if n != tc.n || ok != tc.ok {
t.Errorf("literalLen(%q) = (%d, %v), want (%d, %v)", tc.in, n, ok, tc.n, tc.ok)
}
}
}
func TestImapQuote(t *testing.T) {
if got := imapQuote(`pa"ss\word`); got != `"pa\"ss\\word"` {
t.Errorf("imapQuote() = %q", got)
}
}
func TestUIDSet(t *testing.T) {
if got := uidSet([]uint32{1, 2, 3}); got != "1,2,3" {
t.Errorf("uidSet() = %q, want 1,2,3", got)
}
}

View File

@@ -0,0 +1,249 @@
package mailfetch
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"regexp"
"strings"
"time"
"unicode/utf8"
)
// maxBodyBytes caps how much body text is read from one message.
const maxBodyBytes = 1 << 20 // 1 MiB
// ParsedMessage is the subset of an RFC822 message the agent stores.
type ParsedMessage struct {
From string
To string
Subject string
Date time.Time
SourceAddress string
// SourceHeader records which header the source address came from, so FR-2
// decisions are auditable.
SourceHeader string
BodyText string
}
// ParseMessage parses a raw RFC822 message.
func ParseMessage(raw []byte) (*ParsedMessage, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("parse RFC822 message: %w", err)
}
h := msg.Header
pm := &ParsedMessage{
From: decodeHeader(h.Get("From")),
To: decodeHeader(h.Get("To")),
Subject: decodeHeader(h.Get("Subject")),
Date: parseDate(h.Get("Date")),
}
pm.SourceAddress, pm.SourceHeader = SourceAddress(textproto.MIMEHeader(h))
pm.BodyText = extractBody(textproto.MIMEHeader(h), msg.Body)
return pm, nil
}
// SourceAddress resolves the address the mail arrived at (FR-2).
//
// Order:
// 1. X-Pm-Original-To (added by PurelyMail)
// 2. To
// 3. the PurelyMail "Received ... for <address>" clause
// 4. Return-Path
//
// Delivered-To is deliberately never used: Gmail overwrites it.
func SourceAddress(h textproto.MIMEHeader) (address, header string) {
if v := headerAddress(h.Get("X-Pm-Original-To")); v != "" {
return v, "X-Pm-Original-To"
}
if v := headerAddress(h.Get("To")); v != "" {
return v, "To"
}
for _, rv := range h.Values("Received") {
if v := receivedFor(rv); v != "" {
return v, "Received"
}
}
if v := headerAddress(h.Get("Return-Path")); v != "" {
return v, "Return-Path"
}
return "", ""
}
// headerAddress returns the first address in a header value.
func headerAddress(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if list, err := mail.ParseAddressList(v); err == nil && len(list) > 0 {
return strings.TrimSpace(list[0].Address)
}
if a, err := mail.ParseAddress(v); err == nil {
return strings.TrimSpace(a.Address)
}
v = strings.TrimSpace(strings.TrimPrefix(v, "<"))
if i := strings.IndexAny(v, ">,;"); i >= 0 {
v = v[:i]
}
return strings.Trim(strings.TrimSpace(v), "<>")
}
var receivedForRE = regexp.MustCompile(`(?i)\bfor\s+<?([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+[A-Za-z0-9])>?`)
// receivedFor extracts the address from a "Received: ... for <addr>" clause.
func receivedFor(v string) string {
m := receivedForRE.FindStringSubmatch(v)
if m == nil {
return ""
}
return strings.Trim(m[1], "<>")
}
func decodeHeader(v string) string {
if v == "" {
return ""
}
dec := mime.WordDecoder{}
if s, err := dec.DecodeHeader(v); err == nil {
return s
}
return v
}
func parseDate(v string) time.Time {
if t, err := mail.ParseDate(v); err == nil {
return t.UTC()
}
for _, layout := range []string{time.RFC1123Z, time.RFC1123, "Mon, 2 Jan 2006 15:04:05 -0700"} {
if t, err := time.Parse(layout, v); err == nil {
return t.UTC()
}
}
return time.Time{}
}
// --- body decoding ----------------------------------------------------------
func extractBody(h textproto.MIMEHeader, body io.Reader) string {
mediaType, params := parseMediaType(h.Get("Content-Type"))
decoded := decodeTransfer(h.Get("Content-Transfer-Encoding"), body)
switch {
case mediaType == "" || mediaType == "text/plain":
return toUTF8(readAll(decoded))
case mediaType == "text/html":
return htmlToText(toUTF8(readAll(decoded)))
case strings.HasPrefix(mediaType, "multipart/"):
return extractMultipart(params["boundary"], decoded)
default:
// application/*, image/*, … have no text to show.
return ""
}
}
func extractMultipart(boundary string, r io.Reader) string {
if boundary == "" {
return ""
}
mr := multipart.NewReader(r, boundary)
var plain, html []string
for {
part, err := mr.NextPart()
if err != nil {
break
}
mediaType, _ := parseMediaType(part.Header.Get("Content-Type"))
sub := extractBody(textproto.MIMEHeader(part.Header), part)
_ = part.Close()
if sub == "" {
continue
}
if mediaType == "text/html" {
html = append(html, sub)
} else {
plain = append(plain, sub)
}
}
if len(plain) > 0 {
return strings.TrimSpace(strings.Join(plain, "\n"))
}
return strings.TrimSpace(strings.Join(html, "\n"))
}
func parseMediaType(v string) (string, map[string]string) {
if strings.TrimSpace(v) == "" {
return "", nil
}
mt, params, err := mime.ParseMediaType(v)
if err != nil {
head := strings.TrimSpace(strings.SplitN(v, ";", 2)[0])
return strings.ToLower(head), nil
}
return strings.ToLower(mt), params
}
func decodeTransfer(encoding string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
return quotedprintable.NewReader(r)
default:
return r
}
}
func readAll(r io.Reader) []byte {
b, err := io.ReadAll(io.LimitReader(r, maxBodyBytes))
if err != nil {
return b
}
return b
}
// toUTF8 returns valid UTF-8 text, falling back to a Latin-1 interpretation of
// bytes that are not valid UTF-8 (no charset table is available without adding a
// dependency; forwarded personal mail is overwhelmingly UTF-8 or Latin-1).
func toUTF8(b []byte) string {
if utf8.Valid(b) {
return strings.TrimSpace(string(b))
}
var sb strings.Builder
for _, c := range b {
sb.WriteRune(rune(c))
}
return strings.TrimSpace(sb.String())
}
var (
htmlBlockRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(?:script|style)>`)
htmlTagRE = regexp.MustCompile(`(?s)<[^>]*>`)
htmlSpaceRE = regexp.MustCompile(`[ \t]+`)
htmlNLRE = regexp.MustCompile(`\n{3,}`)
)
func htmlToText(s string) string {
if s == "" {
return ""
}
s = htmlBlockRE.ReplaceAllString(s, " ")
s = htmlTagRE.ReplaceAllString(s, " ")
s = strings.NewReplacer(
"&nbsp;", " ",
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&#39;", "'",
).Replace(s)
s = htmlSpaceRE.ReplaceAllString(s, " ")
s = htmlNLRE.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}

View File

@@ -0,0 +1,174 @@
package mailfetch
import (
"net/textproto"
"strings"
"testing"
)
// realForwardedHeaders mirrors the header set seen on a real forwarded message
// (AGENTS.md §13, message id 619). X-Pm-Original-To must win and Delivered-To
// must never be used.
func TestSourceAddressXPMOriginalToWins(t *testing.T) {
h := textproto.MIMEHeader{}
h.Set("X-Pm-Original-To", "Hello@where-woof.com")
h.Set("To", "Hello@where-woof.com")
h.Set("Return-Path", "<Hello@where-woof.com>")
h.Set("Delivered-To", "samuelrolfe@gmail.com")
addr, header := SourceAddress(h)
if addr != "Hello@where-woof.com" {
t.Errorf("address = %q, want %q", addr, "Hello@where-woof.com")
}
if header != "X-Pm-Original-To" {
t.Errorf("header = %q, want %q", header, "X-Pm-Original-To")
}
}
func TestSourceAddressFallsBackToTo(t *testing.T) {
h := textproto.MIMEHeader{}
h.Set("To", "Second Choice <second@example.test>")
h.Set("Delivered-To", "samuelrolfe@gmail.com")
addr, header := SourceAddress(h)
if addr != "second@example.test" {
t.Errorf("address = %q, want %q", addr, "second@example.test")
}
if header != "To" {
t.Errorf("header = %q, want %q", header, "To")
}
}
func TestSourceAddressFallsBackToReceivedFor(t *testing.T) {
h := textproto.MIMEHeader{}
h.Add("Received", "by mx.example.test with ESMTPS id abc")
h.Add("Received", "from mail.purelymail.com by mx.example.test for <relay@where-woof.com>; Mon, 1 Jan 2026 00:00:00 +0000")
h.Set("Return-Path", "<fallback@example.test>")
addr, header := SourceAddress(h)
if addr != "relay@where-woof.com" {
t.Errorf("address = %q, want %q", addr, "relay@where-woof.com")
}
if header != "Received" {
t.Errorf("header = %q, want %q", header, "Received")
}
}
func TestSourceAddressFallsBackToReturnPath(t *testing.T) {
h := textproto.MIMEHeader{}
h.Set("Return-Path", "<sender@example.test>")
addr, header := SourceAddress(h)
if addr != "sender@example.test" {
t.Errorf("address = %q, want %q", addr, "sender@example.test")
}
if header != "Return-Path" {
t.Errorf("header = %q, want %q", header, "Return-Path")
}
}
func TestSourceAddressIgnoresDeliveredTo(t *testing.T) {
h := textproto.MIMEHeader{}
h.Set("Delivered-To", "samuelrolfe@gmail.com")
addr, header := SourceAddress(h)
if addr != "" || header != "" {
t.Errorf("SourceAddress() = (%q, %q), want empty: Delivered-To must not be used", addr, header)
}
}
func TestParseMessagePlain(t *testing.T) {
raw := strings.Join([]string{
"From: favrolfe@gmail.com",
"To: Hello@where-woof.com",
"X-Pm-Original-To: Hello@where-woof.com",
"Subject: =?UTF-8?Q?Caf=C3=A9_meeting?=",
"Date: Mon, 02 Oct 2006 15:04:05 -0700",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: quoted-printable",
"",
"Hello there=2C this is a test.",
}, "\r\n")
pm, err := ParseMessage([]byte(raw))
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if pm.Subject != "Café meeting" {
t.Errorf("Subject = %q, want %q", pm.Subject, "Café meeting")
}
if pm.SourceAddress != "Hello@where-woof.com" || pm.SourceHeader != "X-Pm-Original-To" {
t.Errorf("source = (%q, %q), want (Hello@where-woof.com, X-Pm-Original-To)", pm.SourceAddress, pm.SourceHeader)
}
if !strings.Contains(pm.BodyText, "Hello there, this is a test.") {
t.Errorf("BodyText = %q, want quoted-printable decoded text", pm.BodyText)
}
}
func TestParseMessageMultipartPrefersPlain(t *testing.T) {
raw := strings.Join([]string{
"From: sender@example.test",
"To: Hello@where-woof.com",
"Subject: multipart",
"MIME-Version: 1.0",
`Content-Type: multipart/alternative; boundary="BOUND"`,
"",
"--BOUND",
"Content-Type: text/plain; charset=utf-8",
"",
"plain part wins",
"--BOUND",
"Content-Type: text/html; charset=utf-8",
"",
"<p>html part <b>loses</b></p>",
"--BOUND--",
"",
}, "\r\n")
pm, err := ParseMessage([]byte(raw))
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if !strings.Contains(pm.BodyText, "plain part wins") {
t.Errorf("BodyText = %q, want the text/plain part", pm.BodyText)
}
if strings.Contains(pm.BodyText, "html part") {
t.Errorf("BodyText = %q, must not contain the HTML part", pm.BodyText)
}
}
func TestParseMessageHTMLOnlyIsStripped(t *testing.T) {
raw := strings.Join([]string{
"From: sender@example.test",
"Subject: html only",
"Content-Type: text/html; charset=utf-8",
"",
"<html><body><h1>Hello</h1><p>World&nbsp;&amp; friends</p></body></html>",
}, "\r\n")
pm, err := ParseMessage([]byte(raw))
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if !strings.Contains(pm.BodyText, "Hello") || !strings.Contains(pm.BodyText, "World & friends") {
t.Errorf("BodyText = %q, want stripped HTML text", pm.BodyText)
}
if strings.Contains(pm.BodyText, "<") {
t.Errorf("BodyText = %q, want no tags", pm.BodyText)
}
}
func TestHeaderAddressParsing(t *testing.T) {
cases := []struct{ in, want string }{
{"Hello@where-woof.com", "Hello@where-woof.com"},
{"<Hello@where-woof.com>", "Hello@where-woof.com"},
{"Sam Rolfe <sam@example.test>", "sam@example.test"},
{"a@example.test, b@example.test", "a@example.test"},
{"", ""},
}
for _, tc := range cases {
if got := headerAddress(tc.in); got != tc.want {
t.Errorf("headerAddress(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}