Phase 4: triage actions (delete, move, mark; bulk select) (AUD-70)

Bulk select in the triage list, with delete/move/mark and a recoverable
delete (FR-6, FR-9).

- migration v4: messages.folder + messages.mailbox; folder/category views
- internal/mailops: app folder -> Gmail label moves over IMAP (IPv4), locate
  by X-GM-MSGID, create labels lazily, move/save/reply/restore, flag marks
- mailfetch: CREATE, UID MOVE (COPY+EXPUNGE fallback), STORE flags, X-GM-MSGID search
- server: POST /actions/bulk with CSRF, UI delete confirmation, second
  confirmation for messages classified Spam (never auto-deleted), per-message
  and summary action-log rows (FR-13)
- inbox: select-all toolbar, checkboxes, folder nav, Restore on non-inbox rows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 21:44:52 +11:00
parent 735934dab2
commit 8c0416750e
18 changed files with 2023 additions and 71 deletions

View File

@@ -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/<address the mail arrived at>` |
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_<action>` 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.

View File

@@ -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
}

View File

@@ -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")
}
}

246
internal/mailops/mailops.go Normal file
View File

@@ -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/<address> (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:<validity>:<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)
}
}

View File

@@ -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(" <Sam+news@Example.com> "); 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")
}
}

255
internal/server/actions.go Normal file
View File

