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:
160
internal/mailfetch/fetch.go
Normal file
160
internal/mailfetch/fetch.go
Normal 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, ",")
|
||||
}
|
||||
40
internal/mailfetch/fetch_test.go
Normal file
40
internal/mailfetch/fetch_test.go
Normal 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
328
internal/mailfetch/imap.go
Normal 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 + `"`
|
||||
}
|
||||
167
internal/mailfetch/imap_test.go
Normal file
167
internal/mailfetch/imap_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
249
internal/mailfetch/message.go
Normal file
249
internal/mailfetch/message.go
Normal 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(
|
||||
" ", " ",
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
""", `"`,
|
||||
"'", "'",
|
||||
).Replace(s)
|
||||
s = htmlSpaceRE.ReplaceAllString(s, " ")
|
||||
s = htmlNLRE.ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
174
internal/mailfetch/message_test.go
Normal file
174
internal/mailfetch/message_test.go
Normal 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 & 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user