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:
255
internal/server/actions.go
Normal file
255
internal/server/actions.go
Normal 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)
|
||||
303
internal/server/actions_test.go
Normal file
303
internal/server/actions_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user