Files
email_agent/internal/server/auth.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

318 lines
8.9 KiB
Go

package server
import (
"context"
"crypto/subtle"
"net/http"
"net/url"
"strings"
"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"
)
const (
// csrfCookieName holds the double-submit CSRF token. It is independent of
// the session so the login form (which has no session yet) is protected too.
csrfCookieName = "email_agent_csrf"
sessionTTL = session.DefaultTTL
)
// ctxKey is the private context key type for request-scoped values.
type ctxKey int
const sessionCtxKey ctxKey = iota
// requireAuth redirects unauthenticated requests to the login screen and
// otherwise puts the session on the request context.
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, ok := s.currentSession(r)
if !ok {
target := "/login?next=" + url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), sessionCtxKey, sess)))
}
}
func (s *Server) currentSession(r *http.Request) (session.Session, bool) {
c, err := r.Cookie(session.CookieName)
if err != nil || c.Value == "" {
return session.Session{}, false
}
return s.sessions.Lookup(c.Value)
}
func sessionFrom(r *http.Request) session.Session {
sess, _ := r.Context().Value(sessionCtxKey).(session.Session)
return sess
}
// loginSecret is the password the single user must supply. EMAIL_AGENT_PASSWORD
// wins; otherwise the session secret doubles as the password so a single
// configured secret is enough.
func (s *Server) loginSecret() string {
if s.cfg.Password != "" {
return s.cfg.Password
}
return s.cfg.SessionSecret
}
// csrfToken returns the current double-submit token, minting one when absent.
func (s *Server) csrfToken(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
return c.Value
}
tok, err := session.RandomToken(32)
if err != nil {
// crypto/rand failing is fatal for security; surface it rather than
// issuing an unprotected form.
s.logger.Error("csrf token generation failed", "error", err)
return ""
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.cfg.IsProduction(),
MaxAge: int(sessionTTL.Seconds()),
})
return tok
}
// csrfOK verifies the double-submit token from the form or the X-CSRF-Token
// header.
func (s *Server) csrfOK(r *http.Request) bool {
c, err := r.Cookie(csrfCookieName)
if err != nil || c.Value == "" {
return false
}
tok := r.FormValue("csrf")
if tok == "" {
tok = r.Header.Get("X-CSRF-Token")
}
return subtle.ConstantTimeCompare([]byte(tok), []byte(c.Value)) == 1
}
func (s *Server) setSessionCookie(w http.ResponseWriter, value string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.cfg.IsProduction(),
Expires: expires,
MaxAge: int(sessionTTL.Seconds()),
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.cfg.IsProduction(),
MaxAge: -1,
})
}
type loginData struct {
PartOfDay string
Error string
Next string
CSRFToken string
Configured bool
}
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, code int, errMsg, next string) {
// Mint the CSRF cookie before the status line, or Set-Cookie is dropped.
csrf := s.csrfToken(w, r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
data := loginData{
PartOfDay: partOfDay(time.Now()),
Error: errMsg,
Next: next,
CSRFToken: csrf,
Configured: s.loginSecret() != "",
}
if err := s.tmpl.ExecuteTemplate(w, "login.html", data); err != nil {
s.logger.Error("render login", "error", err)
}
}
// handleLoginGet renders the sign-in form.
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.currentSession(r); ok {
http.Redirect(w, r, cleanNext(r.URL.Query().Get("next")), http.StatusSeeOther)
return
}
s.renderLogin(w, r, http.StatusOK, "", cleanNext(r.URL.Query().Get("next")))
}
// handleLoginPost validates the CSRF token and password, then starts a session.
// POST (not GET) means the password is never placed in the URL or browser
// history.
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderLogin(w, r, http.StatusBadRequest, "Could not read the sign-in form.", "/")
return
}
next := cleanNext(r.FormValue("next"))
if !s.csrfOK(r) {
s.renderLogin(w, r, http.StatusForbidden, "Your sign-in page expired. Please try again.", next)
return
}
secret := s.loginSecret()
if secret == "" {
s.renderLogin(w, r, http.StatusServiceUnavailable, "Login is not configured yet.", next)
return
}
if subtle.ConstantTimeCompare([]byte(r.FormValue("password")), []byte(secret)) != 1 {
s.logger.Warn("login failed", "remote", r.RemoteAddr)
s.renderLogin(w, r, http.StatusUnauthorized, "Incorrect password.", next)
return
}
sess, cookie, err := s.sessions.New("Sam")
if err != nil {
s.logger.Error("create session", "error", err)
http.Error(w, "could not start a session", http.StatusInternalServerError)
return
}
s.setSessionCookie(w, cookie, sess.ExpiresAt)
s.logger.Info("login ok", "user", sess.User, "remote", r.RemoteAddr)
http.Redirect(w, r, next, http.StatusSeeOther)
}
// handleLogout destroys the session and returns to the login screen.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if c, err := r.Cookie(session.CookieName); err == nil && c.Value != "" {
s.sessions.Destroy(c.Value)
}
s.clearSessionCookie(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type navItem struct {
Name string
Color string
Count int
}
type dashboardData struct {
Greeting string
User string
CSRFToken string
Version string
Total int
Categories []navItem
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, 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
}
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),
Categories: catNav,
Folders: []navItem{
{Name: "Replied-to", Color: "var(--sticker-green)"},
{Name: "Delete", Color: "var(--sticker-red)"},
{Name: "Saved", Color: "var(--sticker-teal)"},
},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
s.logger.Error("render dashboard", "error", err)
}
}
// cleanNext only allows same-site, absolute-path redirects.
func cleanNext(next string) string {
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return "/"
}
return next
}
// partOfDay returns the time-aware greeting prefix.
func partOfDay(t time.Time) string {
switch h := t.Hour(); {
case h < 12:
return "Good morning"
case h < 18:
return "Good afternoon"
default:
return "Good evening"
}
}