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:
182
internal/server/unsubscribe_test.go
Normal file
182
internal/server/unsubscribe_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user