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>
This commit is contained in:
2026-10-11 21:44:52 +11:00
parent 735934dab2
commit 8c0416750e
18 changed files with 2023 additions and 71 deletions

255
internal/server/actions.go Normal file
View File

@@ -0,0 +1,255 @@
// Bulk triage actions for Phase 4 (FR-6, FR-9, FR-13).
//
// The UI selects rows in a group, then asks for delete, move, or mark. Two
// guard rails matter here:
//
// - Deletes are recoverable: they move the message to the Delete label, never
// a hard delete. A restore moves it back to INBOX.
// - Spam is recommended, never auto-deleted. A delete that includes a message
// classified Spam needs a second, explicit confirmation.
//
// Every action is appended to the action log.
package server
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// MailActions is the mailbox side of a triage action. The live implementation is
// *mailops.Client; tests substitute a fake.
type MailActions interface {
// Move files msg under destFolder and returns the Gmail label it now lives
// in.
Move(ctx context.Context, msg store.Message, destFolder string) (string, error)
// Mark applies read/star flags (read, unread, star, unstar).
Mark(ctx context.Context, msg store.Message, mark string) error
}
// handleBulkAction performs one action over the selected message ids.
func (s *Server) handleBulkAction(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.renderInbox(w, r, "", "Could not read the action form.")
return
}
action := strings.ToLower(strings.TrimSpace(r.FormValue("action")))
ids := parseIDList(r.Form["ids"])
if len(ids) == 0 {
s.renderInbox(w, r, "", "Select at least one message first.")
return
}
ctx := r.Context()
msgs, err := s.store.MessagesByIDs(ctx, ids)
if err != nil {
s.logger.Error("load selected messages", "error", err)
s.renderInbox(w, r, "", "Could not load the selected messages.")
return
}
if len(msgs) == 0 {
s.renderInbox(w, r, "", "Those messages no longer exist.")
return
}
// Bulk delete needs a confirmation (FR-6) and every spam action needs its
// own confirmation (§10). The UI sets both after the user confirms; a
// request that arrives without them is refused here.
if action == "delete" {
if r.FormValue("confirm") != "yes" {
s.renderInbox(w, r, "", "Deleting needs confirmation. Select Delete again and confirm.")
return
}
spam, err := s.spamCount(ctx, msgs)
if err != nil {
s.logger.Error("check spam before delete", "error", err)
}
if spam > 0 && r.FormValue("spam_confirm") != "yes" {
s.renderInbox(w, r, "", fmt.Sprintf(
"%d selected message(s) are classified Spam. Spam is never deleted automatically; confirm to move them to the Delete folder.",
spam))
return
}
}
actor := sessionFrom(r).User
if actor == "" {
actor = "Sam"
}
var ok, failed int
for _, m := range msgs {
var err error
switch action {
case "delete", "save", "reply", "restore":
folder := folderForAction(action)
var label string
label, err = s.actions.Move(ctx, m, folder)
if err == nil {
if serr := s.store.SetMessageLocation(ctx, m.ID, folder, label); serr != nil {
s.logger.Error("record message location", "message_id", m.ID, "error", serr)
}
s.logMessageAction(ctx, m.ID, "move", actor,
fmt.Sprintf("to=%s label=%q from=%q", folder, label, m.Mailbox))
}
case "mark_read", "mark_unread", "star", "unstar":
mark := strings.TrimPrefix(action, "mark_")
err = s.actions.Mark(ctx, m, mark)
if err == nil {
s.logMessageAction(ctx, m.ID, "mark", actor, "mark="+mark)
}
default:
s.renderInbox(w, r, "", fmt.Sprintf("Unknown action %q.", action))
return
}
if err != nil {
failed++
s.logger.Warn("triage action failed", "action", action, "message_id", m.ID, "error", err)
continue
}
ok++
}
s.logAction(ctx, "bulk_"+action, actor,
fmt.Sprintf("requested=%d ok=%d failed=%d ids=%s", len(msgs), ok, failed, joinIDs(ids)))
note := actionSummary(action, ok, failed)
if failed > 0 {
note += " " + s.failureHint()
}
s.renderInbox(w, r, note, "")
}
// spamCount reports how many of the selected messages are classified Spam.
func (s *Server) spamCount(ctx context.Context, msgs []store.Message) (int, error) {
ids := make([]int64, len(msgs))
for i, m := range msgs {
ids[i] = m.ID
}
classes, err := s.store.LatestClassifications(ctx, ids)
if err != nil {
return 0, err
}
n := 0
for _, m := range msgs {
if strings.EqualFold(strings.TrimSpace(classes[m.ID].Category), "Spam") {
n++
}
}
return n, nil
}
// folderForAction maps a bulk action to its destination app folder.
func folderForAction(action string) string {
switch action {
case "delete":
return store.FolderDelete
case "save":
return store.FolderSaved
case "reply":
return store.FolderReplied
default:
return store.FolderInbox
}
}
// actionSummary is the human note rendered above the refreshed list.
func actionSummary(action string, ok, failed int) string {
verb := map[string]string{
"delete": "Moved %d message(s) to the Delete folder (recoverable).",
"save": "Saved %d message(s).",
"reply": "Marked %d message(s) as replied-to.",
"restore": "Restored %d message(s) to the inbox.",
"mark_read": "Marked %d message(s) read.",
"mark_unread": "Marked %d message(s) unread.",
"star": "Starred %d message(s).",
"unstar": "Unstarred %d message(s).",
}[action]
if verb == "" {
verb = "Updated %d message(s)."
}
msg := fmt.Sprintf(verb, ok)
if failed > 0 {
msg += fmt.Sprintf(" %d failed.", failed)
}
return msg
}
// failureHint explains the common reason a mailbox action fails.
func (s *Server) failureHint() string {
return "Check GMAIL_USER, GMAIL_APP_PASSWORD, and the container log."
}
// logMessageAction records a per-message action; a logging failure must not fail
// the request.
func (s *Server) logMessageAction(ctx context.Context, messageID int64, action, actor, detail string) {
if err := s.store.LogMessageAction(ctx, messageID, action, actor, detail); err != nil {
s.logger.Warn("log message action", "message_id", messageID, "action", action, "error", err)
}
}
// logAction records a summary action; a logging failure must not fail the
// request.
func (s *Server) logAction(ctx context.Context, action, actor, detail string) {
if err := s.store.LogAction(ctx, action, actor, detail); err != nil {
s.logger.Warn("log action", "action", action, "error", err)
}
}
// parseIDList parses form values into unique, positive message ids.
func parseIDList(values []string) []int64 {
seen := map[int64]struct{}{}
var out []int64
for _, v := range values {
for _, part := range strings.Split(v, ",") {
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || n <= 0 {
continue
}
if _, dup := seen[n]; dup {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
}
return out
}
// joinIDs renders ids for an action-log detail line.
func joinIDs(ids []int64) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatInt(id, 10)
}
return strings.Join(parts, ",")
}
// folderLabel is the display name for an app folder.
func folderLabel(folder string) string {
switch folder {
case store.FolderDelete:
return "Delete"
case store.FolderSaved:
return "Saved"
case store.FolderReplied:
return "Replied-to"
case "all":
return "All mail"
default:
return "Inbox"
}
}
// Ensure the live client satisfies the interface at compile time.
var _ MailActions = (*mailops.Client)(nil)

