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,182 @@
package server
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
func seedUnsubMessage(t *testing.T, srv *Server) int64 {
t.Helper()
id, err := srv.store.UpsertMessage(context.Background(), &store.Message{
GmailID: "unsub-1",
FromAddr: "Heritage Fruit Trees <support@heritagefruittrees.com.au>",
Subject: "New workshops released",
BodyText: "Hello, you have received a newsletter. Unsubscribe at https://heritagefruittrees.com.au/u?id=1.",
SourceAddress: "samuelrolfe@gmail.com",
ListUnsubscribe: "<mailto:unsub@heritagefruittrees.com.au?subject=unsubscribe>, <https://heritagefruittrees.com.au/u?id=1>",
DateSent: time.Now().UTC(),
})
if err != nil {
t.Fatalf("seed message: %v", err)
}
return id
}
func TestUnsubscribePageDetectsLinks(t *testing.T) {
srv := newTestServer(t)
seedUnsubMessage(t, srv)
session, _, _ := login(t, srv)
rec := getWithSession(t, srv, "/unsubscribe", session)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Detected links", "heritagefruittrees.com.au",
"https://heritagefruittrees.com.au/u?id=1",
"mailto:unsub@heritagefruittrees.com.au",
"Ignore domain", "Mark for unsubscribe", "Ignore list",
} {
if !strings.Contains(body, want) {
t.Errorf("unsubscribe page missing %q", want)
}
}
}
func TestUnsubscribeIgnoreAndUnignore(t *testing.T) {
srv := newTestServer(t)
seedUnsubMessage(t, srv)
session, csrfCookie, csrf := login(t, srv)
ctx := context.Background()
rec := postForm(t, srv, "/unsubscribe/ignore", session, csrfCookie, url.Values{
"csrf": {csrf}, "domain": {"heritagefruittrees.com.au"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("ignore status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
rec2, ok, err := srv.store.UnsubscribeByDomain(ctx, "heritagefruittrees.com.au")
if err != nil || !ok {
t.Fatalf("UnsubscribeByDomain() = (_, %v, %v), want a hit", ok, err)
}
if rec2.Status != store.UnsubscribeStatusIgnored {
t.Errorf("status = %q, want ignored", rec2.Status)
}
body := getWithSession(t, srv, "/unsubscribe", session).Body.String()
if !strings.Contains(body, "ignored") || !strings.Contains(body, "Un-ignore") {
t.Errorf("ignore list did not render the ignored domain")
}
rec = postForm(t, srv, "/unsubscribe/unignore", session, csrfCookie, url.Values{
"csrf": {csrf}, "domain": {"heritagefruittrees.com.au"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("unignore status = %d, want 303", rec.Code)
}
if _, ok, _ := srv.store.UnsubscribeByDomain(ctx, "heritagefruittrees.com.au"); ok {
t.Error("domain still on the list after unignore")
}
}
func TestUnsubscribeMarkRecordsWithoutFollowingLink(t *testing.T) {
srv := newTestServer(t)
seedUnsubMessage(t, srv)
session, csrfCookie, csrf := login(t, srv)
ctx := context.Background()
// Without confirmation the handler refuses and records nothing.
rec := postForm(t, srv, "/unsubscribe/mark", session, csrfCookie, url.Values{
"csrf": {csrf}, "domain": {"heritagefruittrees.com.au"},
"url": {"https://heritagefruittrees.com.au/u?id=1"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("mark without confirm status = %d, want 303", rec.Code)
}
if _, ok, _ := srv.store.UnsubscribeByDomain(ctx, "heritagefruittrees.com.au"); ok {
t.Error("unconfirmed mark recorded a domain")
}
rec = postForm(t, srv, "/unsubscribe/mark", session, csrfCookie, url.Values{
"csrf": {csrf}, "domain": {"heritagefruittrees.com.au"},
"url": {"https://heritagefruittrees.com.au/u?id=1"}, "confirm": {"yes"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("mark status = %d, want 303", rec.Code)
}
rec2, ok, err := srv.store.UnsubscribeByDomain(ctx, "heritagefruittrees.com.au")
if err != nil || !ok {
t.Fatalf("UnsubscribeByDomain() = (_, %v, %v), want a hit", ok, err)
}
if rec2.Status != store.UnsubscribeStatusDone {
t.Errorf("status = %q, want done", rec2.Status)
}
if rec2.URL != "https://heritagefruittrees.com.au/u?id=1" {
t.Errorf("url = %q, want the recorded link", rec2.URL)
}
}
func TestUnsubscribeMutationsRequireCSRF(t *testing.T) {
srv := newTestServer(t)
session, _, _ := login(t, srv)
for _, path := range []string{"/unsubscribe/ignore", "/unsubscribe/unignore", "/unsubscribe/mark"} {
rec := postForm(t, srv, path, session, nil, url.Values{"domain": {"x.example"}})
if rec.Code != http.StatusForbidden {
t.Errorf("%s without CSRF status = %d, want 403", path, rec.Code)
}
}
}
func TestUnsubscribeMarkRejectsBadDomain(t *testing.T) {
srv := newTestServer(t)
session, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/unsubscribe/ignore", session, csrfCookie, url.Values{
"csrf": {csrf}, "domain": {"not a domain!"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if _, ok, _ := srv.store.UnsubscribeByDomain(context.Background(), "not a domain!"); ok {
t.Error("bad domain was stored")
}
}
func TestMessageDetailShowsUnsubscribeLinks(t *testing.T) {
srv := newTestServer(t)
id := seedUnsubMessage(t, srv)
session, _, _ := login(t, srv)
rec := getWithSession(t, srv, "/partials/message?id="+itoa(id), session)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Unsubscribe") || !strings.Contains(body, "heritagefruittrees.com.au/u?id=1") {
t.Errorf("message detail did not show the unsubscribe link")
}
}
func TestCleanDomain(t *testing.T) {
cases := map[string]bool{
"brand.example": true,
"Sub.Brand.example": true,
"x-y.example": true,
"": false,
"nodot": false,
"has space.example": false,
"bad!char.example": false,
}
for in, want := range cases {
if _, ok := cleanDomain(in); ok != want {
t.Errorf("cleanDomain(%q) ok = %v, want %v", in, ok, want)
}
}
}