- 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>
127 lines
3.7 KiB
Go
127 lines
3.7 KiB
Go
package unsubscribe
|
|
|
|
import "testing"
|
|
|
|
func TestDetectListUnsubscribeHeader(t *testing.T) {
|
|
res := Detect(
|
|
"<mailto:unsub@heritagefruittrees.com.au?subject=unsubscribe>, <https://heritagefruittrees.com.au/u?id=42>",
|
|
"List-Unsubscribe=One-Click",
|
|
"Heritage Fruit Trees <support@heritagefruittrees.com.au>",
|
|
"Special offer this week.",
|
|
)
|
|
if !res.OneClick {
|
|
t.Error("OneClick = false, want true")
|
|
}
|
|
if len(res.Links) != 2 {
|
|
t.Fatalf("Links len = %d, want 2 (%+v)", len(res.Links), res.Links)
|
|
}
|
|
mail := res.Links[0]
|
|
if mail.Method != MethodMailto {
|
|
t.Errorf("mailto method = %q", mail.Method)
|
|
}
|
|
if mail.Domain != "heritagefruittrees.com.au" {
|
|
t.Errorf("mailto domain = %q", mail.Domain)
|
|
}
|
|
if mail.Source != SourceListUnsubscribe {
|
|
t.Errorf("mailto source = %q", mail.Source)
|
|
}
|
|
http := res.Links[1]
|
|
if http.Method != MethodHTTP {
|
|
t.Errorf("http method = %q", http.Method)
|
|
}
|
|
if http.Host != "heritagefruittrees.com.au" {
|
|
t.Errorf("http host = %q", http.Host)
|
|
}
|
|
if http.Domain != "heritagefruittrees.com.au" {
|
|
t.Errorf("http domain = %q", http.Domain)
|
|
}
|
|
}
|
|
|
|
func TestDetectBareHeaderURL(t *testing.T) {
|
|
res := Detect("https://lists.example.com/unsub?u=abc,", "", "News <news@lists.example.com>", "")
|
|
if len(res.Links) != 1 {
|
|
t.Fatalf("Links = %+v, want 1", res.Links)
|
|
}
|
|
if res.Links[0].URL != "https://lists.example.com/unsub?u=abc" {
|
|
t.Errorf("URL = %q", res.Links[0].URL)
|
|
}
|
|
}
|
|
|
|
func TestDetectBodyFallback(t *testing.T) {
|
|
res := Detect("", "", "Shop <deals@shop.example>", "Thanks for subscribing!\nUnsubscribe here: https://shop.example/opt-out?token=1.\nSee you.")
|
|
if res.Header != "" {
|
|
t.Errorf("Header = %q, want empty", res.Header)
|
|
}
|
|
if len(res.Links) != 1 {
|
|
t.Fatalf("Links = %+v, want 1", res.Links)
|
|
}
|
|
if res.Links[0].Source != SourceBody {
|
|
t.Errorf("Source = %q, want %q", res.Links[0].Source, SourceBody)
|
|
}
|
|
if res.Links[0].URL != "https://shop.example/opt-out?token=1" {
|
|
t.Errorf("URL = %q (trailing period should be trimmed)", res.Links[0].URL)
|
|
}
|
|
}
|
|
|
|
func TestDetectIgnoresUnrelatedBodyLinks(t *testing.T) {
|
|
res := Detect("", "", "Friend <friend@example.com>", "Read more at https://example.com/blog")
|
|
if len(res.Links) != 0 {
|
|
t.Fatalf("Links = %+v, want none", res.Links)
|
|
}
|
|
}
|
|
|
|
func TestDetectBodyMailto(t *testing.T) {
|
|
res := Detect("", "", "Brand <hello@brand.example>", "To unsubscribe, email mailto:leave@brand.example")
|
|
if len(res.Links) != 1 || res.Links[0].Method != MethodMailto {
|
|
t.Fatalf("Links = %+v, want one mailto", res.Links)
|
|
}
|
|
}
|
|
|
|
func TestFreeMailSenderUsesLinkHost(t *testing.T) {
|
|
res := Detect(
|
|
"<https://mailer.example.com/u/9>",
|
|
"",
|
|
"Someone <someone@gmail.com>",
|
|
"",
|
|
)
|
|
if len(res.Links) != 1 {
|
|
t.Fatalf("Links = %+v, want 1", res.Links)
|
|
}
|
|
if res.Links[0].Domain != "mailer.example.com" {
|
|
t.Errorf("domain = %q, want link host", res.Links[0].Domain)
|
|
}
|
|
}
|
|
|
|
func TestDetectDeduplicates(t *testing.T) {
|
|
res := Detect(
|
|
"<https://x.example/u>, <https://x.example/u>",
|
|
"",
|
|
"Brand <hello@brand.example>",
|
|
"",
|
|
)
|
|
if len(res.Links) != 1 {
|
|
t.Fatalf("Links = %+v, want 1 after de-duplication", res.Links)
|
|
}
|
|
}
|
|
|
|
func TestDomain(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Support <support@heritagefruittrees.com.au>": "heritagefruittrees.com.au",
|
|
"plain@brand.example": "brand.example",
|
|
"Someone <someone@gmail.com>": "",
|
|
"": "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := Domain(in); got != want {
|
|
t.Errorf("Domain(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmptyInputs(t *testing.T) {
|
|
res := Detect("", "", "", "")
|
|
if len(res.Links) != 0 || res.OneClick {
|
|
t.Fatalf("Detect(empty) = %+v, want zero value", res)
|
|
}
|
|
}
|