Files
Sam Rolfe 8c0416750e Phase 4: triage actions (delete, move, mark; bulk select) (AUD-70)
Bulk select in the triage list, with delete/move/mark and a recoverable
delete (FR-6, FR-9).

- migration v4: messages.folder + messages.mailbox; folder/category views
- internal/mailops: app folder -> Gmail label moves over IMAP (IPv4), locate
  by X-GM-MSGID, create labels lazily, move/save/reply/restore, flag marks
- mailfetch: CREATE, UID MOVE (COPY+EXPUNGE fallback), STORE flags, X-GM-MSGID search
- server: POST /actions/bulk with CSRF, UI delete confirmation, second
  confirmation for messages classified Spam (never auto-deleted), per-message
  and summary action-log rows (FR-13)
- inbox: select-all toolbar, checkboxes, folder nav, Restore on non-inbox rows

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

319 lines
8.8 KiB
Go
Raw Permalink 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. Phase 4 adds a bulk-select
// toolbar (FR-6) and folder views so a recoverable delete can be seen under the
// Delete folder and restored.
package server
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"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 = store.UnclassifiedCategory
type messageRow struct {
ID int64
From string
Subject string
SourceAddr string
Action string
Confidence int
Classified bool
When string
// Restorable is true for a message outside the inbox, so the row offers a
// Restore button (FR-9: deletes are recoverable).
Restorable bool
}
type messageGroup struct {
Name string
Messages []messageRow
}
type inboxData struct {
Groups []messageGroup
Total int
Note string
Error string
CSRFToken string
Folder string
Category string
FolderLabel string
}
// loadInbox reads messages for the active folder/category filter and groups them
// by the live category list.
func (s *Server) loadInbox(ctx context.Context, f store.MessageFilter) (inboxData, error) {
items, err := s.store.ListMessagesFiltered(ctx, f)
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"
}
folder := strings.TrimSpace(it.Folder)
if folder == "" {
folder = store.FolderInbox
}
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),
Restorable: folder != store.FolderInbox,
})
}
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
}
// inboxSelection reads the active folder/category from the query or the posted
// form, applying the inbox default and rejecting unknown values.
func inboxSelection(r *http.Request) (folder, category string) {
folder = strings.TrimSpace(r.FormValue("folder"))
category = strings.TrimSpace(r.FormValue("category"))
if folder == "" {
folder = store.FolderInbox
}
if folder != "all" && !store.ValidFolder(folder) {
folder = store.FolderInbox
}
return folder, category
}
// 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, errMsg string) {
folder, category := inboxSelection(r)
data, err := s.loadInbox(r.Context(), store.MessageFilter{
Folder: folder,
Category: category,
Unclassified: strings.EqualFold(category, unclassifiedCategory),
Limit: 200,
})
if err != nil {
s.logger.Error("load inbox", "error", err)
http.Error(w, "could not load messages", http.StatusInternalServerError)
return
}
data.Note = note
data.Error = errMsg
data.CSRFToken = s.csrfToken(w, r)
data.Folder = folder
data.Category = category
data.FolderLabel = folderLabel(folder)
if category != "" {
data.FolderLabel = orDefault(category, data.FolderLabel)
}
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")
}
// inboxURL builds the /partials/inbox URL for a folder/category view.
func inboxURL(folder, category string) string {
q := url.Values{}
if folder != "" {
q.Set("folder", folder)
}
if category != "" {
q.Set("category", category)
}
if len(q) == 0 {
return "/partials/inbox"
}
return "/partials/inbox?" + q.Encode()
}