- 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>
183 lines
5.9 KiB
Go
183 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|