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

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])
}
}
}