View File

@@ -0,0 +1,303 @@
package server
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
type moveCall struct {
id int64
dest string
}
type markCall struct {
id int64
mark string
}
type fakeActions struct {
mu sync.Mutex
moves []moveCall
marks []markCall
err error
}
func (f *fakeActions) Move(_ context.Context, msg store.Message, dest string) (string, error) {
if f.err != nil {
return "", f.err
}
f.mu.Lock()
defer f.mu.Unlock()
f.moves = append(f.moves, moveCall{id: msg.ID, dest: dest})
return mailops.MailboxForFolder(dest, msg.SourceAddress), nil
}
func (f *fakeActions) Mark(_ context.Context, msg store.Message, mark string) error {
if f.err != nil {
return f.err
}
f.mu.Lock()
defer f.mu.Unlock()
f.marks = append(f.marks, markCall{id: msg.ID, mark: mark})
return nil
}
func (f *fakeActions) moveCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.moves)
}
func (f *fakeActions) markCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.marks)
}
func newActionsServer(t *testing.T) (*Server, *fakeActions) {
t.Helper()
srv := newTestServer(t)
fa := &fakeActions{}
srv.actions = fa
return srv, fa
}
func seedClassified(t *testing.T, srv *Server, gmailID, subject, category string) int64 {
t.Helper()
ctx := context.Background()
id, err := srv.store.UpsertMessage(ctx, &store.Message{
GmailID: gmailID,
FromAddr: "sender@example.test",
ToAddr: "hello@where-woof.com",
Subject: subject,
SourceAddress: "hello@where-woof.com",
})
if err != nil {
t.Fatalf("seed message: %v", err)
}
if category != "" {
if _, err := srv.store.SaveClassification(ctx, store.Classification{
MessageID: id, Category: category, Importance: "high", Action: "delete", Confidence: 0.9,
}); err != nil {
t.Fatalf("seed classification: %v", err)
}
}
return id
}
func bulkForm(csrf, action string, ids []int64, extra url.Values) url.Values {
form := url.Values{"csrf": {csrf}, "action": {action}}
for _, id := range ids {
form.Add("ids", strconv.FormatInt(id, 10))
}
for k, v := range extra {
form[k] = v
}
return form
}
func TestBulkDeleteRequiresCSRF(t *testing.T) {
srv, fa := newActionsServer(t)
session, _, _ := login(t, srv)
seedClassified(t, srv, "1", "spammy", "Spam")
// No CSRF cookie and no csrf form value.
form := url.Values{"action": {"delete"}, "ids": {"1"}, "confirm": {"yes"}, "spam_confirm": {"yes"}}
rec := postForm(t, srv, "/actions/bulk", session, nil, form)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
if fa.moveCount() != 0 {
t.Errorf("moves = %d, want 0 on a rejected request", fa.moveCount())
}
}
func TestBulkDeleteRequiresConfirmation(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedClassified(t, srv, "1", "newsletter", "Newsletter")
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if fa.moveCount() != 0 {
t.Fatalf("moves = %d, want 0 without confirmation", fa.moveCount())
}
if !strings.Contains(rec.Body.String(), "needs confirmation") {
t.Errorf("body did not explain the confirmation requirement")
}
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
if len(msgs) != 1 || msgs[0].Folder != store.FolderInbox {
t.Errorf("message moved despite missing confirmation: %+v", msgs)
}
}
func TestBulkDeleteSpamRequiresSpamConfirmation(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedClassified(t, srv, "1", "lottery winner", "Spam")
extra := url.Values{"confirm": {"yes"}} // no spam_confirm
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, extra))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if fa.moveCount() != 0 {
t.Fatalf("moves = %d, want 0 without the spam confirmation", fa.moveCount())
}
if !strings.Contains(rec.Body.String(), "classified Spam") {
t.Errorf("body did not explain the spam confirmation")
}
}
func TestBulkDeleteThenRestoreIsRecoverable(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
spamID := seedClassified(t, srv, "1", "lottery winner", "Spam")
infoID := seedClassified(t, srv, "2", "receipt", "Info")
extra := url.Values{"confirm": {"yes"}, "spam_confirm": {"yes"}}
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{spamID, infoID}, extra))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if fa.moveCount() != 2 {
t.Fatalf("moves = %d, want 2", fa.moveCount())
}
if !strings.Contains(rec.Body.String(), "Delete folder") {
t.Errorf("note did not mention the Delete folder")
}
ctx := context.Background()
deleted, err := srv.store.ListMessagesFiltered(ctx, store.MessageFilter{Folder: store.FolderDelete, Limit: 10})
if err != nil {
t.Fatalf("list delete folder: %v", err)
}
if len(deleted) != 2 {
t.Fatalf("delete folder has %d messages, want 2", len(deleted))
}
for _, m := range deleted {
if m.Mailbox != "Delete" {
t.Errorf("message %d mailbox = %q, want Delete", m.ID, m.Mailbox)
}
}
// The Delete folder view shows the recoverable rows with a Restore button.
view := getWithSession(t, srv, "/partials/inbox?folder=delete", session)
if view.Code != http.StatusOK {
t.Fatalf("delete folder view status = %d", view.Code)
}
if !strings.Contains(view.Body.String(), "Restore") {
t.Errorf("delete folder view has no Restore button")
}
// Restore one message.
rec = postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "restore", []int64{spamID}, nil))
if rec.Code != http.StatusOK {
t.Fatalf("restore status = %d, want 200", rec.Code)
}
restored, err := srv.store.MessagesByIDs(ctx, []int64{spamID})
if err != nil {
t.Fatalf("load restored message: %v", err)
}
if len(restored) != 1 || restored[0].Folder != store.FolderInbox || restored[0].Mailbox != "INBOX" {
t.Errorf("restored message = %+v, want inbox/INBOX", restored)
}
// FR-13: every action has a log row.
entries, err := srv.store.RecentActions(ctx, 50)
if err != nil {
t.Fatalf("RecentActions() error = %v", err)
}
var moves, bulk int
for _, e := range entries {
if e.Action == "move" {
moves++
}
if strings.HasPrefix(e.Action, "bulk_") {
bulk++
}
}
if moves != 3 {
t.Errorf("move log rows = %d, want 3 (2 deletes + 1 restore)", moves)
}
if bulk != 2 {
t.Errorf("bulk summary rows = %d, want 2", bulk)
}
}
func TestInboxPartialHasSelectCheckboxes(t *testing.T) {
srv, _ := newActionsServer(t)
session, _, _ := login(t, srv)
seedClassified(t, srv, "1", "one", "Info")
seedClassified(t, srv, "2", "two", "Info")
rec := getWithSession(t, srv, "/partials/inbox", session)
body := rec.Body.String()
if !strings.Contains(body, `id="select-all"`) {
t.Errorf("partial missing select-all checkbox")
}
if strings.Count(body, `name="ids"`) != 2 {
t.Errorf("checkbox count = %d, want 2", strings.Count(body, `name="ids"`))
}
}
func TestBulkMoveAndMark(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedClassified(t, srv, "1", "receipt", "Info")
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "save", []int64{id}, nil)); rec.Code != http.StatusOK {
t.Fatalf("save status = %d", rec.Code)
}
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "mark_read", []int64{id}, nil)); rec.Code != http.StatusOK {
t.Fatalf("mark_read status = %d", rec.Code)
}
if fa.moveCount() != 1 || fa.markCount() != 1 {
t.Fatalf("calls: moves=%d marks=%d, want 1 and 1", fa.moveCount(), fa.markCount())
}
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
if len(msgs) != 1 || msgs[0].Folder != store.FolderSaved {
t.Errorf("message folder = %+v, want saved", msgs)
}
}
func TestBulkNoSelection(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", nil, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Select at least one") {
t.Errorf("body did not explain the empty selection")
}
if fa.moveCount() != 0 {
t.Errorf("moves = %d, want 0", fa.moveCount())
}
}
func TestParseIDList(t *testing.T) {
got := parseIDList([]string{"1,2", "2", "x", "-3", "4"})
want := []int64{1, 2, 4}
if len(got) != len(want) {
t.Fatalf("parseIDList() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("parseIDList()[%d] = %d, want %d", i, got[i], want[i])
}
}
}

