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:
177
internal/store/unsubscribe.go
Normal file
177
internal/store/unsubscribe.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user