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,177 @@
// Unsubscribe tracking for Phase 6 (FR-7).
//
// Two things are persisted here:
//
// - the raw unsubscribe links detected on a message (messages.list_unsubscribe,
// stored by the mail fetch and parsed by internal/unsubscribe); and
// - the domain-level ignore list and the record of what was marked, in the
// `unsubscribe` table.
//
// The app never follows a link. Marking a domain records Sam's decision; the
// actual unsubscribe stays a manual, explicitly approved action (AUD-72).
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// Unsubscribe statuses. `ignored` is the ignore list; `done` records a domain
// Sam has decided to unsubscribe from.
const (
UnsubscribeStatusDone = "done"
UnsubscribeStatusIgnored = "ignored"
)
// ErrUnsubscribeStatus is returned for an unknown unsubscribe status.
var ErrUnsubscribeStatus = errors.New("unsubscribe status must be done or ignored")
// UnsubscribeRecord is one row of the domain-level tracker.
type UnsubscribeRecord struct {
ID int64
Domain string
Status string
URL string
CreatedAt string
UpdatedAt string
}
// ListUnsubscribe returns every domain record, newest first.
func (s *Store) ListUnsubscribe(ctx context.Context) ([]UnsubscribeRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, domain, status, COALESCE(url, ''), created_at, COALESCE(updated_at, '')
FROM unsubscribe ORDER BY COALESCE(updated_at, created_at) DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("list unsubscribe records: %w", err)
}
defer rows.Close()
var out []UnsubscribeRecord
for rows.Next() {
var r UnsubscribeRecord
if err := rows.Scan(&r.ID, &r.Domain, &r.Status, &r.URL, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan unsubscribe record: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list unsubscribe records: %w", err)
}
return out, nil
}
// UnsubscribeByDomain loads one domain record. ok is false when the domain is
// not tracked.
func (s *Store) UnsubscribeByDomain(ctx context.Context, domain string) (UnsubscribeRecord, bool, error) {
domain = strings.TrimSpace(domain)
if domain == "" {
return UnsubscribeRecord{}, false, nil
}
var r UnsubscribeRecord
err := s.db.QueryRowContext(ctx, `
SELECT id, domain, status, COALESCE(url, ''), created_at, COALESCE(updated_at, '')
FROM unsubscribe WHERE domain = ?`, domain).
Scan(&r.ID, &r.Domain, &r.Status, &r.URL, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return UnsubscribeRecord{}, false, nil
}
if err != nil {
return UnsubscribeRecord{}, false, fmt.Errorf("read unsubscribe record %q: %w", domain, err)
}
return r, true, nil
}
// SetUnsubscribeStatus upserts the domain record. An empty url keeps the stored
// one, so an "ignore" does not wipe a link recorded earlier.
func (s *Store) SetUnsubscribeStatus(ctx context.Context, domain, status, url string) error {
domain = strings.TrimSpace(domain)
if domain == "" {
return errors.New("unsubscribe domain must not be empty")
}
if status != UnsubscribeStatusDone && status != UnsubscribeStatusIgnored {
return ErrUnsubscribeStatus
}
const q = `
INSERT INTO unsubscribe (domain, status, url, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (domain) DO UPDATE SET
status = excluded.status,
url = CASE WHEN excluded.url <> '' THEN excluded.url ELSE unsubscribe.url END,
updated_at = datetime('now')`
if _, err := s.db.ExecContext(ctx, q, domain, status, strings.TrimSpace(url)); err != nil {
return fmt.Errorf("set unsubscribe status for %q: %w", domain, err)
}
return nil
}
// ClearUnsubscribe removes a domain record, taking it off the ignore list.
func (s *Store) ClearUnsubscribe(ctx context.Context, domain string) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM unsubscribe WHERE domain = ?`, strings.TrimSpace(domain)); err != nil {
return fmt.Errorf("clear unsubscribe record %q: %w", domain, err)
}
return nil
}
// UnsubscribeCandidate is a message that may carry an unsubscribe link.
type UnsubscribeCandidate struct {
MessageID int64
FromAddr string
Subject string
SourceAddress string
BodyText string
ListUnsubscribe string
ListUnsubscribePost string
Category string
ReceivedAt time.Time
}
// UnsubscribeCandidates returns messages with a List-Unsubscribe header or a
// body that mentions unsubscribing, newest first. The caller runs the detector
// over them, because body parsing lives in internal/unsubscribe.
func (s *Store) UnsubscribeCandidates(ctx context.Context, limit int) ([]UnsubscribeCandidate, error) {
if limit < 1 {
limit = 200
}
const q = `
SELECT m.id, COALESCE(m.from_addr, ''), COALESCE(m.subject, ''),
COALESCE(m.source_address, ''), COALESCE(m.body_text, ''),
COALESCE(m.list_unsubscribe, ''), COALESCE(m.list_unsubscribe_post, ''),
COALESCE(c.category, ''), m.received_at
FROM messages m
LEFT JOIN classifications c ON c.id = (
SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1
)
WHERE (m.list_unsubscribe IS NOT NULL AND m.list_unsubscribe <> '')
OR instr(lower(COALESCE(m.body_text, '')), 'unsubscribe') > 0
ORDER BY COALESCE(NULLIF(m.date_sent, ''), m.received_at) DESC, m.id DESC
LIMIT ?`
rows, err := s.db.QueryContext(ctx, q, limit)
if err != nil {
return nil, fmt.Errorf("list unsubscribe candidates: %w", err)
}
defer rows.Close()
var out []UnsubscribeCandidate
for rows.Next() {
var (
c UnsubscribeCandidate
received string
)
if err := rows.Scan(
&c.MessageID, &c.FromAddr, &c.Subject, &c.SourceAddress,
&c.BodyText, &c.ListUnsubscribe, &c.ListUnsubscribePost,
&c.Category, &received,
); err != nil {
return nil, fmt.Errorf("scan unsubscribe candidate: %w", err)
}
c.ReceivedAt = parseSQLTime(received)
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list unsubscribe candidates: %w", err)
}
return out, nil
}