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,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")
}
}