// 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 }