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:
47
README.md
47
README.md
@@ -9,17 +9,16 @@ pre-planning material and is not authoritative.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Phase 3 — Classify.** The Go skeleton, session login, and HTMX dashboard
|
**Phase 4 — Triage actions.** The Go skeleton, session login, and HTMX
|
||||||
(Phase 1), real mail over **IMAP with an app password, forced to IPv4**
|
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
|
(Phase 2), **JEV classification** (Phase 3), and now **bulk triage actions**
|
||||||
category, an importance, and a suggested action, grouped in the triage list with
|
(Phase 4). The inbox has a select-all toolbar for delete, move, and mark (FR-6),
|
||||||
the recommended action and confidence per row. The category list is editable in
|
and a folder view for the recoverable Delete/Saved/Replied-to Gmail labels (FR-9).
|
||||||
the UI (add, rename, remove) and JEV uses the current list; a plain-text rules
|
A delete moves the message to a Gmail label (never a hard delete) and a row in
|
||||||
table can override the classifier (FR-4). The FR-2 source address is resolved
|
the Delete folder can be **restored** to the inbox. Bulk delete needs a UI
|
||||||
from `X-Pm-Original-To`, with `To`, the PurelyMail `Received … for` clause, and
|
confirmation and a message classified Spam needs a second confirmation — spam is
|
||||||
`Return-Path` as fallbacks; `Delivered-To` is never used. See
|
never auto-deleted (§10). Every action is appended to `actions_log` (FR-13). See
|
||||||
[`docs/phase3-classify.md`](docs/phase3-classify.md) for the confirmed JEV shape
|
[`docs/phase4-triage.md`](docs/phase4-triage.md) for the phase evidence.
|
||||||
and the phase evidence. Triage actions come in Phase 4.
|
|
||||||
|
|
||||||
## Layout
|
## 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/classify/ JEV (TypeSafe System One) client, rule engine, orchestration
|
||||||
internal/config/ environment-driven configuration
|
internal/config/ environment-driven configuration
|
||||||
internal/mailfetch/ stdlib-only IMAP client, MIME parsing, FR-2 source address
|
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/session/ server-side session store + signed cookie values
|
||||||
internal/store/ SQLite open + schema (internal/store/schema.sql)
|
internal/store/ SQLite open + schema (internal/store/schema.sql)
|
||||||
internal/server/ HTTP routes, auth middleware, HTMX handlers
|
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;
|
always sees the current list. Renaming cascades to existing classifications;
|
||||||
removing a category reassigns its messages to the first remaining category.
|
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
|
## Run locally
|
||||||
|
|
||||||
```sh
|
```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/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/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/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) |
|
| `GET` | `/categories` | session | Category editor (FR-14) |
|
||||||
| `POST` | `/categories/add` | session + CSRF | Adds a category |
|
| `POST` | `/categories/add` | session + CSRF | Adds a category |
|
||||||
| `POST` | `/categories/rename` | session + CSRF | Renames a category and updates existing classifications |
|
| `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
|
`summaries`, plus `categories`, `schema_migrations`. Schema init is idempotent
|
||||||
and runs on every start. Migration v2 adds `imap_uid` and `imap_uidvalidity` to
|
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
|
`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.
|
||||||
|
|||||||
114
internal/mailfetch/imap_write.go
Normal file
114
internal/mailfetch/imap_write.go
Normal 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
|
||||||
|
}
|
||||||
96
internal/mailfetch/imap_write_test.go
Normal file
96
internal/mailfetch/imap_write_test.go
Normal 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
246
internal/mailops/mailops.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
203
internal/mailops/mailops_test.go
Normal file
203
internal/mailops/mailops_test.go
Normal 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
255
internal/server/actions.go
Normal 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)
|
||||||
303
internal/server/actions_test.go
Normal file
303
internal/server/actions_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,6 +215,9 @@ type navItem struct {
|
|||||||
Name string
|
Name string
|
||||||
Color string
|
Color string
|
||||||
Count int
|
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 {
|
type dashboardData struct {
|
||||||
@@ -250,7 +253,7 @@ func categoryCounts(items []store.MessageListItem, categories []store.Category)
|
|||||||
return counts
|
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) {
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
sess := sessionFrom(r)
|
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)
|
s.logger.Error("load dashboard categories", "error", err)
|
||||||
}
|
}
|
||||||
counts := categoryCounts(items, categories)
|
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)
|
catNav := make([]navItem, 0, len(categories)+1)
|
||||||
for _, c := range categories {
|
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{
|
catNav = append(catNav, navItem{
|
||||||
Name: unclassifiedCategory,
|
Name: unclassifiedCategory,
|
||||||
Color: "var(--ink-faint)",
|
Color: "var(--ink-faint)",
|
||||||
Count: counts[unclassifiedCategory],
|
Count: counts[unclassifiedCategory],
|
||||||
|
Param: "category=" + url.QueryEscape(unclassifiedCategory),
|
||||||
})
|
})
|
||||||
|
|
||||||
data := dashboardData{
|
data := dashboardData{
|
||||||
@@ -285,9 +299,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
Total: len(items),
|
Total: len(items),
|
||||||
Categories: catNav,
|
Categories: catNav,
|
||||||
Folders: []navItem{
|
Folders: []navItem{
|
||||||
{Name: "Replied-to", Color: "var(--sticker-green)"},
|
{Name: "Replied-to", Color: "var(--sticker-green)", Count: folderCounts[store.FolderReplied], Param: "folder=" + store.FolderReplied},
|
||||||
{Name: "Delete", Color: "var(--sticker-red)"},
|
{Name: "Delete", Color: "var(--sticker-red)", Count: folderCounts[store.FolderDelete], Param: "folder=" + store.FolderDelete},
|
||||||
{Name: "Saved", Color: "var(--sticker-teal)"},
|
{Name: "Saved", Color: "var(--sticker-teal)", Count: folderCounts[store.FolderSaved], Param: "folder=" + store.FolderSaved},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Inbox rendering, the manual mail scan, and the classify action.
|
// 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
|
// 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
|
// recommended action plus confidence on each row. Phase 4 adds a bulk-select
|
||||||
// classification fall under "Unclassified".
|
// toolbar (FR-6) and folder views so a recoverable delete can be seen under the
|
||||||
|
// Delete folder and restored.
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,7 +22,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
|
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
|
||||||
const unclassifiedCategory = "Unclassified"
|
const unclassifiedCategory = store.UnclassifiedCategory
|
||||||
|
|
||||||
type messageRow struct {
|
type messageRow struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -31,6 +33,9 @@ type messageRow struct {
|
|||||||
Confidence int
|
Confidence int
|
||||||
Classified bool
|
Classified bool
|
||||||
When string
|
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 {
|
type messageGroup struct {
|
||||||
@@ -43,11 +48,16 @@ type inboxData struct {
|
|||||||
Total int
|
Total int
|
||||||
Note string
|
Note string
|
||||||
Error string
|
Error string
|
||||||
|
CSRFToken string
|
||||||
|
Folder string
|
||||||
|
Category string
|
||||||
|
FolderLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadInbox reads messages and groups them by the live category list.
|
// loadInbox reads messages for the active folder/category filter and groups them
|
||||||
func (s *Server) loadInbox(ctx context.Context) (inboxData, error) {
|
// by the live category list.
|
||||||
items, err := s.store.ListMessages(ctx, 200)
|
func (s *Server) loadInbox(ctx context.Context, f store.MessageFilter) (inboxData, error) {
|
||||||
|
items, err := s.store.ListMessagesFiltered(ctx, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return inboxData{}, err
|
return inboxData{}, err
|
||||||
}
|
}
|
||||||
@@ -88,6 +98,10 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
|||||||
if action == "" {
|
if action == "" {
|
||||||
action = "Review"
|
action = "Review"
|
||||||
}
|
}
|
||||||
|
folder := strings.TrimSpace(it.Folder)
|
||||||
|
if folder == "" {
|
||||||
|
folder = store.FolderInbox
|
||||||
|
}
|
||||||
buckets[key] = append(buckets[key], messageRow{
|
buckets[key] = append(buckets[key], messageRow{
|
||||||
ID: it.ID,
|
ID: it.ID,
|
||||||
From: orDefault(it.FromAddr, "(unknown sender)"),
|
From: orDefault(it.FromAddr, "(unknown sender)"),
|
||||||
@@ -97,6 +111,7 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
|||||||
Confidence: confidencePercent(it.Confidence),
|
Confidence: confidencePercent(it.Confidence),
|
||||||
Classified: it.HasClassification,
|
Classified: it.HasClassification,
|
||||||
When: formatWhen(it.DateSent, it.ReceivedAt),
|
When: formatWhen(it.DateSent, it.ReceivedAt),
|
||||||
|
Restorable: folder != store.FolderInbox,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +141,20 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
|||||||
return groups
|
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.
|
// confidencePercent turns a 0–1 confidence into a whole percentage.
|
||||||
func confidencePercent(v float64) int {
|
func confidencePercent(v float64) int {
|
||||||
if v <= 0 {
|
if v <= 0 {
|
||||||
@@ -137,14 +166,28 @@ func confidencePercent(v float64) int {
|
|||||||
return int(v*100 + 0.5)
|
return int(v*100 + 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
|
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note, errMsg string) {
|
||||||
data, err := s.loadInbox(r.Context())
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("load inbox", "error", err)
|
s.logger.Error("load inbox", "error", err)
|
||||||
http.Error(w, "could not load messages", http.StatusInternalServerError)
|
http.Error(w, "could not load messages", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.Note = note
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.tmpl.ExecuteTemplate(w, "inbox.html", data); err != nil {
|
if err := s.tmpl.ExecuteTemplate(w, "inbox.html", data); err != nil {
|
||||||
s.logger.Error("render inbox partial", "error", err)
|
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.
|
// handleInboxPartial is the HTMX fragment for the centre list.
|
||||||
func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) {
|
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
|
// 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)
|
sum, err := s.fetcher.Fetch(ctx)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
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):
|
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):
|
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:
|
default:
|
||||||
s.logger.Error("mail scan failed", "error", err)
|
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:
|
case err == nil:
|
||||||
s.renderInbox(w, r, fmt.Sprintf(
|
s.renderInbox(w, r, fmt.Sprintf(
|
||||||
"Classified %d of %d message(s); %d failed; %d still unclassified.",
|
"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):
|
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:
|
default:
|
||||||
s.logger.Error("classification run failed", "error", err)
|
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")
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
|
"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/config"
|
||||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
|
"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/session"
|
||||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
|
||||||
@@ -41,6 +42,7 @@ type Server struct {
|
|||||||
sessions *session.Manager
|
sessions *session.Manager
|
||||||
fetcher *mailfetch.Fetcher
|
fetcher *mailfetch.Fetcher
|
||||||
classifier *classify.Classifier
|
classifier *classify.Classifier
|
||||||
|
actions MailActions
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Server. It does not start listening.
|
// 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),
|
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
|
||||||
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
|
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
|
||||||
),
|
),
|
||||||
|
actions: mailops.New(cfg, logger),
|
||||||
}
|
}
|
||||||
s.http = &http.Server{
|
s.http = &http.Server{
|
||||||
Addr: cfg.Addr,
|
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/classify", s.requireAuth(s.handleClassify))
|
||||||
mux.HandleFunc("POST /actions/reclassify", s.requireAuth(s.handleReclassify))
|
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.
|
// FR-14: editable category list.
|
||||||
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
|
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
|
||||||
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))
|
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ type Message struct {
|
|||||||
ReceivedAt time.Time
|
ReceivedAt time.Time
|
||||||
IMAPUID int64
|
IMAPUID int64
|
||||||
IMAPUIDValidity 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.
|
// MessageListItem is a message plus its latest classification, when present.
|
||||||
@@ -38,6 +43,24 @@ type MessageListItem struct {
|
|||||||
HasClassification bool
|
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
|
// 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
|
// same Gmail id is seen again. received_at is preserved on update so the
|
||||||
// original arrival time is stable.
|
// original arrival time is stable.
|
||||||
@@ -99,6 +122,7 @@ SELECT
|
|||||||
COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''),
|
COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''),
|
||||||
COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at,
|
COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at,
|
||||||
COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0),
|
COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0),
|
||||||
|
COALESCE(m.folder, 'inbox'),
|
||||||
COALESCE(c.category, ''), COALESCE(c.importance, ''),
|
COALESCE(c.category, ''), COALESCE(c.importance, ''),
|
||||||
COALESCE(c.action, ''), COALESCE(c.confidence, 0)
|
COALESCE(c.action, ''), COALESCE(c.confidence, 0)
|
||||||
FROM messages m
|
FROM messages m
|
||||||
@@ -125,7 +149,7 @@ LIMIT ?`
|
|||||||
&item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr,
|
&item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr,
|
||||||
&item.ToAddr, &item.Subject, &item.BodyText,
|
&item.ToAddr, &item.Subject, &item.BodyText,
|
||||||
&item.SourceAddress, &dateSent, &received,
|
&item.SourceAddress, &dateSent, &received,
|
||||||
&item.IMAPUID, &item.IMAPUIDValidity,
|
&item.IMAPUID, &item.IMAPUIDValidity, &item.Folder,
|
||||||
&item.Category, &item.Importance, &item.Action, &item.Confidence,
|
&item.Category, &item.Importance, &item.Action, &item.Confidence,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan message: %w", err)
|
return nil, fmt.Errorf("scan message: %w", err)
|
||||||
|
|||||||
@@ -28,11 +28,18 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
-- belongs to. UIDs are only meaningful while UIDVALIDITY is unchanged.
|
-- belongs to. UIDs are only meaningful while UIDVALIDITY is unchanged.
|
||||||
imap_uid INTEGER,
|
imap_uid INTEGER,
|
||||||
imap_uidvalidity 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')),
|
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
created_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_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_thread_id ON messages (thread_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_source ON messages (source_address);
|
CREATE INDEX IF NOT EXISTS idx_messages_source ON messages (source_address);
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,27 @@ func (s *Store) applyMigrations(ctx context.Context) error {
|
|||||||
return fmt.Errorf("record schema version 3: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
265
internal/store/triage.go
Normal file
265
internal/store/triage.go
Normal 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
|
||||||
|
}
|
||||||
194
internal/store/triage_test.go
Normal file
194
internal/store/triage_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -375,3 +375,40 @@ a:hover { text-decoration: underline; }
|
|||||||
.category-rename { grid-template-columns: 1fr; }
|
.category-rename { grid-template-columns: 1fr; }
|
||||||
.category-remove .btn { width: 100%; }
|
.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); }
|
||||||
|
|||||||
@@ -49,13 +49,15 @@
|
|||||||
<a class="nav-edit" href="/categories">Edit</a>
|
<a class="nav-edit" href="/categories">Edit</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-group-items">
|
<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 class="dot" style="background: var(--primary)"></span>
|
||||||
<span>All mail</span>
|
<span>All mail</span>
|
||||||
<span class="count">{{.Total}}</span>
|
<span class="count">{{.Total}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{range .Categories}}
|
{{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 class="dot" style="background: {{.Color}}; {{if not .Count}}opacity:.35;{{end}}"></span>
|
||||||
<span>{{.Name}}</span>
|
<span>{{.Name}}</span>
|
||||||
<span class="count">{{.Count}}</span>
|
<span class="count">{{.Count}}</span>
|
||||||
@@ -67,10 +69,11 @@
|
|||||||
<div class="nav-group-title">Folders</div>
|
<div class="nav-group-title">Folders</div>
|
||||||
<div class="nav-group-items">
|
<div class="nav-group-items">
|
||||||
{{range .Folders}}
|
{{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 class="dot" style="background: {{.Color}}"></span>
|
||||||
<span>{{.Name}}</span>
|
<span>{{.Name}}</span>
|
||||||
<span class="count">0</span>
|
<span class="count">{{.Count}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,5 +122,31 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,55 @@
|
|||||||
<div class="scan-note-slot">
|
<div class="scan-note-slot">
|
||||||
{{if .Note}}<div class="scan-note" role="status">{{.Note}}</div>{{end}}
|
{{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>
|
</div>
|
||||||
|
|
||||||
|
<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}}
|
{{if .Groups}}
|
||||||
{{range .Groups}}
|
{{range .Groups}}
|
||||||
<section class="msg-group" aria-label="{{.Name}}">
|
<section class="msg-group" aria-label="{{.Name}}">
|
||||||
@@ -11,6 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{range .Messages}}
|
{{range .Messages}}
|
||||||
<div class="msg-row">
|
<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-main">
|
||||||
<div class="msg-from">{{.From}}</div>
|
<div class="msg-from">{{.From}}</div>
|
||||||
<div class="msg-subject">{{.Subject}}</div>
|
<div class="msg-subject">{{.Subject}}</div>
|
||||||
@@ -20,6 +69,13 @@
|
|||||||
<span class="pill action-pill">{{.Action}}</span>
|
<span class="pill action-pill">{{.Action}}</span>
|
||||||
{{if .Classified}}<span class="msg-confidence" title="Classifier confidence">{{.Confidence}}%</span>{{end}}
|
{{if .Classified}}<span class="msg-confidence" title="Classifier confidence">{{.Confidence}}%</span>{{end}}
|
||||||
{{if .When}}<span class="msg-when">{{.When}}</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>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -27,8 +83,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-title">No messages yet</div>
|
<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>
|
<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>
|
<span class="pill">No messages</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user