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

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

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

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

View File

@@ -9,17 +9,16 @@ pre-planning material and is not authoritative.
## Status
**Phase 3 — Classify.** The Go skeleton, session login, and HTMX dashboard
(Phase 1), real mail over **IMAP with an app password, forced to IPv4**
(Phase 2), and now **JEV classification** (Phase 3). Every message gets a
category, an importance, and a suggested action, grouped in the triage list with
the recommended action and confidence per row. The category list is editable in
the UI (add, rename, remove) and JEV uses the current list; a plain-text rules
table can override the classifier (FR-4). The FR-2 source address is resolved
from `X-Pm-Original-To`, with `To`, the PurelyMail `Received … for` clause, and
`Return-Path` as fallbacks; `Delivered-To` is never used. See
[`docs/phase3-classify.md`](docs/phase3-classify.md) for the confirmed JEV shape
and the phase evidence. Triage actions come in Phase 4.
**Phase 4 — Triage actions.** The Go skeleton, session login, and HTMX
dashboard (Phase 1), real mail over **IMAP with an app password, forced to IPv4**
(Phase 2), **JEV classification** (Phase 3), and now **bulk triage actions**
(Phase 4). The inbox has a select-all toolbar for delete, move, and mark (FR-6),
and a folder view for the recoverable Delete/Saved/Replied-to Gmail labels (FR-9).
A delete moves the message to a Gmail label (never a hard delete) and a row in
the Delete folder can be **restored** to the inbox. Bulk delete needs a UI
confirmation and a message classified Spam needs a second confirmation — spam is
never auto-deleted (§10). Every action is appended to `actions_log` (FR-13). See
[`docs/phase4-triage.md`](docs/phase4-triage.md) for the phase evidence.
## Layout
@@ -28,6 +27,7 @@ cmd/email-agent/ service entry point (`-fetch`, `-classify`, `-classify-a
internal/classify/ JEV (TypeSafe System One) client, rule engine, orchestration
internal/config/ environment-driven configuration
internal/mailfetch/ stdlib-only IMAP client, MIME parsing, FR-2 source address
internal/mailops/ Gmail label moves and flag changes for triage actions
internal/session/ server-side session store + signed cookie values
internal/store/ SQLite open + schema (internal/store/schema.sql)
internal/server/ HTTP routes, auth middleware, HTMX handlers
@@ -124,6 +124,26 @@ in `classifications.category` and the Choice option sent to JEV, so the model
always sees the current list. Renaming cascades to existing classifications;
removing a category reassigns its messages to the first remaining category.
## Triage actions (Phase 4)
`internal/mailops` turns an app folder into a Gmail label and performs the move
over IMAP, forcing IPv4 exactly like the read path:
| App folder | Gmail label |
|---|---|
| `inbox` | `INBOX` |
| `delete` | `Delete` (recoverable; never a hard delete) |
| `saved` | `Saved` |
| `replied` | `Replied-to/<address the mail arrived at>` |
A move uses the IMAP `MOVE` extension (with a `COPY` + `\Deleted` + `EXPUNGE`
fallback). Because Gmail gives each label its own `UID`, a message is located by
its stable `X-GM-MSGID`, not by a stored `UID`. A message in the Delete, Saved,
or Replied-to folder has a **Restore** button that moves it back to `INBOX`.
Every action appends to `actions_log`: one `move` or `mark` row per message,
plus one `bulk_<action>` summary row.
## Run locally
```sh
@@ -157,6 +177,7 @@ Caddy on `.35` proxies `mail.lab.audasmedia.com.au` to this port.
| `POST` | `/actions/fetch` | session + CSRF | Runs one IMAP scan, returns the refreshed list |
| `POST` | `/actions/classify` | session + CSRF | Classifies pending messages, returns the refreshed list |
| `POST` | `/actions/reclassify` | session + CSRF | Re-classifies every message with the current categories and rules |
| `POST` | `/actions/bulk` | session + CSRF | Bulk delete/move/mark and restore over selected message ids (FR-6/FR-9) |
| `GET` | `/categories` | session | Category editor (FR-14) |
| `POST` | `/categories/add` | session + CSRF | Adds a category |
| `POST` | `/categories/rename` | session + CSRF | Renames a category and updates existing classifications |
@@ -175,4 +196,6 @@ schema in `internal/store/schema.sql` creates the AGENTS.md §7 tables:
`summaries`, plus `categories`, `schema_migrations`. Schema init is idempotent
and runs on every start. Migration v2 adds `imap_uid` and `imap_uidvalidity` to
`messages`; migration v3 seeds the first category list and a starter rules
version once, so a category removed by the user is not silently re-added.
version once, so a category removed by the user is not silently re-added;
migration v4 adds `folder` and `mailbox` to `messages` for the Phase 4 triage
folders.