- 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>
194 lines
6.2 KiB
Go
194 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|