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

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

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

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

View File

@@ -0,0 +1,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)
}
}
}