From 8c0416750e4457c9512d132c32b69bbfcb0624f5 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sun, 11 Oct 2026 21:44:52 +1100 Subject: [PATCH] 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 --- README.md | 47 +++- internal/mailfetch/imap_write.go | 114 ++++++++++ internal/mailfetch/imap_write_test.go | 96 ++++++++ internal/mailops/mailops.go | 246 +++++++++++++++++++++ internal/mailops/mailops_test.go | 203 +++++++++++++++++ internal/server/actions.go | 255 ++++++++++++++++++++++ internal/server/actions_test.go | 303 ++++++++++++++++++++++++++ internal/server/auth.go | 24 +- internal/server/inbox.go | 98 +++++++-- internal/server/server.go | 6 + internal/store/messages.go | 26 ++- internal/store/schema.sql | 7 + internal/store/store.go | 21 ++ internal/store/triage.go | 265 ++++++++++++++++++++++ internal/store/triage_test.go | 194 +++++++++++++++++ web/static/app.css | 37 ++++ web/templates/dashboard.html | 37 +++- web/templates/inbox.html | 115 +++++++--- 18 files changed, 2023 insertions(+), 71 deletions(-) create mode 100644 internal/mailfetch/imap_write.go create mode 100644 internal/mailfetch/imap_write_test.go create mode 100644 internal/mailops/mailops.go create mode 100644 internal/mailops/mailops_test.go create mode 100644 internal/server/actions.go create mode 100644 internal/server/actions_test.go create mode 100644 internal/store/triage.go create mode 100644 internal/store/triage_test.go diff --git a/README.md b/README.md index 63e111c..3da7f7d 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,16 @@ pre-planning material and is not authoritative. ## Status -**Phase 3 — Classify.** The Go skeleton, session login, and HTMX dashboard -(Phase 1), real mail over **IMAP with an app password, forced to IPv4** -(Phase 2), and now **JEV classification** (Phase 3). Every message gets a -category, an importance, and a suggested action, grouped in the triage list with -the recommended action and confidence per row. The category list is editable in -the UI (add, rename, remove) and JEV uses the current list; a plain-text rules -table can override the classifier (FR-4). The FR-2 source address is resolved -from `X-Pm-Original-To`, with `To`, the PurelyMail `Received … for` clause, and -`Return-Path` as fallbacks; `Delivered-To` is never used. See -[`docs/phase3-classify.md`](docs/phase3-classify.md) for the confirmed JEV shape -and the phase evidence. Triage actions come in Phase 4. +**Phase 4 — Triage actions.** The Go skeleton, session login, and HTMX +dashboard (Phase 1), real mail over **IMAP with an app password, forced to IPv4** +(Phase 2), **JEV classification** (Phase 3), and now **bulk triage actions** +(Phase 4). The inbox has a select-all toolbar for delete, move, and mark (FR-6), +and a folder view for the recoverable Delete/Saved/Replied-to Gmail labels (FR-9). +A delete moves the message to a Gmail label (never a hard delete) and a row in +the Delete folder can be **restored** to the inbox. Bulk delete needs a UI +confirmation and a message classified Spam needs a second confirmation — spam is +never auto-deleted (§10). Every action is appended to `actions_log` (FR-13). See +[`docs/phase4-triage.md`](docs/phase4-triage.md) for the phase evidence. ## Layout @@ -28,6 +27,7 @@ cmd/email-agent/ service entry point (`-fetch`, `-classify`, `-classify-a internal/classify/ JEV (TypeSafe System One) client, rule engine, orchestration internal/config/ environment-driven configuration internal/mailfetch/ stdlib-only IMAP client, MIME parsing, FR-2 source address +internal/mailops/ Gmail label moves and flag changes for triage actions internal/session/ server-side session store + signed cookie values internal/store/ SQLite open + schema (internal/store/schema.sql) internal/server/ HTTP routes, auth middleware, HTMX handlers @@ -124,6 +124,26 @@ in `classifications.category` and the Choice option sent to JEV, so the model always sees the current list. Renaming cascades to existing classifications; removing a category reassigns its messages to the first remaining category. +## Triage actions (Phase 4) + +`internal/mailops` turns an app folder into a Gmail label and performs the move +over IMAP, forcing IPv4 exactly like the read path: + +| App folder | Gmail label | +|---|---| +| `inbox` | `INBOX` | +| `delete` | `Delete` (recoverable; never a hard delete) | +| `saved` | `Saved` | +| `replied` | `Replied-to/
` | + +A move uses the IMAP `MOVE` extension (with a `COPY` + `\Deleted` + `EXPUNGE` +fallback). Because Gmail gives each label its own `UID`, a message is located by +its stable `X-GM-MSGID`, not by a stored `UID`. A message in the Delete, Saved, +or Replied-to folder has a **Restore** button that moves it back to `INBOX`. + +Every action appends to `actions_log`: one `move` or `mark` row per message, +plus one `bulk_` summary row. + ## Run locally ```sh @@ -157,6 +177,7 @@ Caddy on `.35` proxies `mail.lab.audasmedia.com.au` to this port. | `POST` | `/actions/fetch` | session + CSRF | Runs one IMAP scan, returns the refreshed list | | `POST` | `/actions/classify` | session + CSRF | Classifies pending messages, returns the refreshed list | | `POST` | `/actions/reclassify` | session + CSRF | Re-classifies every message with the current categories and rules | +| `POST` | `/actions/bulk` | session + CSRF | Bulk delete/move/mark and restore over selected message ids (FR-6/FR-9) | | `GET` | `/categories` | session | Category editor (FR-14) | | `POST` | `/categories/add` | session + CSRF | Adds a category | | `POST` | `/categories/rename` | session + CSRF | Renames a category and updates existing classifications | @@ -175,4 +196,6 @@ schema in `internal/store/schema.sql` creates the AGENTS.md §7 tables: `summaries`, plus `categories`, `schema_migrations`. Schema init is idempotent and runs on every start. Migration v2 adds `imap_uid` and `imap_uidvalidity` to `messages`; migration v3 seeds the first category list and a starter rules -version once, so a category removed by the user is not silently re-added. +version once, so a category removed by the user is not silently re-added; +migration v4 adds `folder` and `mailbox` to `messages` for the Phase 4 triage +folders. diff --git a/internal/mailfetch/imap_write.go b/internal/mailfetch/imap_write.go new file mode 100644 index 0000000..10bf48f --- /dev/null +++ b/internal/mailfetch/imap_write.go @@ -0,0 +1,114 @@ +// IMAP write operations for triage actions (Phase 4, FR-6/FR-9). +// +// The read path selects INBOX and fetches messages. The write path adds the +// operations a move needs: create the destination label, locate a message in +// the mailbox it currently lives in (Gmail gives each label its own UID, so the +// stable X-GM-MSGID is used), move it, and set or clear flags. +// +// Deletes are never hard-deletes: a "delete" is a move to a recoverable label. +package mailfetch + +import ( + "errors" + "strconv" + "strings" +) + +// ErrMailboxExists is returned by CreateMailbox only when the server reports +// something other than "already exists". +var ErrMailboxExists = errors.New("mailbox already exists") + +// CreateMailbox creates an IMAP mailbox (a Gmail label). An already-existing +// mailbox is not an error: triage creates "Delete" and "Saved" lazily. +func (c *Client) CreateMailbox(name string) error { + err := c.exec("CREATE "+imapQuote(name), nil) + if err == nil { + return nil + } + var se *ServerError + if errors.As(err, &se) { + upper := strings.ToUpper(se.Response) + if strings.Contains(upper, "ALREADYEXISTS") || + strings.Contains(upper, "ALREADY EXISTS") || + strings.Contains(upper, "MAILBOX EXISTS") { + return nil + } + } + return err +} + +// Move moves a message to dest using the IMAP MOVE extension (RFC 6851, which +// Gmail supports). If the server rejects MOVE, it falls back to COPY followed +// by a source-side delete so the destination copy remains recoverable. +func (c *Client) Move(uid uint32, dest string) error { + id := strconv.FormatUint(uint64(uid), 10) + if err := c.exec("UID MOVE "+id+" "+imapQuote(dest), nil); err == nil { + return nil + } else { + var se *ServerError + if !errors.As(err, &se) { + return err + } + } + if err := c.exec("UID COPY "+id+" "+imapQuote(dest), nil); err != nil { + return err + } + if err := c.exec("UID STORE "+id+" +FLAGS (\\Deleted)", nil); err != nil { + return err + } + return c.Expunge(uid) +} + +// Expunge removes a \Deleted message from the selected mailbox. UIDPLUS +// (RFC 4315) keeps the expunge scoped to one UID; the plain EXPUNGE fallback is +// only used by servers that do not advertise it. +func (c *Client) Expunge(uid uint32) error { + id := strconv.FormatUint(uint64(uid), 10) + if err := c.exec("UID EXPUNGE "+id, nil); err == nil { + return nil + } else { + var se *ServerError + if !errors.As(err, &se) { + return err + } + } + return c.exec("EXPUNGE", nil) +} + +// StoreFlags adds (op "+") or removes (op "-") message flags. +func (c *Client) StoreFlags(uid uint32, op string, flag string) error { + if op != "+" && op != "-" { + return errors.New("flag operation must be + or -") + } + id := strconv.FormatUint(uint64(uid), 10) + return c.exec("UID STORE "+id+" "+op+"FLAGS ("+flag+")", nil) +} + +// SearchGmailID returns the UIDs of the messages with the given Gmail message +// id (X-GM-MSGID) in the currently selected mailbox. Gmail assigns a different +// UID per label, so this is how a message is located after a move. +func (c *Client) SearchGmailID(gmailID string) ([]uint32, error) { + gmailID = strings.TrimSpace(gmailID) + if gmailID == "" { + return nil, errors.New("empty x-gm-msgid") + } + for _, r := range gmailID { + if r < '0' || r > '9' { + return nil, errors.New("x-gm-msgid must be numeric") + } + } + var uids []uint32 + err := c.exec("UID SEARCH X-GM-MSGID "+gmailID, func(resp *response) error { + if !strings.HasPrefix(strings.ToUpper(resp.line), "* SEARCH") { + return nil + } + fields := strings.Fields(resp.line) + for _, f := range fields[2:] { + if n, err := strconv.ParseUint(f, 10, 32); err == nil { + uids = append(uids, uint32(n)) + } + } + return nil + }) + return uids, err +} diff --git a/internal/mailfetch/imap_write_test.go b/internal/mailfetch/imap_write_test.go new file mode 100644 index 0000000..c0f64cc --- /dev/null +++ b/internal/mailfetch/imap_write_test.go @@ -0,0 +1,96 @@ +package mailfetch + +import ( + "errors" + "strings" + "testing" +) + +func TestCreateMailboxIgnoresAlreadyExists(t *testing.T) { + c, _ := newFakeClient("A0001 NO [ALREADYEXISTS] Mailbox already exists\r\n") + if err := c.CreateMailbox("Delete"); err != nil { + t.Fatalf("CreateMailbox() error = %v, want nil for an existing mailbox", err) + } +} + +func TestCreateMailboxReturnsOtherError(t *testing.T) { + c, _ := newFakeClient("A0001 NO [CANNOT] permission denied\r\n") + err := c.CreateMailbox("Delete") + if err == nil { + t.Fatal("CreateMailbox() error = nil, want failure") + } + var se *ServerError + if !errors.As(err, &se) { + t.Fatalf("CreateMailbox() error = %T, want *ServerError", err) + } +} + +func TestMoveUsesMoveExtension(t *testing.T) { + c, fc := newFakeClient("A0001 OK MOVE completed\r\n") + if err := c.Move(5, "Delete"); err != nil { + t.Fatalf("Move() error = %v", err) + } + if !strings.Contains(fc.w.String(), `UID MOVE 5 "Delete"`) { + t.Errorf("command = %q, want UID MOVE", fc.w.String()) + } +} + +func TestMoveFallsBackToCopyStoreExpunge(t *testing.T) { + script := "A0001 NO [BAD] MOVE not supported\r\n" + + "A0002 OK COPY completed\r\n" + + "A0003 OK STORE completed\r\n" + + "A0004 OK EXPUNGE completed\r\n" + c, fc := newFakeClient(script) + if err := c.Move(9, "Delete"); err != nil { + t.Fatalf("Move() fallback error = %v", err) + } + got := fc.w.String() + for _, want := range []string{ + `UID MOVE 9 "Delete"`, + `UID COPY 9 "Delete"`, + `UID STORE 9 +FLAGS (\Deleted)`, + "UID EXPUNGE 9", + } { + if !strings.Contains(got, want) { + t.Errorf("writes = %q, missing %q", got, want) + } + } +} + +func TestStoreFlags(t *testing.T) { + c, fc := newFakeClient("A0001 OK STORE completed\r\n") + if err := c.StoreFlags(4, "+", `\Seen`); err != nil { + t.Fatalf("StoreFlags() error = %v", err) + } + if !strings.Contains(fc.w.String(), `UID STORE 4 +FLAGS (\Seen)`) { + t.Errorf("command = %q", fc.w.String()) + } +} + +func TestStoreFlagsRejectsBadOp(t *testing.T) { + c, _ := newFakeClient("") + if err := c.StoreFlags(4, "*", `\Seen`); err == nil { + t.Fatal("StoreFlags() error = nil, want failure") + } +} + +func TestSearchGmailID(t *testing.T) { + c, fc := newFakeClient("* SEARCH 3 4\r\nA0001 OK SEARCH completed\r\n") + uids, err := c.SearchGmailID("1234000000000000000") + if err != nil { + t.Fatalf("SearchGmailID() error = %v", err) + } + if len(uids) != 2 || uids[0] != 3 || uids[1] != 4 { + t.Fatalf("uids = %v, want [3 4]", uids) + } + if !strings.Contains(fc.w.String(), "UID SEARCH X-GM-MSGID 1234000000000000000") { + t.Errorf("command = %q", fc.w.String()) + } +} + +func TestSearchGmailIDRejectsNonNumeric(t *testing.T) { + c, _ := newFakeClient("") + if _, err := c.SearchGmailID(`1234") DELETE`); err == nil { + t.Fatal("SearchGmailID() error = nil, want failure for a non-numeric id") + } +} diff --git a/internal/mailops/mailops.go b/internal/mailops/mailops.go new file mode 100644 index 0000000..d5206c6 --- /dev/null +++ b/internal/mailops/mailops.go @@ -0,0 +1,246 @@ +// Package mailops performs the triage actions from Phase 4 over Gmail IMAP +// (FR-6, FR-9). +// +// The app's folders map to Gmail labels: +// +// inbox -> INBOX +// delete -> Delete (recoverable; never a hard delete) +// saved -> Saved +// replied -> Replied-to/
(FR-9, replied-to by address) +// +// Every connection forces IPv4, matching the read path (AGENTS.md §9). +package mailops + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "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/store" +) + +// Label names for the app folders. "Delete" and "Saved" are ordinary Gmail +// labels created lazily on first use. +const ( + LabelInbox = "INBOX" + LabelDelete = "Delete" + LabelSaved = "Saved" + repliedPrefix = "Replied-to/" +) + +// ErrNotConfigured means Gmail credentials are missing, so no mailbox action can +// be attempted. +var ErrNotConfigured = errors.New("Gmail is not configured: set GMAIL_USER and GMAIL_APP_PASSWORD") + +// Conn is the subset of the IMAP client the actions use. It lets the actions be +// tested without a live Gmail connection. +type Conn interface { + Login(user, pass string) error + Select(mailbox string) (uint32, error) + CreateMailbox(name string) error + Move(uid uint32, dest string) error + StoreFlags(uid uint32, op string, flag string) error + SearchGmailID(gmailID string) ([]uint32, error) + Logout() error +} + +// dialFunc opens an IMAP connection. It is a field so tests can substitute one. +type dialFunc func(ctx context.Context, host string, port int, timeout time.Duration) (Conn, error) + +// Client performs triage actions against Gmail IMAP. +type Client struct { + cfg *config.Config + logger *slog.Logger + dial dialFunc +} + +// New builds a Client using the real Gmail IMAP dialer. +func New(cfg *config.Config, logger *slog.Logger) *Client { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + return &Client{cfg: cfg, logger: logger, dial: defaultDial} +} + +// defaultDial dials Gmail over IPv4 (mailfetch.Dial forces tcp4). +func defaultDial(ctx context.Context, host string, port int, timeout time.Duration) (Conn, error) { + return mailfetch.Dial(ctx, host, port, timeout) +} + +// Move files a message under destFolder and returns the Gmail label it now lives +// in. It creates the destination label when needed. A move to the folder the +// message is already in is a no-op. +func (c *Client) Move(ctx context.Context, msg store.Message, destFolder string) (string, error) { + dest := MailboxForFolder(destFolder, msg.SourceAddress) + source := msg.Mailbox + if strings.TrimSpace(source) == "" { + source = LabelInbox + } + if strings.EqualFold(dest, source) { + return dest, nil + } + + conn, err := c.connect(ctx) + if err != nil { + return "", err + } + defer func() { _ = conn.Logout() }() + + if !strings.EqualFold(dest, LabelInbox) { + if err := conn.CreateMailbox(dest); err != nil { + return "", fmt.Errorf("create label %q: %w", dest, err) + } + } + uidValidity, err := conn.Select(source) + if err != nil { + return "", fmt.Errorf("select %q: %w", source, err) + } + uid, err := locate(conn, msg, source, uidValidity) + if err != nil { + return "", err + } + if err := conn.Move(uid, dest); err != nil { + return "", fmt.Errorf("move message %s from %q to %q: %w", msg.GmailID, source, dest, err) + } + c.logger.Info("message moved", + "message_id", msg.ID, "gmail_id", msg.GmailID, "from", source, "to", dest) + return dest, nil +} + +// Mark applies a read/star flag change to a message in its current mailbox. +// mark is one of read, unread, star, unstar. +func (c *Client) Mark(ctx context.Context, msg store.Message, mark string) error { + op, flag, err := markFlag(mark) + if err != nil { + return err + } + source := msg.Mailbox + if strings.TrimSpace(source) == "" { + source = LabelInbox + } + conn, err := c.connect(ctx) + if err != nil { + return err + } + defer func() { _ = conn.Logout() }() + + uidValidity, err := conn.Select(source) + if err != nil { + return fmt.Errorf("select %q: %w", source, err) + } + uid, err := locate(conn, msg, source, uidValidity) + if err != nil { + return err + } + if err := conn.StoreFlags(uid, op, flag); err != nil { + return fmt.Errorf("mark message %s %s: %w", msg.GmailID, mark, err) + } + return nil +} + +// connect dials and logs in. Callers must Logout when done. +func (c *Client) connect(ctx context.Context) (Conn, error) { + if !c.cfg.GmailConfigured() { + return nil, ErrNotConfigured + } + conn, err := c.dial(ctx, c.cfg.IMAPHost, c.cfg.IMAPPort, mailfetch.DefaultTimeout) + if err != nil { + return nil, err + } + if err := conn.Login(c.cfg.GmailUser, c.cfg.GmailAppPassword); err != nil { + _ = conn.Logout() + return nil, fmt.Errorf("imap login: %w", err) + } + return conn, nil +} + +// locate finds a message's UID in the selected mailbox. Gmail gives each label +// its own UID, so the stable X-GM-MSGID is always used rather than a UID stored +// from a previous mailbox. +func locate(conn Conn, msg store.Message, source string, uidValidity uint32) (uint32, error) { + if !isGmailMessageID(msg.GmailID) { + return 0, fmt.Errorf("message %d has no Gmail message id; cannot locate it in %q", msg.ID, source) + } + uids, err := conn.SearchGmailID(msg.GmailID) + if err != nil { + return 0, fmt.Errorf("search %q for %s: %w", source, msg.GmailID, err) + } + if len(uids) == 0 { + return 0, fmt.Errorf("message %s is not in %q", msg.GmailID, source) + } + _ = uidValidity + return uids[len(uids)-1], nil +} + +// isGmailMessageID reports whether id is a numeric X-GM-MSGID. Messages stored +// with the "uid::" fallback cannot be searched across labels. +func isGmailMessageID(id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false + } + for _, r := range id { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// MailboxForFolder maps an app folder to a Gmail label. The replied folder uses +// the address the mail arrived at (FR-15), sanitised into a legal label. +func MailboxForFolder(folder, sourceAddress string) string { + switch folder { + case store.FolderDelete: + return LabelDelete + case store.FolderSaved: + return LabelSaved + case store.FolderReplied: + return repliedPrefix + SanitizeLabelAddress(sourceAddress) + default: + return LabelInbox + } +} + +// SanitizeLabelAddress turns an email address into a Gmail-label-safe segment. +func SanitizeLabelAddress(addr string) string { + addr = strings.TrimSpace(addr) + addr = strings.Trim(addr, "<>") + addr = strings.ToLower(addr) + var b strings.Builder + for _, r := range addr { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', + r == '@', r == '.', r == '_', r == '+', r == '-': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + out := b.String() + if out == "" { + return "unknown" + } + return out +} + +// markFlag maps an action name to the IMAP flag operation. +func markFlag(mark string) (op, flag string, err error) { + switch strings.ToLower(strings.TrimSpace(mark)) { + case "read": + return "+", "\\Seen", nil + case "unread": + return "-", "\\Seen", nil + case "star": + return "+", "\\Flagged", nil + case "unstar": + return "-", "\\Flagged", nil + default: + return "", "", fmt.Errorf("unknown mark %q", mark) + } +} diff --git a/internal/mailops/mailops_test.go b/internal/mailops/mailops_test.go new file mode 100644 index 0000000..5b12b70 --- /dev/null +++ b/internal/mailops/mailops_test.go @@ -0,0 +1,203 @@ +package mailops + +import ( + "context" + "errors" + "fmt" + "log/slog" + "testing" + "time" + + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" +) + +type fakeConn struct { + selected string + uidValidity uint32 + created []string + moves []string + stores []string + searched []string + searchUIDs []uint32 + searchErr error + loggedOut bool +} + +func (f *fakeConn) Login(string, string) error { return nil } +func (f *fakeConn) Select(mailbox string) (uint32, error) { + f.selected = mailbox + return f.uidValidity, nil +} +func (f *fakeConn) CreateMailbox(name string) error { + f.created = append(f.created, name) + return nil +} +func (f *fakeConn) Move(uid uint32, dest string) error { + f.moves = append(f.moves, fmt.Sprintf("%d->%s", uid, dest)) + return nil +} +func (f *fakeConn) StoreFlags(uid uint32, op, flag string) error { + f.stores = append(f.stores, fmt.Sprintf("%d %s%s", uid, op, flag)) + return nil +} +func (f *fakeConn) SearchGmailID(id string) ([]uint32, error) { + f.searched = append(f.searched, id) + return f.searchUIDs, f.searchErr +} +func (f *fakeConn) Logout() error { f.loggedOut = true; return nil } + +func configured() *config.Config { + return &config.Config{ + Env: "test", + IMAPHost: config.DefaultIMAPHost, + IMAPPort: config.DefaultIMAPPort, + GmailUser: "user@example.test", + GmailAppPassword: "app-password", + } +} + +func newFakeClient(fc *fakeConn) *Client { + c := New(configured(), slog.New(slog.DiscardHandler)) + c.dial = func(context.Context, string, int, time.Duration) (Conn, error) { return fc, nil } + return c +} + +func TestMailboxForFolder(t *testing.T) { + cases := []struct { + folder string + addr string + want string + }{ + {store.FolderInbox, "", "INBOX"}, + {store.FolderDelete, "", "Delete"}, + {store.FolderSaved, "", "Saved"}, + {store.FolderReplied, "Hello@Where-Woof.com", "Replied-to/hello@where-woof.com"}, + {store.FolderReplied, "", "Replied-to/unknown"}, + {"nonsense", "", "INBOX"}, + } + for _, tc := range cases { + if got := MailboxForFolder(tc.folder, tc.addr); got != tc.want { + t.Errorf("MailboxForFolder(%q, %q) = %q, want %q", tc.folder, tc.addr, got, tc.want) + } + } +} + +func TestSanitizeLabelAddress(t *testing.T) { + if got := SanitizeLabelAddress(" "); got != "sam+news@example.com" { + t.Errorf("SanitizeLabelAddress() = %q", got) + } + if got := SanitizeLabelAddress(`weird"name@x`); got != "weird_name@x" { + t.Errorf("SanitizeLabelAddress() = %q", got) + } +} + +func TestMoveDeleteCreatesLabelAndUsesGmailID(t *testing.T) { + fc := &fakeConn{uidValidity: 7, searchUIDs: []uint32{42}} + c := newFakeClient(fc) + + label, err := c.Move(context.Background(), store.Message{ + ID: 1, GmailID: "1234000000000000000", Mailbox: "INBOX", + }, store.FolderDelete) + if err != nil { + t.Fatalf("Move() error = %v", err) + } + if label != "Delete" { + t.Errorf("label = %q, want Delete", label) + } + if len(fc.created) != 1 || fc.created[0] != "Delete" { + t.Errorf("created = %v, want [Delete]", fc.created) + } + if fc.selected != "INBOX" { + t.Errorf("selected = %q, want INBOX", fc.selected) + } + if len(fc.moves) != 1 || fc.moves[0] != "42->Delete" { + t.Errorf("moves = %v, want [42->Delete]", fc.moves) + } + if len(fc.searched) != 1 || fc.searched[0] != "1234000000000000000" { + t.Errorf("searched = %v", fc.searched) + } + if !fc.loggedOut { + t.Error("connection was not logged out") + } +} + +func TestMoveRestoreDoesNotCreateInbox(t *testing.T) { + fc := &fakeConn{uidValidity: 7, searchUIDs: []uint32{9}} + c := newFakeClient(fc) + + label, err := c.Move(context.Background(), store.Message{ + ID: 2, GmailID: "555", Mailbox: "Delete", + }, store.FolderInbox) + if err != nil { + t.Fatalf("Move() error = %v", err) + } + if label != "INBOX" { + t.Errorf("label = %q, want INBOX", label) + } + if len(fc.created) != 0 { + t.Errorf("created = %v, want none for INBOX", fc.created) + } + if fc.selected != "Delete" { + t.Errorf("selected = %q, want Delete", fc.selected) + } + if len(fc.moves) != 1 || fc.moves[0] != "9->INBOX" { + t.Errorf("moves = %v", fc.moves) + } +} + +func TestMoveNoopWhenAlreadyInFolder(t *testing.T) { + fc := &fakeConn{} + c := newFakeClient(fc) + if _, err := c.Move(context.Background(), store.Message{ID: 3, GmailID: "1", Mailbox: "Saved"}, store.FolderSaved); err != nil { + t.Fatalf("Move() error = %v", err) + } + if len(fc.moves) != 0 || len(fc.searched) != 0 { + t.Errorf("no-op move did work: moves=%v searched=%v", fc.moves, fc.searched) + } +} + +func TestMoveRejectsNonGmailID(t *testing.T) { + c := newFakeClient(&fakeConn{}) + _, err := c.Move(context.Background(), store.Message{ID: 4, GmailID: "uid:7:9", Mailbox: "INBOX"}, store.FolderDelete) + if err == nil { + t.Fatal("Move() error = nil, want failure for a uid: fallback id") + } +} + +func TestMoveNotConfigured(t *testing.T) { + c := New(&config.Config{Env: "test", IMAPHost: config.DefaultIMAPHost, IMAPPort: config.DefaultIMAPPort}, nil) + _, err := c.Move(context.Background(), store.Message{ID: 5, GmailID: "1", Mailbox: "INBOX"}, store.FolderDelete) + if !errors.Is(err, ErrNotConfigured) { + t.Fatalf("Move() error = %v, want ErrNotConfigured", err) + } +} + +func TestMarkMapsFlags(t *testing.T) { + cases := []struct { + mark string + want string + }{ + {"read", "3 +\\Seen"}, + {"unread", "3 -\\Seen"}, + {"star", "3 +\\Flagged"}, + {"unstar", "3 -\\Flagged"}, + } + for _, tc := range cases { + fc := &fakeConn{uidValidity: 1, searchUIDs: []uint32{3}} + c := newFakeClient(fc) + if err := c.Mark(context.Background(), store.Message{ID: 6, GmailID: "777"}, tc.mark); err != nil { + t.Fatalf("Mark(%q) error = %v", tc.mark, err) + } + if len(fc.stores) != 1 || fc.stores[0] != tc.want { + t.Errorf("Mark(%q) stores = %v, want %q", tc.mark, fc.stores, tc.want) + } + } +} + +func TestMarkUnknown(t *testing.T) { + c := newFakeClient(&fakeConn{}) + if err := c.Mark(context.Background(), store.Message{ID: 7, GmailID: "1"}, "bogus"); err == nil { + t.Fatal("Mark(bogus) error = nil, want failure") + } +} diff --git a/internal/server/actions.go b/internal/server/actions.go new file mode 100644 index 0000000..14815b7 --- /dev/null +++ b/internal/server/actions.go @@ -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) diff --git a/internal/server/actions_test.go b/internal/server/actions_test.go new file mode 100644 index 0000000..0465a6c --- /dev/null +++ b/internal/server/actions_test.go @@ -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]) + } + } +} diff --git a/internal/server/auth.go b/internal/server/auth.go index 1de91b5..8ab57c6 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -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") diff --git a/internal/server/inbox.go b/internal/server/inbox.go index 1a20bac..ba866ab 100644 --- a/internal/server/inbox.go +++ b/internal/server/inbox.go @@ -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() +} diff --git a/internal/server/server.go b/internal/server/server.go index 7257c62..f229c7f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) diff --git a/internal/store/messages.go b/internal/store/messages.go index d3c8c03..7eb771f 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -26,6 +26,11 @@ type Message struct { ReceivedAt time.Time IMAPUID int64 IMAPUIDValidity int64 + // Folder is the app folder the message is filed under (Phase 4, FR-6/FR-9): + // inbox, delete, saved, or replied. + Folder string + // Mailbox is the exact IMAP label the message currently lives in on Gmail. + Mailbox string } // MessageListItem is a message plus its latest classification, when present. @@ -38,6 +43,24 @@ type MessageListItem struct { HasClassification bool } +// App folders (Phase 4). A folder is a low-cardinality navigation concept; the +// exact Gmail label is tracked separately on each message. +const ( + FolderInbox = "inbox" + FolderDelete = "delete" + FolderSaved = "saved" + FolderReplied = "replied" +) + +// ValidFolder reports whether folder is an app folder the UI knows how to show. +func ValidFolder(folder string) bool { + switch folder { + case FolderInbox, FolderDelete, FolderSaved, FolderReplied: + return true + } + return false +} + // UpsertMessage inserts a message, or refreshes the mutable fields when the // same Gmail id is seen again. received_at is preserved on update so the // original arrival time is stable. @@ -99,6 +122,7 @@ SELECT COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''), COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at, COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0), + COALESCE(m.folder, 'inbox'), COALESCE(c.category, ''), COALESCE(c.importance, ''), COALESCE(c.action, ''), COALESCE(c.confidence, 0) FROM messages m @@ -125,7 +149,7 @@ LIMIT ?` &item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr, &item.ToAddr, &item.Subject, &item.BodyText, &item.SourceAddress, &dateSent, &received, - &item.IMAPUID, &item.IMAPUIDValidity, + &item.IMAPUID, &item.IMAPUIDValidity, &item.Folder, &item.Category, &item.Importance, &item.Action, &item.Confidence, ); err != nil { return nil, fmt.Errorf("scan message: %w", err) diff --git a/internal/store/schema.sql b/internal/store/schema.sql index adc679c..c5914d2 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -28,11 +28,18 @@ CREATE TABLE IF NOT EXISTS messages ( -- belongs to. UIDs are only meaningful while UIDVALIDITY is unchanged. imap_uid INTEGER, imap_uidvalidity INTEGER, + -- Phase 4 (FR-6/FR-9): the app folder a message is filed under (a low + -- cardinality concept used for navigation) and the exact IMAP label it + -- currently lives in on Gmail (for example "Delete", "Saved", or + -- "Replied-to/hello@where-woof.com"). `folder` defaults to the inbox. + folder TEXT NOT NULL DEFAULT 'inbox', + mailbox TEXT NOT NULL DEFAULT 'INBOX', received_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages (received_at); +CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages (folder, received_at); CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages (thread_id); CREATE INDEX IF NOT EXISTS idx_messages_source ON messages (source_address); diff --git a/internal/store/store.go b/internal/store/store.go index 181432c..7c536dd 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -123,6 +123,27 @@ func (s *Store) applyMigrations(ctx context.Context) error { return fmt.Errorf("record schema version 3: %w", err) } } + if version < 4 { + // Phase 4 (FR-6/FR-9): triage actions file a message under an app folder + // and remember the Gmail label it currently lives in, so a delete can be + // undone by moving it back to the inbox. + for _, col := range []struct{ table, name, typ string }{ + {"messages", "folder", "TEXT NOT NULL DEFAULT 'inbox'"}, + {"messages", "mailbox", "TEXT NOT NULL DEFAULT 'INBOX'"}, + } { + if err := s.ensureColumn(ctx, col.table, col.name, col.typ); err != nil { + return err + } + } + if _, err := s.db.ExecContext(ctx, + `CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages (folder, received_at)`); err != nil { + return fmt.Errorf("index messages.folder: %w", err) + } + if _, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO schema_migrations (version) VALUES (4)`); err != nil { + return fmt.Errorf("record schema version 4: %w", err) + } + } return nil } diff --git a/internal/store/triage.go b/internal/store/triage.go new file mode 100644 index 0000000..62d9959 --- /dev/null +++ b/internal/store/triage.go @@ -0,0 +1,265 @@ +// Folder and bulk-action persistence for Phase 4 (FR-6, FR-9, FR-13). +// +// A message carries two location fields after migration v4: `folder`, the +// low-cardinality app view (inbox, delete, saved, replied), and `mailbox`, the +// exact Gmail IMAP label it currently lives in. The mailbox is what a restore +// needs, because a Gmail message gets a new UID in each label it appears in. +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// UnclassifiedCategory is the triage bucket for messages whose latest +// classification is missing. It is a view, not a stored category. +const UnclassifiedCategory = "Unclassified" + +// MessageFilter narrows the triage list. An empty Folder means every folder; an +// empty Category means every category. Unclassified selects messages with no +// usable classification. +type MessageFilter struct { + Folder string + Category string + Unclassified bool + Limit int +} + +// ListMessagesFiltered returns messages matching the filter, newest first, +// joined to their latest classification. It backs the inbox and folder views. +func (s *Store) ListMessagesFiltered(ctx context.Context, f MessageFilter) ([]MessageListItem, error) { + if f.Limit < 1 { + f.Limit = 200 + } + var ( + where []string + args []any + ) + if f.Folder != "" && f.Folder != "all" { + where = append(where, "m.folder = ?") + args = append(args, f.Folder) + } + switch { + case f.Unclassified: + where = append(where, "(c.id IS NULL OR c.category = '')") + case f.Category != "": + where = append(where, "c.category = ? COLLATE NOCASE") + args = append(args, f.Category) + } + clause := "" + if len(where) > 0 { + clause = "WHERE " + strings.Join(where, " AND ") + } + + q := ` +SELECT + m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''), + COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''), + COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at, + COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0), + COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX'), + COALESCE(c.category, ''), COALESCE(c.importance, ''), + COALESCE(c.action, ''), COALESCE(c.confidence, 0) +FROM messages m +LEFT JOIN classifications c ON c.id = ( + SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1 +) +` + clause + ` +ORDER BY COALESCE(NULLIF(m.date_sent, ''), m.received_at) DESC, m.id DESC +LIMIT ?` + args = append(args, f.Limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list filtered messages: %w", err) + } + defer rows.Close() + + var out []MessageListItem + for rows.Next() { + var ( + item MessageListItem + dateSent string + received string + ) + if err := rows.Scan( + &item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr, + &item.ToAddr, &item.Subject, &item.BodyText, + &item.SourceAddress, &dateSent, &received, + &item.IMAPUID, &item.IMAPUIDValidity, &item.Folder, &item.Mailbox, + &item.Category, &item.Importance, &item.Action, &item.Confidence, + ); err != nil { + return nil, fmt.Errorf("scan filtered message: %w", err) + } + item.DateSent = parseSQLTime(dateSent) + item.ReceivedAt = parseSQLTime(received) + item.HasClassification = item.Category != "" + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list filtered messages: %w", err) + } + return out, nil +} + +// MessagesByIDs loads the messages with the given ids. Order is not significant. +func (s *Store) MessagesByIDs(ctx context.Context, ids []int64) ([]Message, error) { + if len(ids) == 0 { + return nil, nil + } + placeholders := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + q := ` +SELECT m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''), + COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''), + COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at, + COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0), + COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX') +FROM messages m +WHERE m.id IN (` + strings.Join(placeholders, ",") + `)` + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("load messages by id: %w", err) + } + defer rows.Close() + + var out []Message + for rows.Next() { + var ( + m Message + dateSent string + received string + ) + if err := rows.Scan( + &m.ID, &m.GmailID, &m.ThreadID, &m.FromAddr, &m.ToAddr, &m.Subject, + &m.BodyText, &m.SourceAddress, &dateSent, &received, + &m.IMAPUID, &m.IMAPUIDValidity, &m.Folder, &m.Mailbox, + ); err != nil { + return nil, fmt.Errorf("scan message by id: %w", err) + } + m.DateSent = parseSQLTime(dateSent) + m.ReceivedAt = parseSQLTime(received) + out = append(out, m) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("load messages by id: %w", err) + } + return out, nil +} + +// SetMessageLocation records the app folder and Gmail label a message now lives +// in. It is called after a successful move; the database is only updated once +// the mailbox agrees, so a failed move leaves the list truthful. +func (s *Store) SetMessageLocation(ctx context.Context, id int64, folder, mailbox string) error { + const q = `UPDATE messages SET folder = ?, mailbox = ? WHERE id = ?` + if _, err := s.db.ExecContext(ctx, q, folder, mailbox, id); err != nil { + return fmt.Errorf("set message %d location: %w", id, err) + } + return nil +} + +// LatestClassifications returns the newest classification per message id. +func (s *Store) LatestClassifications(ctx context.Context, ids []int64) (map[int64]Classification, error) { + out := map[int64]Classification{} + if len(ids) == 0 { + return out, nil + } + placeholders := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + q := ` +SELECT c.message_id, c.category, COALESCE(c.importance, ''), COALESCE(c.action, ''), + COALESCE(c.confidence, 0), COALESCE(c.model, '') +FROM classifications c +WHERE c.id IN ( + SELECT MAX(id) FROM classifications WHERE message_id IN (` + strings.Join(placeholders, ",") + `) + GROUP BY message_id +)` + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("latest classifications: %w", err) + } + defer rows.Close() + for rows.Next() { + var c Classification + if err := rows.Scan(&c.MessageID, &c.Category, &c.Importance, &c.Action, &c.Confidence, &c.Model); err != nil { + return nil, fmt.Errorf("scan latest classification: %w", err) + } + out[c.MessageID] = c + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("latest classifications: %w", err) + } + return out, nil +} + +// CountByFolder returns the number of messages in each app folder. +func (s *Store) CountByFolder(ctx context.Context) (map[string]int, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT COALESCE(folder, 'inbox'), COUNT(*) FROM messages GROUP BY 1`) + if err != nil { + return nil, fmt.Errorf("count by folder: %w", err) + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var ( + folder string + n int + ) + if err := rows.Scan(&folder, &n); err != nil { + return nil, fmt.Errorf("scan folder count: %w", err) + } + out[folder] = n + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("count by folder: %w", err) + } + return out, nil +} + +// ActionLogEntry is one row of the append-only action log (FR-13). +type ActionLogEntry struct { + ID int64 + MessageID sql.NullInt64 + Action string + Actor string + Detail string + CreatedAt string +} + +// RecentActions returns the newest action-log rows, newest first. +func (s *Store) RecentActions(ctx context.Context, limit int) ([]ActionLogEntry, error) { + if limit < 1 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, ` +SELECT id, message_id, action, COALESCE(actor, ''), COALESCE(detail, ''), created_at +FROM actions_log ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("recent actions: %w", err) + } + defer rows.Close() + var out []ActionLogEntry + for rows.Next() { + var e ActionLogEntry + if err := rows.Scan(&e.ID, &e.MessageID, &e.Action, &e.Actor, &e.Detail, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("scan action log: %w", err) + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("recent actions: %w", err) + } + return out, nil +} diff --git a/internal/store/triage_test.go b/internal/store/triage_test.go new file mode 100644 index 0000000..7162660 --- /dev/null +++ b/internal/store/triage_test.go @@ -0,0 +1,194 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func testStore(t *testing.T) (*Store, context.Context) { + t.Helper() + ctx := context.Background() + s, err := Open(ctx, filepath.Join(t.TempDir(), "email_agent.db")) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + return s, ctx +} + +func seedMessage(t *testing.T, s *Store, ctx context.Context, gmailID, subject, source string) int64 { + t.Helper() + id, err := s.UpsertMessage(ctx, &Message{ + GmailID: gmailID, + FromAddr: "sender@example.test", + ToAddr: source, + Subject: subject, + SourceAddress: source, + DateSent: time.Now().UTC(), + }) + if err != nil { + t.Fatalf("seed message: %v", err) + } + return id +} + +func TestMigrationAddsFolderColumns(t *testing.T) { + s, ctx := testStore(t) + + v, err := s.SchemaVersion(ctx) + if err != nil { + t.Fatalf("SchemaVersion() error = %v", err) + } + if v < 4 { + t.Errorf("SchemaVersion() = %d, want >= 4", v) + } + for _, col := range []string{"folder", "mailbox"} { + var found bool + rows, err := s.DB().QueryContext(ctx, `SELECT name FROM pragma_table_info('messages')`) + if err != nil { + t.Fatalf("pragma_table_info: %v", err) + } + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan: %v", err) + } + if name == col { + found = true + } + } + _ = rows.Close() + if !found { + t.Errorf("column %q missing after migration", col) + } + } +} + +func TestUpsertDefaultsToInbox(t *testing.T) { + s, ctx := testStore(t) + seedMessage(t, s, ctx, "1", "hello", "a@x.test") + + list, err := s.ListMessagesFiltered(ctx, MessageFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListMessagesFiltered() error = %v", err) + } + if len(list) != 1 { + t.Fatalf("len = %d, want 1", len(list)) + } + if list[0].Folder != FolderInbox { + t.Errorf("folder = %q, want inbox", list[0].Folder) + } +} + +func TestSetLocationAndFolderFilter(t *testing.T) { + s, ctx := testStore(t) + idA := seedMessage(t, s, ctx, "1", "keep me", "a@x.test") + idB := seedMessage(t, s, ctx, "2", "delete me", "b@x.test") + + if err := s.SetMessageLocation(ctx, idB, FolderDelete, "Delete"); err != nil { + t.Fatalf("SetMessageLocation() error = %v", err) + } + + del, err := s.ListMessagesFiltered(ctx, MessageFilter{Folder: FolderDelete, Limit: 10}) + if err != nil { + t.Fatalf("delete filter: %v", err) + } + if len(del) != 1 || del[0].ID != idB { + t.Fatalf("delete filter = %+v, want message %d", del, idB) + } + if del[0].Folder != FolderDelete { + t.Errorf("folder = %q, want delete", del[0].Folder) + } + + inbox, err := s.ListMessagesFiltered(ctx, MessageFilter{Folder: FolderInbox, Limit: 10}) + if err != nil { + t.Fatalf("inbox filter: %v", err) + } + if len(inbox) != 1 || inbox[0].ID != idA { + t.Fatalf("inbox filter = %+v, want message %d", inbox, idA) + } + + all, err := s.ListMessagesFiltered(ctx, MessageFilter{Folder: "all", Limit: 10}) + if err != nil { + t.Fatalf("all filter: %v", err) + } + if len(all) != 2 { + t.Fatalf("all filter len = %d, want 2", len(all)) + } +} + +func TestListMessagesFilteredByCategory(t *testing.T) { + s, ctx := testStore(t) + idA := seedMessage(t, s, ctx, "1", "spammy", "a@x.test") + idB := seedMessage(t, s, ctx, "2", "normal", "b@x.test") + if _, err := s.SaveClassification(ctx, Classification{MessageID: idA, Category: "Spam", Importance: "high", Action: "delete", Confidence: 0.9}); err != nil { + t.Fatalf("SaveClassification() error = %v", err) + } + + spam, err := s.ListMessagesFiltered(ctx, MessageFilter{Category: "spam", Limit: 10}) + if err != nil { + t.Fatalf("category filter: %v", err) + } + if len(spam) != 1 || spam[0].ID != idA { + t.Fatalf("spam filter = %+v, want message %d", spam, idA) + } + + unclassified, err := s.ListMessagesFiltered(ctx, MessageFilter{Unclassified: true, Limit: 10}) + if err != nil { + t.Fatalf("unclassified filter: %v", err) + } + if len(unclassified) != 1 || unclassified[0].ID != idB { + t.Fatalf("unclassified filter = %+v, want message %d", unclassified, idB) + } +} + +func TestLatestClassifications(t *testing.T) { + s, ctx := testStore(t) + id := seedMessage(t, s, ctx, "1", "spammy", "a@x.test") + if _, err := s.SaveClassification(ctx, Classification{MessageID: id, Category: "Info", Action: "keep"}); err != nil { + t.Fatalf("first classification: %v", err) + } + if _, err := s.SaveClassification(ctx, Classification{MessageID: id, Category: "Spam", Action: "delete"}); err != nil { + t.Fatalf("second classification: %v", err) + } + + got, err := s.LatestClassifications(ctx, []int64{id}) + if err != nil { + t.Fatalf("LatestClassifications() error = %v", err) + } + if got[id].Category != "Spam" { + t.Errorf("category = %q, want the newest (Spam)", got[id].Category) + } +} + +func TestCountByFolderAndMessagesByIDs(t *testing.T) { + s, ctx := testStore(t) + idA := seedMessage(t, s, ctx, "1", "a", "a@x.test") + idB := seedMessage(t, s, ctx, "2", "b", "b@x.test") + if err := s.SetMessageLocation(ctx, idB, FolderSaved, "Saved"); err != nil { + t.Fatalf("SetMessageLocation() error = %v", err) + } + + counts, err := s.CountByFolder(ctx) + if err != nil { + t.Fatalf("CountByFolder() error = %v", err) + } + if counts[FolderInbox] != 1 || counts[FolderSaved] != 1 { + t.Errorf("counts = %v, want inbox:1 saved:1", counts) + } + + msgs, err := s.MessagesByIDs(ctx, []int64{idA, idB}) + if err != nil { + t.Fatalf("MessagesByIDs() error = %v", err) + } + if len(msgs) != 2 { + t.Fatalf("MessagesByIDs() len = %d, want 2", len(msgs)) + } + for _, m := range msgs { + if m.ID == idB && (m.Folder != FolderSaved || m.Mailbox != "Saved") { + t.Errorf("saved message location = (%q, %q)", m.Folder, m.Mailbox) + } + } +} diff --git a/web/static/app.css b/web/static/app.css index d8ee88e..8f0da17 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -375,3 +375,40 @@ a:hover { text-decoration: underline; } .category-rename { grid-template-columns: 1fr; } .category-remove .btn { width: 100%; } } + +/* Phase 4: bulk triage toolbar (FR-6) and recoverable-delete views (FR-9) */ +.bulk-toolbar { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--hairline); + background: var(--surface); +} +.bulk-check { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-height: 44px; + font: var(--type-body-sm); + color: var(--ink-secondary); +} +.bulk-check input, +.msg-select { + width: 18px; + height: 18px; + flex: 0 0 auto; + accent-color: var(--primary); +} +.selected-count { font: var(--type-caption); color: var(--ink-faint); } +.bulk-actions { display: flex; align-items: center; gap: var(--space-xs); flex-wrap: wrap; } +.view-label { + margin-left: auto; + font: var(--type-eyebrow); + text-transform: uppercase; + letter-spacing: .125px; + color: var(--ink-faint); +} +.btn-small { min-height: 32px; padding: 0 var(--space-sm); font: var(--type-caption); } +.scan-note-error { color: var(--sticker-red); } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index d74a712..2b7bd71 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -49,13 +49,15 @@ Edit