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

@@ -159,3 +159,51 @@ func (s *Store) SaveRules(ctx context.Context, text string) (int, error) {
}
return next, nil
}
// RuleVersion is one saved rules revision, for the version list in the editor
// (FR-11).
type RuleVersion struct {
ID int64
Version int
Text string
CreatedAt string
}
// RulesVersions returns saved rules revisions, newest first.
func (s *Store) RulesVersions(ctx context.Context, limit int) ([]RuleVersion, error) {
if limit < 1 {
limit = 20
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, version, text, created_at FROM rules ORDER BY version DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list rules versions: %w", err)
}
defer rows.Close()
var out []RuleVersion
for rows.Next() {
var v RuleVersion
if err := rows.Scan(&v.ID, &v.Version, &v.Text, &v.CreatedAt); err != nil {
return nil, fmt.Errorf("scan rules version: %w", err)
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list rules versions: %w", err)
}
return out, nil
}
// RuleByVersion returns the text of one saved rules revision. ok is false when
// the version does not exist.
func (s *Store) RuleByVersion(ctx context.Context, version int) (string, bool, error) {
var text string
err := s.db.QueryRowContext(ctx, `SELECT text FROM rules WHERE version = ?`, version).Scan(&text)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("read rules version %d: %w", version, err)
}
return text, true, nil
}