@@ -0,0 +1,255 @@
// Bulk triage actions for Phase 4 (FR-6, FR-9, FR-13).
//
// The UI selects rows in a group, then asks for delete, move, or mark. Two
// guard rails matter here:
//
// - Deletes are recoverable: they move the message to the Delete label, never
// a hard delete. A restore moves it back to INBOX.
// - Spam is recommended, never auto-deleted. A delete that includes a message
// classified Spam needs a second, explicit confirmation.
//
// Every action is appended to the action log.
package server
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// MailActions is the mailbox side of a triage action. The live implementation is
// *mailops.Client; tests substitute a fake.
type MailActions interface {
// Move files msg under destFolder and returns the Gmail label it now lives
// in.
Move(ctx context.Context, msg store.Message, destFolder string) (string, error)
// Mark applies read/star flags (read, unread, star, unstar).
Mark(ctx context.Context, msg store.Message, mark string) error
}
// handleBulkAction performs one action over the selected message ids.
func (s *Server) handleBulkAction(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.renderInbox(w, r, "", "Could not read the action form.")
return
}
action := strings.ToLower(strings.TrimSpace(r.FormValue("action")))
ids := parseIDList(r.Form["ids"])
if len(ids) == 0 {
s.renderInbox(w, r, "", "Select at least one message first.")
return
}
ctx := r.Context()
msgs, err := s.store.MessagesByIDs(ctx, ids)
if err != nil {
s.logger.Error("load selected messages", "error", err)
s.renderInbox(w, r, "", "Could not load the selected messages.")
return
}
if len(msgs) == 0 {
s.renderInbox(w, r, "", "Those messages no longer exist.")
return
}
// Bulk delete needs a confirmation (FR-6) and every spam action needs its
// own confirmation (§10). The UI sets both after the user confirms; a
// request that arrives without them is refused here.
if action == "delete" {
if r.FormValue("confirm") != "yes" {
s.renderInbox(w, r, "", "Deleting needs confirmation. Select Delete again and confirm.")
return
}
spam, err := s.spamCount(ctx, msgs)
if err != nil {
s.logger.Error("check spam before delete", "error", err)
}
if spam > 0 && r.FormValue("spam_confirm") != "yes" {
s.renderInbox(w, r, "", fmt.Sprintf(
"%d selected message(s) are classified Spam. Spam is never deleted automatically; confirm to move them to the Delete folder.",
spam))
return
}
}
actor := sessionFrom(r).User
if actor == "" {
actor = "Sam"
}
var ok, failed int
for _, m := range msgs {
var err error
switch action {
case "delete", "save", "reply", "restore":
folder := folderForAction(action)
var label string
label, err = s.actions.Move(ctx, m, folder)
if err == nil {
if serr := s.store.SetMessageLocation(ctx, m.ID, folder, label); serr != nil {
s.logger.Error("record message location", "message_id", m.ID, "error", serr)
}
s.logMessageAction(ctx, m.ID, "move", actor,
fmt.Sprintf("to=%s label=%q from=%q", folder, label, m.Mailbox))
}
case "mark_read", "mark_unread", "star", "unstar":
mark := strings.TrimPrefix(action, "mark_")
err = s.actions.Mark(ctx, m, mark)
if err == nil {
s.logMessageAction(ctx, m.ID, "mark", actor, "mark="+mark)
}
default:
s.renderInbox(w, r, "", fmt.Sprintf("Unknown action %q.", action))
return
}
if err != nil {
failed++
s.logger.Warn("triage action failed", "action", action, "message_id", m.ID, "error", err)
continue
}
ok++
}
s.logAction(ctx, "bulk_"+action, actor,
fmt.Sprintf("requested=%d ok=%d failed=%d ids=%s", len(msgs), ok, failed, joinIDs(ids)))
note := actionSummary(action, ok, failed)
if failed > 0 {
note += " " + s.failureHint()
}
s.renderInbox(w, r, note, "")
}
// spamCount reports how many of the selected messages are classified Spam.
func (s *Server) spamCount(ctx context.Context, msgs []store.Message) (int, error) {
ids := make([]int64, len(msgs))
for i, m := range msgs {
ids[i] = m.ID
}
classes, err := s.store.LatestClassifications(ctx, ids)
if err != nil {
return 0, err
}
n := 0
for _, m := range msgs {
if strings.EqualFold(strings.TrimSpace(classes[m.ID].Category), "Spam") {
n++
}
}
return n, nil
}
// folderForAction maps a bulk action to its destination app folder.
func folderForAction(action string) string {
switch action {
case "delete":
return store.FolderDelete
case "save":
return store.FolderSaved
case "reply":
return store.FolderReplied
default:
return store.FolderInbox
}
}
// actionSummary is the human note rendered above the refreshed list.
func actionSummary(action string, ok, failed int) string {
verb := map[string]string{
"delete": "Moved %d message(s) to the Delete folder (recoverable).",
"save": "Saved %d message(s).",
"reply": "Marked %d message(s) as replied-to.",
"restore": "Restored %d message(s) to the inbox.",
"mark_read": "Marked %d message(s) read.",
"mark_unread": "Marked %d message(s) unread.",
"star": "Starred %d message(s).",
"unstar": "Unstarred %d message(s).",
}[action]
if verb == "" {
verb = "Updated %d message(s)."
}
msg := fmt.Sprintf(verb, ok)
if failed > 0 {
msg += fmt.Sprintf(" %d failed.", failed)
}
return msg
}
// failureHint explains the common reason a mailbox action fails.
func (s *Server) failureHint() string {
return "Check GMAIL_USER, GMAIL_APP_PASSWORD, and the container log."
}
// logMessageAction records a per-message action; a logging failure must not fail
// the request.
func (s *Server) logMessageAction(ctx context.Context, messageID int64, action, actor, detail string) {
if err := s.store.LogMessageAction(ctx, messageID, action, actor, detail); err != nil {
s.logger.Warn("log message action", "message_id", messageID, "action", action, "error", err)
}
}
// logAction records a summary action; a logging failure must not fail the
// request.
func (s *Server) logAction(ctx context.Context, action, actor, detail string) {
if err := s.store.LogAction(ctx, action, actor, detail); err != nil {
s.logger.Warn("log action", "action", action, "error", err)
}
}
// parseIDList parses form values into unique, positive message ids.
func parseIDList(values []string) []int64 {
seen := map[int64]struct{}{}
var out []int64
for _, v := range values {
for _, part := range strings.Split(v, ",") {
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || n <= 0 {
continue
}
if _, dup := seen[n]; dup {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
}
return out
}
// joinIDs renders ids for an action-log detail line.
func joinIDs(ids []int64) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatInt(id, 10)
}
return strings.Join(parts, ",")
}
// folderLabel is the display name for an app folder.
func folderLabel(folder string) string {
switch folder {
case store.FolderDelete:
return "Delete"
case store.FolderSaved:
return "Saved"
case store.FolderReplied:
return "Replied-to"
case "all":
return "All mail"
default:
return "Inbox"
}
}
// Ensure the live client satisfies the interface at compile time.
var _ MailActions = (*mailops.Client)(nil)

