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

225
internal/server/rules.go Normal file
View File

@@ -0,0 +1,225 @@
// Rules editor for Phase 6 (FR-4, FR-11).
//
// The rules document is plain text (see internal/classify for the syntax). It is
// saved as a new numbered version on every save, so the current text and its
// history are both available. The classifier reads the newest version and
// applies it as an override; the reply model also reads it.
package server
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
)
type ruleRevision struct {
Version int
CreatedAt string
Lines int
Preview string
}
type rulesData struct {
Text string
Version int
Revisions []ruleRevision
Errors []classify.ParseError
RuleCount int
Note string
Error string
CSRFToken string
// ClassifierReady is true when JEV is configured, so the page can offer a
// re-classify action that actually runs.
ClassifierReady bool
TotalMessages int
}
// handleRules renders the rules editor with the current text and version list.
func (s *Server) handleRules(w http.ResponseWriter, r *http.Request) {
s.renderRules(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error"))
}
func (s *Server) renderRules(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) {
ctx := r.Context()
text, version, err := s.store.CurrentRules(ctx)
if err != nil {
s.logger.Error("load rules", "error", err)
http.Error(w, "could not load the rules", http.StatusInternalServerError)
return
}
parsed := classify.ParseRules(text)
revisions, err := s.store.RulesVersions(ctx, 20)
if err != nil {
s.logger.Error("load rules versions", "error", err)
}
revs := make([]ruleRevision, 0, len(revisions))
for _, rv := range revisions {
revs = append(revs, ruleRevision{
Version: rv.Version,
CreatedAt: rv.CreatedAt,
Lines: countRuleLines(rv.Text),
Preview: firstRuleLine(rv.Text),
})
}
total, err := s.store.CountMessages(ctx)
if err != nil {
s.logger.Error("count messages", "error", err)
}
data := rulesData{
Text: text,
Version: version,
Revisions: revs,
Errors: parsed.Errors,
RuleCount: len(parsed.Rules),
Note: note,
Error: errMsg,
CSRFToken: s.csrfToken(w, r),
ClassifierReady: s.classifier.Configured(),
TotalMessages: total,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := s.tmpl.ExecuteTemplate(w, "rules.html", data); err != nil {
s.logger.Error("render rules", "error", err)
}
}
// handleRulesSave versions the posted text (FR-11).
func (s *Server) handleRulesSave(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.redirectRules(w, r, "", "Could not read the form.")
return
}
text := r.FormValue("text")
version, err := s.store.SaveRules(r.Context(), text)
if err != nil {
s.logger.Error("save rules", "error", err)
s.redirectRules(w, r, "", "Could not save the rules: "+err.Error())
return
}
parsed := classify.ParseRules(text)
s.logAction(r.Context(), "rules_save", "Sam",
"version="+strconv.Itoa(version)+" rules="+strconv.Itoa(len(parsed.Rules))+" parse_errors="+strconv.Itoa(len(parsed.Errors)))
s.redirectRules(w, r, "Saved rules version "+strconv.Itoa(version)+". Re-classify all to apply it to existing mail.", "")
}
// handleRulesRestore re-saves an older version as the newest one, so history is
// append-only (FR-11).
func (s *Server) handleRulesRestore(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.redirectRules(w, r, "", "Could not read the form.")
return
}
version, err := strconv.Atoi(strings.TrimSpace(r.FormValue("version")))
if err != nil || version < 1 {
s.redirectRules(w, r, "", "Unknown rules version.")
return
}
text, ok, err := s.store.RuleByVersion(r.Context(), version)
if err != nil {
s.redirectRules(w, r, "", "Could not load that version.")
return
}
if !ok {
s.redirectRules(w, r, "", "That rules version no longer exists.")
return
}
newVersion, err := s.store.SaveRules(r.Context(), text)
if err != nil {
s.logger.Error("restore rules", "from_version", version, "error", err)
s.redirectRules(w, r, "", "Could not restore that version: "+err.Error())
return
}
s.logAction(r.Context(), "rules_restore", "Sam",
"from_version="+strconv.Itoa(version)+" new_version="+strconv.Itoa(newVersion))
s.redirectRules(w, r, "Restored version "+strconv.Itoa(version)+" as version "+strconv.Itoa(newVersion)+".", "")
}
// handleRulesReclassify re-runs the classifier over every stored message and
// returns to the rules editor. It is the rules page's "apply" action: saving a
// new version does not re-classify by itself.
func (s *Server) handleRulesReclassify(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if rc := http.NewResponseController(w); rc != nil {
_ = rc.SetWriteDeadline(time.Now().Add(15 * time.Minute))
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
defer cancel()
sum, err := s.classifier.RunAll(ctx)
switch {
case err == nil:
s.logAction(ctx, "reclassify", "Sam",
fmt.Sprintf("considered=%d classified=%d failed=%d rules_version=%d",
sum.Considered, sum.Classified, sum.Failed, sum.RulesVersion))
s.redirectRules(w, r, fmt.Sprintf(
"Re-classified %d of %d message(s); %d failed. The current rules are now applied.",
sum.Classified, sum.Considered, sum.Failed), "")
case errors.Is(err, classify.ErrNotConfigured):
s.redirectRules(w, r, "", "Classification is not connected yet: set JEV_API_KEY.")
default:
s.logger.Error("rules reclassify failed", "error", err)
s.redirectRules(w, r, "", "Classification failed: "+err.Error())
}
}
// redirectRules is POST/redirect/GET so a refresh does not resubmit.
func (s *Server) redirectRules(w http.ResponseWriter, r *http.Request, note, errMsg string) {
q := url.Values{}
if note != "" {
q.Set("note", note)
}
if errMsg != "" {
q.Set("error", errMsg)
}
http.Redirect(w, r, "/rules?"+q.Encode(), http.StatusSeeOther)
}
// countRuleLines counts the non-empty, non-comment lines in a rules document.
func countRuleLines(text string) int {
n := 0
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
n++
}
return n
}
// firstRuleLine returns a short preview of the first real rule, or a note when
// the document has only comments.
func firstRuleLine(text string) string {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if len(line) > 80 {
return line[:80] + "…"
}
return line
}
return "(comments only)"
}