View File

@@ -215,6 +215,9 @@ type navItem struct {
Name string
Color string
Count int
// Param is the /partials/inbox query fragment for this item, for example
// "category=Action" or "folder=delete" (Phase 4 folder views).
Param string
}
type dashboardData struct {
@@ -250,7 +253,7 @@ func categoryCounts(items []store.MessageListItem, categories []store.Category)
return counts
}
// handleDashboard renders the inbox shell with real category counts.
// handleDashboard renders the inbox shell with real category and folder counts.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r)
@@ -266,15 +269,26 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.logger.Error("load dashboard categories", "error", err)
}
counts := categoryCounts(items, categories)
folderCounts, err := s.store.CountByFolder(r.Context())
if err != nil {
s.logger.Error("load dashboard folder counts", "error", err)
folderCounts = nil
}
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: c.Name,
Color: "var(--primary-soft, var(--surface-2))",
Count: counts[c.Name],
Param: "category=" + url.QueryEscape(c.Name),
})
}
catNav = append(catNav, navItem{
Name: unclassifiedCategory,
Color: "var(--ink-faint)",
Count: counts[unclassifiedCategory],
Param: "category=" + url.QueryEscape(unclassifiedCategory),
})
data := dashboardData{
@@ -285,9 +299,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
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)"},
{Name: "Replied-to", Color: "var(--sticker-green)", Count: folderCounts[store.FolderReplied], Param: "folder=" + store.FolderReplied},
{Name: "Delete", Color: "var(--sticker-red)", Count: folderCounts[store.FolderDelete], Param: "folder=" + store.FolderDelete},
{Name: "Saved", Color: "var(--sticker-teal)", Count: folderCounts[store.FolderSaved], Param: "folder=" + store.FolderSaved},
},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")

