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:
225
internal/server/rules.go
Normal file
225
internal/server/rules.go
Normal 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)"
|
||||
}
|
||||
Reference in New Issue
Block a user