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>
This commit is contained in:
2026-10-11 21:37:38 +11:00
parent bf43b6bbef
commit af3b5e5f0e
27 changed files with 3235 additions and 102 deletions

View File

@@ -9,6 +9,7 @@ import (
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
)
@@ -226,36 +227,63 @@ type dashboardData struct {
Folders []navItem
}
// categoryCounts returns per-category message counts keyed by the canonical
// category name, plus the unclassified bucket.
func categoryCounts(items []store.MessageListItem, categories []store.Category) map[string]int {
canonical := make(map[string]string, len(categories))
for _, c := range categories {
canonical[strings.ToLower(c.Name)] = c.Name
}
counts := map[string]int{}
for _, it := range items {
raw := strings.TrimSpace(it.Category)
if raw == "" {
counts[unclassifiedCategory]++
continue
}
if name, ok := canonical[strings.ToLower(raw)]; ok {
counts[name]++
} else {
counts[raw]++
}
}
return counts
}
// handleDashboard renders the inbox shell with real category counts.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r)
// FR-5: counts come from the stored messages. Phase 2 has no classifier
// yet, so every message lands under Unclassified (AGENTS.md §13.2).
// FR-5: counts come from the stored messages, grouped by the live category
// list (FR-14).
items, err := s.store.ListMessages(r.Context(), 200)
if err != nil {
s.logger.Error("load dashboard counts", "error", err)
items = nil
}
counts := categoryCounts(items)
categories, err := s.store.ListCategories(r.Context())
if err != nil {
s.logger.Error("load dashboard categories", "error", err)
}
counts := categoryCounts(items, categories)
catNav := make([]navItem, 0, len(categories)+1)
for _, c := range categories {
catNav = append(catNav, navItem{Name: c.Name, Color: "var(--primary-soft, var(--surface-2))", Count: counts[c.Name]})
}
catNav = append(catNav, navItem{
Name: unclassifiedCategory,
Color: "var(--ink-faint)",
Count: counts[unclassifiedCategory],
})
data := dashboardData{
Greeting: partOfDay(time.Now()),
User: sess.User,
CSRFToken: s.csrfToken(w, r),
Version: version.Version,
Total: len(items),
// Placeholders until FR-14 (editable categories) and FR-9 (folders)
// land. Colours are the design system's decorative sticker palette.
Categories: []navItem{
{Name: "Action", Color: "var(--primary)", Count: counts["action"]},
{Name: "Important", Color: "var(--sticker-orange)", Count: counts["important"]},
{Name: "Info", Color: "var(--sticker-sky)", Count: counts["info"]},
{Name: "Spam", Color: "var(--sticker-red)", Count: counts["spam"]},
{Name: "Newsletter", Color: "var(--sticker-purple)", Count: counts["newsletter"]},
{Name: "Auto-reply", Color: "var(--sticker-teal)", Count: counts["auto-reply"]},
{Name: "Unclassified", Color: "var(--ink-faint)", Count: counts[unclassifiedCategory]},
},
Greeting: partOfDay(time.Now()),
User: sess.User,
CSRFToken: s.csrfToken(w, r),
Version: version.Version,
Total: len(items),
Categories: catNav,
Folders: []navItem{
{Name: "Replied-to", Color: "var(--sticker-green)"},
{Name: "Delete", Color: "var(--sticker-red)"},

View File

@@ -0,0 +1,151 @@
// Category management UI and handlers (FR-14).
//
// The category list is editable: add, rename, and remove. JEV uses whatever list
// is current at classification time, and renaming rewrites existing
// classifications so the triage list stays consistent.
package server
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
type categoriesData struct {
Categories []store.Category
CSRFToken string
Note string
Error string
Total int
Classified int
}
// handleCategories renders the category editor the inbox nav links to.
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
s.renderCategories(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error"))
}
func (s *Server) renderCategories(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) {
categories, err := s.store.ListCategories(r.Context())
if err != nil {
s.logger.Error("list categories", "error", err)
http.Error(w, "could not load categories", http.StatusInternalServerError)
return
}
coverage, err := s.store.ClassificationCoverage(r.Context())
if err != nil {
s.logger.Error("classification coverage", "error", err)
}
data := categoriesData{
Categories: categories,
CSRFToken: s.csrfToken(w, r),
Note: note,
Error: errMsg,
Total: coverage.Total,
Classified: coverage.Classified,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := s.tmpl.ExecuteTemplate(w, "categories.html", data); err != nil {
s.logger.Error("render categories", "error", err)
}
}
// handleCategoryAdd creates a category (FR-14).
func (s *Server) handleCategoryAdd(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.redirectCategories(w, r, "", "Could not read the form.")
return
}
name, description := r.FormValue("name"), r.FormValue("description")
cat, err := s.store.AddCategory(r.Context(), name, description)
switch {
case err == nil:
s.logger.Info("category added", "name", cat.Name)
_ = s.store.LogAction(r.Context(), "category_add", "Sam", "name="+cat.Name)
s.redirectCategories(w, r, fmt.Sprintf("Added category %q.", cat.Name), "")
case errors.Is(err, store.ErrCategoryExists):
s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name))
default:
s.redirectCategories(w, r, "", err.Error())
}
}
// handleCategoryRename changes a category's name and updates existing
// classifications (FR-14).
func (s *Server) handleCategoryRename(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.redirectCategories(w, r, "", "Could not read the form.")
return
}
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
s.redirectCategories(w, r, "", "Unknown category.")
return
}
name := r.FormValue("name")
switch err := s.store.RenameCategory(r.Context(), id, name); {
case err == nil:
s.logger.Info("category renamed", "id", id, "name", name)
_ = s.store.LogAction(r.Context(), "category_rename", "Sam",
fmt.Sprintf("id=%d name=%s", id, name))
s.redirectCategories(w, r, fmt.Sprintf("Renamed category to %q.", name), "")
case errors.Is(err, store.ErrCategoryExists):
s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name))
default:
s.redirectCategories(w, r, "", err.Error())
}
}
// handleCategoryRemove deletes a category and moves its messages to the first
// remaining category (FR-14).
func (s *Server) handleCategoryRemove(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.redirectCategories(w, r, "", "Could not read the form.")
return
}
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
s.redirectCategories(w, r, "", "Unknown category.")
return
}
switch err := s.store.RemoveCategory(r.Context(), id); {
case err == nil:
s.logger.Info("category removed", "id", id)
_ = s.store.LogAction(r.Context(), "category_remove", "Sam", fmt.Sprintf("id=%d", id))
s.redirectCategories(w, r, "Removed category; its messages moved to the first remaining category.", "")
case errors.Is(err, store.ErrLastCategory):
s.redirectCategories(w, r, "", "At least one category is required.")
default:
s.redirectCategories(w, r, "", err.Error())
}
}
// redirectCategories is POST/redirect/GET so a refresh does not resubmit.
func (s *Server) redirectCategories(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, "/categories?"+q.Encode(), http.StatusSeeOther)
}

