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>
261 lines
7.0 KiB
Go
261 lines
7.0 KiB
Go
// 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")
|
||
}
|