View File

@@ -0,0 +1,303 @@
package server
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
type moveCall struct {
id int64
dest string
}
type markCall struct {
id int64
mark string
}
type fakeActions struct {
mu sync.Mutex
moves []moveCall
marks []markCall
err error
}
func (f *fakeActions) Move(_ context.Context, msg store.Message, dest string) (string, error) {
if f.err != nil {
return "", f.err
}
f.mu.Lock()
defer f.mu.Unlock()
f.moves = append(f.moves, moveCall{id: msg.ID, dest: dest})
return mailops.MailboxForFolder(dest, msg.SourceAddress), nil
}
func (f *fakeActions) Mark(_ context.Context, msg store.Message, mark string) error {
if f.err != nil {
return f.err
}
f.mu.Lock()
defer f.mu.Unlock()
f.marks = append(f.marks, markCall{id: msg.ID, mark: mark})
return nil
}
func (f *fakeActions) moveCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.moves)
}
func (f *fakeActions) markCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.marks)
}
func newActionsServer(t *testing.T) (*Server, *fakeActions) {
t.Helper()
srv := newTestServer(t)
fa := &fakeActions{}
srv.actions = fa
return srv, fa
}
func seedClassified(t *testing.T, srv *Server, gmailID, subject, category string) int64 {
t.Helper()
ctx := context.Background()
id, err := srv.store.UpsertMessage(ctx, &store.Message{
GmailID: gmailID,
FromAddr: "sender@example.test",
ToAddr: "hello@where-woof.com",
Subject: subject,
SourceAddress: "hello@where-woof.com",
})
if err != nil {
t.Fatalf("seed message: %v", err)
}
if category != "" {
if _, err := srv.store.SaveClassification(ctx, store.Classification{
MessageID: id, Category: category, Importance: "high", Action: "delete", Confidence: 0.9,
}); err != nil {
t.Fatalf("seed classification: %v", err)
}
}
return id
}
func bulkForm(csrf, action string, ids []int64, extra url.Values) url.Values {
form := url.Values{"csrf": {csrf}, "action": {action}}
for _, id := range ids {
form.Add("ids", strconv.FormatInt(id, 10))
}
for k, v := range extra {
form[k] = v
}
return form
}
func TestBulkDeleteRequiresCSRF(t *testing.T) {
srv, fa := newActionsServer(t)
session, _, _ := login(t, srv)
seedClassified(t, srv, "1", "spammy", "Spam")
// No CSRF cookie and no csrf form value.
form := url.Values{"action": {"delete"}, "ids": {"1"}, "confirm": {"yes"}, "spam_confirm": {"yes"}}
rec := postForm(t, srv, "/actions/bulk", session, nil, form)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
if fa.moveCount() != 0 {
t.Errorf("moves = %d, want 0 on a rejected request", fa.moveCount())
}
}
func TestBulkDeleteRequiresConfirmation(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedClassified(t, srv, "1", "newsletter", "Newsletter")
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if fa.moveCount() != 0 {
t.Fatalf("moves = %d, want 0 without confirmation", fa.moveCount())
}
if !strings.Contains(rec.Body.String(), "needs confirmation") {
t.Errorf("body did not explain the confirmation requirement")
}
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
if len(msgs) != 1 || msgs[0].Folder != store.FolderInbox {
t.Errorf("message moved despite missing confirmation: %+v", msgs)
}
}
func TestBulkDeleteSpamRequiresSpamConfirmation(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedClassified(t, srv, "1", "lottery winner", "Spam")
extra := url.Values{"confirm": {"yes"}} // no spam_confirm
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, extra))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if fa.moveCount() != 0 {
t.Fatalf("moves = %d, want 0 without the spam confirmation", fa.moveCount())
}
if !strings.Contains(rec.Body.String(), "classified Spam") {
t.Errorf("body did not explain the spam confirmation")
}
}
func TestBulkDeleteThenRestoreIsRecoverable(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
spamID := seedClassified(t, srv, "1", "lottery winner", "Spam")
infoID := seedClassified(t, srv, "2", "receipt", "Info")
extra := url.Values{"confirm": {"yes"}, "spam_confirm": {"yes"}}
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{spamID, infoID}, extra))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if fa.moveCount() != 2 {
t.Fatalf("moves = %d, want 2", fa.moveCount())
}
if !strings.Contains(rec.Body.String(), "Delete folder") {
t.Errorf("note did not mention the Delete folder")
}
ctx := context.Background()
deleted, err := srv.store.ListMessagesFiltered(ctx, store.MessageFilter{Folder: store.FolderDelete, Limit: 10})
if err != nil {
t.Fatalf("list delete folder: %v", err)
}
if len(deleted) != 2 {
t.Fatalf("delete folder has %d messages, want 2", len(deleted))
}
for _, m := range deleted {
if m.Mailbox != "Delete" {
t.Errorf("message %d mailbox = %q, want Delete", m.ID, m.Mailbox)
}
}
// The Delete folder view shows the recoverable rows with a Restore button.
view := getWithSession(t, srv, "/partials/inbox?folder=delete", session)
if view.Code != http.StatusOK {
t.Fatalf("delete folder view status = %d", view.Code)
}
if !strings.Contains(view.Body.String(), "Restore") {
t.Errorf("delete folder view has no Restore button")
}
// Restore one message.
rec = postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "restore", []int64{spamID}, nil))
if rec.Code != http.StatusOK {
t.Fatalf("restore status = %d, want 200", rec.Code)
}
restored, err := srv.store.MessagesByIDs(ctx, []int64{spamID})
if err != nil {
t.Fatalf("load restored message: %v", err)
}
if len(restored) != 1 || restored[0].Folder != store.FolderInbox || restored[0].Mailbox != "INBOX" {
t.Errorf("restored message = %+v, want inbox/INBOX", restored)
}
// FR-13: every action has a log row.
entries, err := srv.store.RecentActions(ctx, 50)
if err != nil {
t.Fatalf("RecentActions() error = %v", err)
}
var moves, bulk int
for _, e := range entries {
if e.Action == "move" {
moves++
}
if strings.HasPrefix(e.Action, "bulk_") {
bulk++
}
}
if moves != 3 {
t.Errorf("move log rows = %d, want 3 (2 deletes + 1 restore)", moves)
}
if bulk != 2 {
t.Errorf("bulk summary rows = %d, want 2", bulk)
}
}
func TestInboxPartialHasSelectCheckboxes(t *testing.T) {
srv, _ := newActionsServer(t)
session, _, _ := login(t, srv)
seedClassified(t, srv, "1", "one", "Info")
seedClassified(t, srv, "2", "two", "Info")
rec := getWithSession(t, srv, "/partials/inbox", session)
body := rec.Body.String()
if !strings.Contains(body, `id="select-all"`) {
t.Errorf("partial missing select-all checkbox")
}
if strings.Count(body, `name="ids"`) != 2 {
t.Errorf("checkbox count = %d, want 2", strings.Count(body, `name="ids"`))
}
}
func TestBulkMoveAndMark(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
id := seedClassified(t, srv, "1", "receipt", "Info")
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "save", []int64{id}, nil)); rec.Code != http.StatusOK {
t.Fatalf("save status = %d", rec.Code)
}
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "mark_read", []int64{id}, nil)); rec.Code != http.StatusOK {
t.Fatalf("mark_read status = %d", rec.Code)
}
if fa.moveCount() != 1 || fa.markCount() != 1 {
t.Fatalf("calls: moves=%d marks=%d, want 1 and 1", fa.moveCount(), fa.markCount())
}
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
if len(msgs) != 1 || msgs[0].Folder != store.FolderSaved {
t.Errorf("message folder = %+v, want saved", msgs)
}
}
func TestBulkNoSelection(t *testing.T) {
srv, fa := newActionsServer(t)
session, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", nil, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Select at least one") {
t.Errorf("body did not explain the empty selection")
}
if fa.moveCount() != 0 {
t.Errorf("moves = %d, want 0", fa.moveCount())
}
}
func TestParseIDList(t *testing.T) {
got := parseIDList([]string{"1,2", "2", "x", "-3", "4"})
want := []int64{1, 2, 4}
if len(got) != len(want) {
t.Fatalf("parseIDList() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("parseIDList()[%d] = %d, want %d", i, got[i], want[i])
}
}
}