View File

@@ -0,0 +1,213 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// login signs in and returns the session cookie plus the CSRF cookie/token.
func login(t *testing.T, srv *Server) (*http.Cookie, *http.Cookie, string) {
t.Helper()
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
csrf := csrfFromBody(t, rec.Body.String())
csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie))
sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName)
if sessionCookie == nil {
t.Fatal("login did not set the session cookie")
}
return sessionCookie, csrfCookie, csrf
}
func postForm(t *testing.T, srv *Server, path string, session, csrfCookie *http.Cookie, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(session)
if csrfCookie != nil {
req.AddCookie(csrfCookie)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
func getWithSession(t *testing.T, srv *Server, path string, session *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(session)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
func TestCategoriesPageRenders(t *testing.T) {
srv := newTestServer(t)
session, _, _ := login(t, srv)
rec := getWithSession(t, srv, "/categories", session)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Categories", "Action", "Spam", "Add category", "Back to inbox"} {
if !strings.Contains(body, want) {
t.Errorf("categories page missing %q", want)
}
}
}
func TestCategoryAddRenameRemove(t *testing.T) {
srv := newTestServer(t)
session, csrfCookie, csrf := login(t, srv)
// Add (FR-14).
rec := postForm(t, srv, "/categories/add", session, csrfCookie, url.Values{
"csrf": {csrf}, "name": {"Finance"}, "description": {"Bills and payments"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("add status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
if rec := getWithSession(t, srv, "/categories", session); !strings.Contains(rec.Body.String(), "Finance") {
t.Fatal("added category is not rendered")
}
id := categoryID(t, srv, "Finance")
// Rename (FR-14).
rec = postForm(t, srv, "/categories/rename", session, csrfCookie, url.Values{
"csrf": {csrf}, "id": {itoa(id)}, "name": {"Money"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("rename status = %d, want 303", rec.Code)
}
body := getWithSession(t, srv, "/categories", session).Body.String()
if !strings.Contains(body, "Money") || strings.Contains(body, ">Finance<") {
t.Fatalf("rename not reflected; body contains Finance=%v Money=%v",
strings.Contains(body, "Finance"), strings.Contains(body, "Money"))
}
// Remove (FR-14).
id = categoryID(t, srv, "Money")
rec = postForm(t, srv, "/categories/remove", session, csrfCookie, url.Values{
"csrf": {csrf}, "id": {itoa(id)},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("remove status = %d, want 303", rec.Code)
}
if rec := getWithSession(t, srv, "/categories", session); strings.Contains(rec.Body.String(), "Money") {
t.Fatal("removed category is still rendered")
}
}
func TestCategoryMutationsRequireCSRF(t *testing.T) {
srv := newTestServer(t)
session, _, _ := login(t, srv)
rec := postForm(t, srv, "/categories/add", session, nil, url.Values{"name": {"Nope"}})
if rec.Code != http.StatusForbidden {
t.Fatalf("add without CSRF status = %d, want 403", rec.Code)
}
}
func TestClassifyAnonymousRedirects(t *testing.T) {
srv := newTestServer(t)
for _, path := range []string{"/actions/classify", "/actions/reclassify"} {
req := httptest.NewRequest(http.MethodPost, path, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Errorf("%s status = %d, want 303 (redirect to login)", path, rec.Code)
}
}
}
func TestClassifyWithoutJEVKeyReportsNotConfigured(t *testing.T) {
srv := newTestServer(t)
session, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/actions/classify", session, csrfCookie, url.Values{"csrf": {csrf}})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "JEV_API_KEY") {
t.Errorf("expected a not-configured note; body: %s", rec.Body.String())
}
}
func TestDashboardLinksToCategoriesAndShowsClassify(t *testing.T) {
srv := newTestServer(t)
session, _, _ := login(t, srv)
rec := getWithSession(t, srv, "/", session)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`href="/categories"`, "Classify", "Re-classify all", "Action", "Auto-reply"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
}
func TestInboxShowsClassificationConfidence(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
msgID, err := srv.store.UpsertMessage(ctx, &store.Message{
GmailID: "cls-1", FromAddr: "a@b.com", Subject: "Classified mail",
SourceAddress: "hello@where-woof.com", DateSent: time.Now().UTC(),
})
if err != nil {
t.Fatalf("seed message: %v", err)
}
if _, err := srv.store.SaveClassification(ctx, store.Classification{
MessageID: msgID, Category: "Action", Importance: "high", Action: "reply",
Confidence: 0.82, Model: "jev-1.13.0",
}); err != nil {
t.Fatalf("seed classification: %v", err)
}
session, _, _ := login(t, srv)
rec := getWithSession(t, srv, "/partials/inbox", session)
body := rec.Body.String()
for _, want := range []string{"Classified mail", "Action", "reply", "82%"} {
if !strings.Contains(body, want) {
t.Errorf("inbox partial missing %q", want)
}
}
}
func categoryID(t *testing.T, srv *Server, name string) int64 {
t.Helper()
cats, err := srv.store.ListCategories(context.Background())
if err != nil {
t.Fatalf("ListCategories() error = %v", err)
}
for _, c := range cats {
if c.Name == name {
return c.ID
}
}
t.Fatalf("category %q not found", name)
return 0
}
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}

View File

@@ -1,9 +1,8 @@
// Inbox rendering and the manual mail scan.
// Inbox rendering, the manual mail scan, and the classify action.
//
// Phase 2 lists real mail from the database. Classification is Phase 3, so
// messages without a classification are shown under "Unclassified" with a
// temporary "Review" recommended action; the grouping and action plumbing is
// already category-aware.
// 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 (
@@ -15,26 +14,13 @@ import (
"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 fallback bucket before Phase 3 adds JEV
// categories (AGENTS.md §13.2).
const unclassifiedCategory = "unclassified"
// categoryOrder is the display order for the built-in categories.
var categoryOrder = []string{"action", "important", "info", "spam", "newsletter", "auto-reply"}
var categoryDisplay = map[string]string{
"action": "Action",
"important": "Important",
"info": "Info",
"spam": "Spam",
"newsletter": "Newsletter",
"auto-reply": "Auto-reply",
"unclassified": "Unclassified",
}
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
const unclassifiedCategory = "Unclassified"
type messageRow struct {
ID int64
@@ -42,6 +28,8 @@ type messageRow struct {
Subject string
SourceAddr string
Action string
Confidence int
Classified bool
When string
}
@@ -57,21 +45,42 @@ type inboxData struct {
Error string
}
// loadInbox reads messages and groups them for rendering.
// 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
}
return inboxData{Groups: groupMessages(items), Total: len(items)}, nil
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. Unknown (custom) categories get
// their own group; messages with no classification fall into "Unclassified".
func groupMessages(items []store.MessageListItem) []messageGroup {
// 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 {
key := strings.ToLower(strings.TrimSpace(it.Category))
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
}
@@ -85,62 +94,47 @@ func groupMessages(items []store.MessageListItem) []messageGroup {
Subject: orDefault(it.Subject, "(no subject)"),
SourceAddr: it.SourceAddress,
Action: action,
Confidence: confidencePercent(it.Confidence),
Classified: it.HasClassification,
When: formatWhen(it.DateSent, it.ReceivedAt),
})
}
// Order: built-in categories first, then custom ones alphabetically, then
// Unclassified last.
var keys []string
for _, k := range categoryOrder {
if _, ok := buckets[k]; ok {
keys = append(keys, k)
for _, name := range order {
if _, ok := buckets[name]; ok {
keys = append(keys, name)
}
}
var custom []string
var unknown []string
for k := range buckets {
if k == unclassifiedCategory || contains(categoryOrder, k) {
if k == unclassifiedCategory || contains(order, k) {
continue
}
custom = append(custom, k)
unknown = append(unknown, k)
}
sort.Strings(custom)
keys = append(keys, custom...)
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: displayCategory(k),
Messages: buckets[k],
})
groups = append(groups, messageGroup{Name: k, Messages: buckets[k]})
}
return groups
}
func displayCategory(key string) string {
if name, ok := categoryDisplay[key]; ok {
return name
// confidencePercent turns a 0–1 confidence into a whole percentage.
func confidencePercent(v float64) int {
if v <= 0 {
return 0
}
if key == "" {
return "Unclassified"
if v > 1 {
v = 1
}
return strings.ToUpper(key[:1]) + key[1:]
}
// categoryCounts returns per-category message counts for the left nav.
func categoryCounts(items []store.MessageListItem) map[string]int {
counts := map[string]int{}
for _, it := range items {
key := strings.ToLower(strings.TrimSpace(it.Category))
if key == "" {
key = unclassifiedCategory
}
counts[key]++
}
return counts
return int(v*100 + 0.5)
}
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
@@ -187,6 +181,53 @@ func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) {
}
}
// 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

View File

@@ -17,6 +17,7 @@ import (
"net/http"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
@@ -32,13 +33,14 @@ func init() {
// Server holds the HTTP dependencies.
type Server struct {
cfg *config.Config
store *store.Store
logger *slog.Logger
http *http.Server
tmpl *template.Template
sessions *session.Manager
fetcher *mailfetch.Fetcher
cfg *config.Config
store *store.Store
logger *slog.Logger
http *http.Server
tmpl *template.Template
sessions *session.Manager
fetcher *mailfetch.Fetcher
classifier *classify.Classifier
}
// New builds a Server. It does not start listening.
@@ -64,6 +66,10 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
tmpl: tmpl,
sessions: sessions,
fetcher: mailfetch.NewFetcher(cfg, st, logger),
classifier: classify.NewClassifier(
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
),
}
s.http = &http.Server{
Addr: cfg.Addr,
@@ -113,6 +119,14 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/inbox", s.requireAuth(s.handleInboxPartial))
mux.HandleFunc("POST /actions/fetch", s.requireAuth(s.handleFetchMail))
mux.HandleFunc("POST /actions/classify", s.requireAuth(s.handleClassify))
mux.HandleFunc("POST /actions/reclassify", s.requireAuth(s.handleReclassify))
// FR-14: editable category list.
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))
mux.HandleFunc("POST /categories/rename", s.requireAuth(s.handleCategoryRename))
mux.HandleFunc("POST /categories/remove", s.requireAuth(s.handleCategoryRemove))
return s.recoverPanic(s.logRequests(mux))
}
@@ -234,6 +248,10 @@ type statusRecorder struct {
status int
}
// Unwrap lets http.ResponseController reach the underlying writer (the classify
// handler extends its write deadline for long runs).
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)