View File

@@ -1,8 +1,9 @@
// 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".
// 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 (
@@ -10,6 +11,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
@@ -20,7 +22,7 @@ import (
)
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
const unclassifiedCategory = "Unclassified"
const unclassifiedCategory = store.UnclassifiedCategory
type messageRow struct {
ID int64
@@ -31,6 +33,9 @@ type messageRow struct {
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 {
@@ -39,15 +44,20 @@ type messageGroup struct {
}
type inboxData struct {
Groups []messageGroup
Total int
Note string
Error string
Groups []messageGroup
Total int
Note string
Error string
CSRFToken string
Folder string
Category string
FolderLabel 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)
// 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
}
@@ -88,6 +98,10 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
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)"),
@@ -97,6 +111,7 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
Confidence: confidencePercent(it.Confidence),
Classified: it.HasClassification,
When: formatWhen(it.DateSent, it.ReceivedAt),
Restorable: folder != store.FolderInbox,
})
}
@@ -126,6 +141,20 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
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 {
@@ -137,14 +166,28 @@ func confidencePercent(v float64) int {
return int(v*100 + 0.5)
}
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
data, err := s.loadInbox(r.Context())
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)
@@ -153,7 +196,7 @@ func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string
// handleInboxPartial is the HTMX fragment for the centre list.
func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) {
s.renderInbox(w, r, "")
s.renderInbox(w, r, "", "")
}
// handleFetchMail runs one IMAP scan, then returns the refreshed list. It is a
@@ -170,14 +213,14 @@ func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) {
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))
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.")
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.")
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())
s.renderInbox(w, r, "Mail scan failed: "+err.Error(), "")
}
}
@@ -219,12 +262,12 @@ func (s *Server) runClassifier(w http.ResponseWriter, r *http.Request, all bool)
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))
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.")
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())
s.renderInbox(w, r, "Classification failed: "+err.Error(), "")
}
}
@@ -258,3 +301,18 @@ func formatWhen(sent, received time.Time) string {
}
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()
}

View File

@@ -20,6 +20,7 @@ import (
"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/mailops"
"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"
@@ -41,6 +42,7 @@ type Server struct {
sessions *session.Manager
fetcher *mailfetch.Fetcher
classifier *classify.Classifier
actions MailActions
}
// New builds a Server. It does not start listening.
@@ -70,6 +72,7 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
),
actions: mailops.New(cfg, logger),
}
s.http = &http.Server{
Addr: cfg.Addr,
@@ -122,6 +125,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /actions/classify", s.requireAuth(s.handleClassify))
mux.HandleFunc("POST /actions/reclassify", s.requireAuth(s.handleReclassify))
// Phase 4 (FR-6/FR-9): bulk triage actions (delete, move, mark) and restore.
mux.HandleFunc("POST /actions/bulk", s.requireAuth(s.handleBulkAction))
// FR-14: editable category list.
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))