View File

@@ -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")

View File

@@ -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()
}

View File

@@ -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))

View File

@@ -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)

View File

@@ -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);

View File

@@ -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
}

265
internal/store/triage.go Normal file
View File

@@ -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
}

View File

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

View File

@@ -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); }

View File

@@ -49,13 +49,15 @@
<a class="nav-edit" href="/categories">Edit</a>
</div>
<div class="nav-group-items">
<a class="nav-item nav-item-active" href="#">
<a class="nav-item nav-item-active" href="#"
hx-get="/partials/inbox?folder=all" hx-target="#inbox-list" hx-swap="innerHTML">
<span class="dot" style="background: var(--primary)"></span>
<span>All mail</span>
<span class="count">{{.Total}}</span>
</a>
{{range .Categories}}
<a class="nav-item" href="#">
<a class="nav-item" href="#"
hx-get="/partials/inbox?{{.Param}}" hx-target="#inbox-list" hx-swap="innerHTML">
<span class="dot" style="background: {{.Color}}; {{if not .Count}}opacity:.35;{{end}}"></span>
<span>{{.Name}}</span>
<span class="count">{{.Count}}</span>
@@ -67,10 +69,11 @@
<div class="nav-group-title">Folders</div>
<div class="nav-group-items">
{{range .Folders}}
<a class="nav-item" href="#">
<a class="nav-item" href="#"
hx-get="/partials/inbox?{{.Param}}" hx-target="#inbox-list" hx-swap="innerHTML">
<span class="dot" style="background: {{.Color}}"></span>
<span>{{.Name}}</span>
<span class="count">0</span>
<span class="count">{{.Count}}</span>
</a>
{{end}}
</div>
@@ -119,5 +122,31 @@
</main>
<script src="/static/htmx.min.js" defer></script>
<script>
// Bulk-select state for the Phase 4 triage toolbar. The message list is
// swapped by HTMX, so the handlers live on the document (delegation).
function eaSelectedIds() {
return document.querySelectorAll('#bulk-items input[name="ids"]:checked');
}
function eaUpdateSelected() {
var el = document.getElementById('selected-count');
if (el) el.textContent = eaSelectedIds().length + ' selected';
}
document.addEventListener('change', function (e) {
if (!e.target) return;
if (e.target.id === 'select-all') {
var on = e.target.checked;
document.querySelectorAll('#bulk-items input[name="ids"]').forEach(function (cb) { cb.checked = on; });
eaUpdateSelected();
} else if (e.target.name === 'ids') {
eaUpdateSelected();
}
});
document.body.addEventListener('htmx:afterSwap', function () {
var sa = document.getElementById('select-all');
if (sa) sa.checked = false;
eaUpdateSelected();
});
</script>
</body>
</html>

