Files
email_agent/internal/server/inbox.go
Sam Rolfe af3b5e5f0e Phase 3: JEV classification, rules table, editable categories (AUD-69)
Implements FR-3, FR-4, FR-5 and FR-14.

- internal/classify: TypeSafe System One (JEV) HTTP client with the confirmed
  request/response shape, retry with backoff on 429/529/5xx, a plain-text rule
  engine (first match wins, overrides only the named fields), and bounded
  orchestration with a per-run cap.
- internal/store: migration v3 adds an editable `categories` table (name =
  JEV Choice option and classifications key; rename cascades, remove reassigns)
  and seeds a starter rules version once. Classification persistence, coverage,
  and pending/all message queries.
- internal/server: POST /actions/classify and /actions/reclassify, plus the
  FR-14 category editor (GET /categories and add/rename/remove). The triage list
  now groups by the live category list and shows action + confidence.
- CLI: -classify and -classify-all for n8n.
- Docs: docs/phase3-classify.md with the confirmed JEV shape, a live read-only
  classification, and the DoD evidence.

DoD: all 100 stored messages have a non-null category, importance, and suggested
action (0 nulls); JEV picked a newly added/renamed category for 6 real messages
over HTTP. One live JEV call recorded in the phase doc.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 21:37:38 +11:00

261 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Inbox rendering, the manual mail scan, and the classify action.
//
// Phase 3 groups messages by the live category list (FR-5) and shows the JEV
// recommended action plus confidence on each row. Messages without a
// classification fall under "Unclassified".
package server
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
const unclassifiedCategory = "Unclassified"
type messageRow struct {
ID int64
From string
Subject string
SourceAddr string
Action string
Confidence int
Classified bool
When string
}
type messageGroup struct {
Name string
Messages []messageRow
}
type inboxData struct {
Groups []messageGroup
Total int
Note string
Error string
}
// loadInbox reads messages and groups them by the live category list.
func (s *Server) loadInbox(ctx context.Context) (inboxData, error) {
items, err := s.store.ListMessages(ctx, 200)
if err != nil {
return inboxData{}, err
}
categories, err := s.store.ListCategories(ctx)
if err != nil {
return inboxData{}, err
}
return inboxData{Groups: groupMessages(items, categories), Total: len(items)}, nil
}
// groupMessages buckets messages by category, ordering groups the way the
// category list is ordered in the UI. Unknown categories (for example a
// classification written before a rename) keep their own group; messages with
// no classification fall into "Unclassified" last.
func groupMessages(items []store.MessageListItem, categories []store.Category) []messageGroup {
canonical := make(map[string]string, len(categories))
order := make([]string, 0, len(categories))
for _, c := range categories {
canonical[strings.ToLower(c.Name)] = c.Name
order = append(order, c.Name)
}
buckets := map[string][]messageRow{}
for _, it := range items {
raw := strings.TrimSpace(it.Category)
key := ""
if raw != "" {
if name, ok := canonical[strings.ToLower(raw)]; ok {
key = name
} else {
key = raw
}
}
if key == "" {
key = unclassifiedCategory
}
action := strings.TrimSpace(it.Action)
if action == "" {
action = "Review"
}
buckets[key] = append(buckets[key], messageRow{
ID: it.ID,
From: orDefault(it.FromAddr, "(unknown sender)"),
Subject: orDefault(it.Subject, "(no subject)"),
SourceAddr: it.SourceAddress,
Action: action,
Confidence: confidencePercent(it.Confidence),
Classified: it.HasClassification,
When: formatWhen(it.DateSent, it.ReceivedAt),
})
}
var keys []string
for _, name := range order {
if _, ok := buckets[name]; ok {
keys = append(keys, name)
}
}
var unknown []string
for k := range buckets {
if k == unclassifiedCategory || contains(order, k) {
continue
}
unknown = append(unknown, k)
}
sort.Strings(unknown)
keys = append(keys, unknown...)
if _, ok := buckets[unclassifiedCategory]; ok {
keys = append(keys, unclassifiedCategory)
}
groups := make([]messageGroup, 0, len(keys))
for _, k := range keys {
groups = append(groups, messageGroup{Name: k, Messages: buckets[k]})
}
return groups
}
// confidencePercent turns a 0–1 confidence into a whole percentage.
func confidencePercent(v float64) int {
if v <= 0 {
return 0
}
if v > 1 {
v = 1
}
return int(v*100 + 0.5)
}
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
data, err := s.loadInbox(r.Context())
if err != nil {
s.logger.Error("load inbox", "error", err)
http.Error(w, "could not load messages", http.StatusInternalServerError)
return
}
data.Note = note
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "inbox.html", data); err != nil {
s.logger.Error("render inbox partial", "error", err)
}
}
// handleInboxPartial is the HTMX fragment for the centre list.
func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) {
s.renderInbox(w, r, "")
}
// handleFetchMail runs one IMAP scan, then returns the refreshed list. It is a
// POST because it changes stored state and must carry a CSRF token.
func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
sum, err := s.fetcher.Fetch(ctx)
switch {
case err == nil:
s.renderInbox(w, r, fmt.Sprintf("Scanned %d message(s); %d stored or refreshed.", sum.Fetched, sum.Stored))
case errors.Is(err, mailfetch.ErrNoCredentials):
s.renderInbox(w, r, "Mail is not connected yet: set GMAIL_USER and GMAIL_APP_PASSWORD.")
case errors.Is(err, mailfetch.ErrAlreadyRunning):
s.renderInbox(w, r, "A mail scan is already running.")
default:
s.logger.Error("mail scan failed", "error", err)
s.renderInbox(w, r, "Mail scan failed: "+err.Error())
}
}
// handleClassify classifies pending messages (FR-3) and returns the refreshed
// list. A run can take minutes, so the write deadline is extended for this
// handler only.
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
s.runClassifier(w, r, false)
}
// handleReclassify re-runs the classifier over every stored message. It is how
// a category-list or rules edit is applied to mail that was already classified.
func (s *Server) handleReclassify(w http.ResponseWriter, r *http.Request) {
s.runClassifier(w, r, true)
}
func (s *Server) runClassifier(w http.ResponseWriter, r *http.Request, all bool) {
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()
var (
sum classify.Summary
err error
)
if all {
sum, err = s.classifier.RunAll(ctx)
} else {
sum, err = s.classifier.Run(ctx)
}
switch {
case err == nil:
s.renderInbox(w, r, fmt.Sprintf(
"Classified %d of %d message(s); %d failed; %d still unclassified.",
sum.Classified, sum.Considered, sum.Failed, sum.Remaining))
case errors.Is(err, classify.ErrNotConfigured):
s.renderInbox(w, r, "Classification is not connected yet: set JEV_API_KEY.")
default:
s.logger.Error("classification run failed", "error", err)
s.renderInbox(w, r, "Classification failed: "+err.Error())
}
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return strings.TrimSpace(v)
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// formatWhen renders a compact, timezone-stable timestamp.
func formatWhen(sent, received time.Time) string {
t := sent
if t.IsZero() {
t = received
}
if t.IsZero() {
return ""
}
if t.Year() == time.Now().Year() {
return t.Format("2 Jan 15:04")
}
return t.Format("2 Jan 2006")
}