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>
This commit is contained in:
2026-10-11 22:24:38 +11:00
parent 7c66e4ad31
commit 00bb0e05c4
24 changed files with 2121 additions and 42 deletions

View File

@@ -0,0 +1,126 @@
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)
}
}