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:
193
internal/store/unsubscribe_test.go
Normal file
193
internal/store/unsubscribe_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrationV6AddsUnsubscribeColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
|
||||
// Simulate a v5 database: messages has no List-Unsubscribe columns and the
|
||||
// unsubscribe table has no url/updated_at. Opening it must apply migration
|
||||
// v6.
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("open old db: %v", err)
|
||||
}
|
||||
stmts := []string{
|
||||
`CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gmail_id TEXT NOT NULL UNIQUE,
|
||||
thread_id TEXT, message_id_header TEXT, from_addr TEXT, to_addr TEXT,
|
||||
subject TEXT, body_text TEXT, source_address TEXT, date_sent TEXT,
|
||||
imap_uid INTEGER, imap_uidvalidity INTEGER,
|
||||
folder TEXT NOT NULL DEFAULT 'inbox',
|
||||
mailbox TEXT NOT NULL DEFAULT 'INBOX',
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')))`,
|
||||
`CREATE TABLE unsubscribe (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'ignored')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')))`,
|
||||
`CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))`,
|
||||
`INSERT INTO schema_migrations (version) VALUES (5)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := db.ExecContext(ctx, q); err != nil {
|
||||
t.Fatalf("seed old db: %v", err)
|
||||
}
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
s, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() on v5 database error = %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
v, err := s.SchemaVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion() error = %v", err)
|
||||
}
|
||||
if v < 6 {
|
||||
t.Errorf("SchemaVersion() = %d, want >= 6", v)
|
||||
}
|
||||
for _, tc := range []struct{ table, col string }{
|
||||
{"messages", "list_unsubscribe"},
|
||||
{"messages", "list_unsubscribe_post"},
|
||||
{"unsubscribe", "url"},
|
||||
{"unsubscribe", "updated_at"},
|
||||
} {
|
||||
var n int
|
||||
q := `SELECT COUNT(*) FROM pragma_table_info('` + tc.table + `') WHERE name = ?`
|
||||
if err := s.DB().QueryRowContext(ctx, q, tc.col).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s.%s: %v", tc.table, tc.col, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("%s.%s missing after migration v6", tc.table, tc.col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeRecordUpsert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t) // reuses the store helper from categories_test.go
|
||||
|
||||
if _, ok, err := s.UnsubscribeByDomain(ctx, "brand.example"); err != nil || ok {
|
||||
t.Fatalf("UnsubscribeByDomain() = (_, %v, %v), want a miss", ok, err)
|
||||
}
|
||||
|
||||
if err := s.SetUnsubscribeStatus(ctx, "brand.example", UnsubscribeStatusIgnored, "https://brand.example/u"); err != nil {
|
||||
t.Fatalf("SetUnsubscribeStatus(ignore) error = %v", err)
|
||||
}
|
||||
rec, ok, err := s.UnsubscribeByDomain(ctx, "brand.example")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("UnsubscribeByDomain() = (_, %v, %v), want a hit", ok, err)
|
||||
}
|
||||
if rec.Status != UnsubscribeStatusIgnored || rec.URL != "https://brand.example/u" {
|
||||
t.Errorf("record = %+v, want ignored with url", rec)
|
||||
}
|
||||
|
||||
// A later mark with no URL must keep the recorded link.
|
||||
if err := s.SetUnsubscribeStatus(ctx, "brand.example", UnsubscribeStatusDone, ""); err != nil {
|
||||
t.Fatalf("SetUnsubscribeStatus(done) error = %v", err)
|
||||
}
|
||||
rec, _, _ = s.UnsubscribeByDomain(ctx, "brand.example")
|
||||
if rec.Status != UnsubscribeStatusDone {
|
||||
t.Errorf("status = %q, want done", rec.Status)
|
||||
}
|
||||
if rec.URL != "https://brand.example/u" {
|
||||
t.Errorf("url = %q, want the earlier link preserved", rec.URL)
|
||||
}
|
||||
|
||||
if err := s.SetUnsubscribeStatus(ctx, "brand.example", "bogus", ""); err == nil {
|
||||
t.Error("SetUnsubscribeStatus(bogus) accepted an unknown status")
|
||||
}
|
||||
|
||||
records, err := s.ListUnsubscribe(ctx)
|
||||
if err != nil || len(records) != 1 {
|
||||
t.Fatalf("ListUnsubscribe() = (%v, %v), want one record", records, err)
|
||||
}
|
||||
|
||||
if err := s.ClearUnsubscribe(ctx, "brand.example"); err != nil {
|
||||
t.Fatalf("ClearUnsubscribe() error = %v", err)
|
||||
}
|
||||
if err := s.SetUnsubscribeStatus(ctx, "", UnsubscribeStatusIgnored, ""); err == nil {
|
||||
t.Error("SetUnsubscribeStatus accepted an empty domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeCandidates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
seed := func(gmailID, from, body, header string) {
|
||||
t.Helper()
|
||||
if _, err := s.UpsertMessage(ctx, &Message{
|
||||
GmailID: gmailID,
|
||||
FromAddr: from,
|
||||
Subject: "subject " + gmailID,
|
||||
BodyText: body,
|
||||
ListUnsubscribe: header,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", gmailID, err)
|
||||
}
|
||||
}
|
||||
seed("header-msg", "Support <support@brand.example>", "hello", "<https://brand.example/u>")
|
||||
seed("body-msg", "Shop <deals@shop.example>", "You can unsubscribe at https://shop.example/opt-out now.", "")
|
||||
seed("plain-msg", "Friend <friend@example.com>", "Just saying hi.", "")
|
||||
|
||||
got, err := s.UnsubscribeCandidates(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("UnsubscribeCandidates() error = %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("candidates = %d, want 2 (%+v)", len(got), got)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, c := range got {
|
||||
ids[c.Subject] = true
|
||||
}
|
||||
if !ids["subject body-msg"] || !ids["subject header-msg"] {
|
||||
t.Errorf("candidates = %+v, want the header and body messages", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesVersionsAndRestore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
if _, err := s.SaveRules(ctx, "from contains \"a\" => category=Info"); err != nil {
|
||||
t.Fatalf("SaveRules() error = %v", err)
|
||||
}
|
||||
if _, err := s.SaveRules(ctx, "from contains \"b\" => category=Action"); err != nil {
|
||||
t.Fatalf("SaveRules() error = %v", err)
|
||||
}
|
||||
|
||||
versions, err := s.RulesVersions(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RulesVersions() error = %v", err)
|
||||
}
|
||||
if len(versions) < 3 {
|
||||
t.Fatalf("RulesVersions() = %d, want >= 3 (seed + 2 saves)", len(versions))
|
||||
}
|
||||
if versions[0].Version != 3 {
|
||||
t.Errorf("newest version = %d, want 3", versions[0].Version)
|
||||
}
|
||||
|
||||
text, ok, err := s.RuleByVersion(ctx, 2)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("RuleByVersion(2) = (_, %v, %v), want a hit", ok, err)
|
||||
}
|
||||
if text != "from contains \"a\" => category=Info" {
|
||||
t.Errorf("RuleByVersion(2) = %q", text)
|
||||
}
|
||||
if _, ok, _ := s.RuleByVersion(ctx, 999); ok {
|
||||
t.Error("RuleByVersion(999) reported a hit")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user