Files
email_agent/internal/mailfetch/message_test.go
Sam Rolfe 00bb0e05c4 Phase 6: rules editor + unsubscribe detection (AUD-72)
- FR-11 rules editor: plain-text area at /rules, saved and versioned
  (SaveRules appends a version; restore re-saves an old version), parse
  errors surfaced, one-click re-classify. The reply model now reads the
  rules document too (FR-11).
- FR-7 unsubscribe: parse and store List-Unsubscribe / List-Unsubscribe-Post
  (migration v6), detect email and web links (internal/unsubscribe), a
  domain-level ignore list, and an explicit record of what was marked. No
  code path contacts an unsubscribe link; acting on one needs Sam's approval.
- Migration v6 is additive; unsubscribe gains url/updated_at.
- Tests: detector, parser, store migration/records/candidates, rules editor,
  unsubscribe handlers, message-detail rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 22:27:08 +11:00

206 lines
6.5 KiB
Go

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)
}
}
}
func TestParseMessageCapturesUnsubscribeHeaders(t *testing.T) {
raw := "From: Heritage Fruit Trees <support@heritagefruittrees.com.au>\r\n" +
"To: samuelrolfe@gmail.com\r\n" +
"X-Pm-Original-To: hello@where-woof.com\r\n" +
"Subject: New workshops\r\n" +
"List-Unsubscribe: <mailto:unsub@heritagefruittrees.com.au?subject=unsubscribe>, <https://heritagefruittrees.com.au/u?id=1>\r\n" +
"List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n" +
"Message-Id: <abc@mail.example>\r\n" +
"Date: Mon, 01 Jan 2026 10:00:00 +0000\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" +
"Hello, you can unsubscribe at https://heritagefruittrees.com.au/u?id=1.\r\n"
pm, err := ParseMessage([]byte(raw))
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if !strings.Contains(pm.ListUnsubscribe, "mailto:unsub@heritagefruittrees.com.au") {
t.Errorf("ListUnsubscribe = %q", pm.ListUnsubscribe)
}
if !strings.Contains(pm.ListUnsubscribe, "https://heritagefruittrees.com.au/u?id=1") {
t.Errorf("ListUnsubscribe = %q", pm.ListUnsubscribe)
}
if pm.ListUnsubscribePost != "List-Unsubscribe=One-Click" {
t.Errorf("ListUnsubscribePost = %q", pm.ListUnsubscribePost)
}
if pm.SourceAddress != "hello@where-woof.com" {
t.Errorf("SourceAddress = %q", pm.SourceAddress)
}
}