View File

@@ -1,34 +1,91 @@
<div class="scan-note-slot">
{{if .Note}}<div class="scan-note" role="status">{{.Note}}</div>{{end}}
{{if .Error}}<div class="scan-note scan-note-error" role="alert">{{.Error}}</div>{{end}}
</div>
{{if .Groups}}
{{range .Groups}}
<section class="msg-group" aria-label="{{.Name}}">
<div class="msg-group-head">
<span class="msg-group-name">{{.Name}}</span>
<span class="count">{{len .Messages}}</span>
</div>
{{range .Messages}}
<div class="msg-row">
<div class="msg-main">
<div class="msg-from">{{.From}}</div>
<div class="msg-subject">{{.Subject}}</div>
{{if .SourceAddr}}<div class="msg-source">arrived at <span class="source-addr">{{.SourceAddr}}</span></div>{{end}}
</div>
<div class="msg-meta">
<span class="pill action-pill">{{.Action}}</span>
{{if .Classified}}<span class="msg-confidence" title="Classifier confidence">{{.Confidence}}%</span>{{end}}
{{if .When}}<span class="msg-when">{{.When}}</span>{{end}}
</div>
</div>
{{end}}
</section>
{{end}}
{{else}}
<div class="empty-state">
<div class="empty-title">No messages yet</div>
<p class="empty-copy">Use <strong>Check mail</strong> to fetch from Gmail, then <strong>Classify</strong> to let JEV sort each message.</p>
<span class="pill">No stored messages</span>
<div class="bulk-toolbar" aria-label="Bulk actions">
<label class="bulk-check">
<input type="checkbox" id="select-all">
<span>Select all</span>
</label>
<span class="selected-count" id="selected-count">0 selected</span>
<div class="bulk-actions">
<button type="button" class="btn btn-utility btn-danger"
hx-post="/actions/bulk" hx-include="#bulk-items" hx-target="#inbox-list" hx-swap="innerHTML"
hx-vals='{"action":"delete","confirm":"yes","spam_confirm":"yes"}'
hx-confirm="Move the selected messages to the Delete folder? They can be restored from there. This applies to every selected row, including anything classified Spam.">
Delete
</button>
<button type="button" class="btn btn-utility"
hx-post="/actions/bulk" hx-include="#bulk-items" hx-target="#inbox-list" hx-swap="innerHTML"
hx-vals='{"action":"save"}'>
Save
</button>
<button type="button" class="btn btn-utility"
hx-post="/actions/bulk" hx-include="#bulk-items" hx-target="#inbox-list" hx-swap="innerHTML"
hx-vals='{"action":"reply"}'>
Mark replied
</button>
<button type="button" class="btn btn-utility"
hx-post="/actions/bulk" hx-include="#bulk-items" hx-target="#inbox-list" hx-swap="innerHTML"
hx-vals='{"action":"mark_read"}'>
Mark read
</button>
<button type="button" class="btn btn-utility"
hx-post="/actions/bulk" hx-include="#bulk-items" hx-target="#inbox-list" hx-swap="innerHTML"
hx-vals='{"action":"mark_unread"}'>
Mark unread
</button>
<button type="button" class="btn btn-utility"
hx-post="/actions/bulk" hx-include="#bulk-items" hx-target="#inbox-list" hx-swap="innerHTML"
hx-vals='{"action":"star"}'>
Star
</button>
</div>
<span class="view-label">{{.FolderLabel}}</span>
</div>
<div id="bulk-items">
<input type="hidden" id="bulk-csrf" name="csrf" value="{{.CSRFToken}}">
<input type="hidden" name="folder" value="{{.Folder}}">
<input type="hidden" name="category" value="{{.Category}}">
{{if .Groups}}
{{range .Groups}}
<section class="msg-group" aria-label="{{.Name}}">
<div class="msg-group-head">
<span class="msg-group-name">{{.Name}}</span>
<span class="count">{{len .Messages}}</span>
</div>
{{range .Messages}}
<div class="msg-row">
<input class="msg-select" type="checkbox" name="ids" value="{{.ID}}" aria-label="Select message from {{.From}}">
<div class="msg-main">
<div class="msg-from">{{.From}}</div>
<div class="msg-subject">{{.Subject}}</div>
{{if .SourceAddr}}<div class="msg-source">arrived at <span class="source-addr">{{.SourceAddr}}</span></div>{{end}}
</div>
<div class="msg-meta">
<span class="pill action-pill">{{.Action}}</span>
{{if .Classified}}<span class="msg-confidence" title="Classifier confidence">{{.Confidence}}%</span>{{end}}
{{if .When}}<span class="msg-when">{{.When}}</span>{{end}}
{{if .Restorable}}
<button type="button" class="btn btn-utility btn-small"
hx-post="/actions/bulk" hx-include="#bulk-csrf" hx-target="#inbox-list" hx-swap="innerHTML"
hx-vals='{"action":"restore","confirm":"yes","ids":"{{.ID}}"}'>
Restore
</button>
{{end}}
</div>
</div>
{{end}}
</section>
{{end}}
{{else}}
<div class="empty-state">
<div class="empty-title">Nothing in {{.FolderLabel}}</div>
<p class="empty-copy">Use <strong>Check mail</strong> to fetch from Gmail, then <strong>Classify</strong> to let JEV sort each message.</p>
<span class="pill">No messages</span>
</div>
{{end}}
</div>
{{end}}