- 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>
206 lines
6.5 KiB
Go
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 & 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)
|
|
}
|
|
}
|