- 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>
178 lines
6.0 KiB
Go
178 lines
6.0 KiB
Go
// 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
|
|
}
|