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