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