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>
304 lines
8.9 KiB
Go
304 lines
8.9 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|