From ae53374ad1b528c0b783c9fe6b78bd00b6e81432 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sun, 11 Oct 2026 21:47:36 +1100 Subject: [PATCH] docs: Phase 4 triage evidence and deploy log (AUD-70) Record the DoD evidence for Phase 4: select-all toolbar, live delete -> Delete label -> restore of message 100 over public HTTPS, the missing-CSRF 403, the two confirmation gates, and the FR-13 action-log rows. Co-Authored-By: Paperclip --- docs/phase4-triage.md | 143 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/phase4-triage.md diff --git a/docs/phase4-triage.md b/docs/phase4-triage.md new file mode 100644 index 0000000..d621b83 --- /dev/null +++ b/docs/phase4-triage.md @@ -0,0 +1,143 @@ +# Phase 4 — Triage actions (delete, move, mark; bulk select) + +Status: **done and deployed on the host running `email-agent`.** [AUD-70](/AUD/issues/AUD-70), +parent [AUD-68](/AUD/issues/AUD-68). Phase 4 of AGENTS.md §11. Commits +`8c04167` (implementation) and `318b7b4` (migration-order fix + regression test). + +**DoD:** select-all then delete works, and the message is recoverable. + +## 1. What was built + +- **Bulk select (FR-6).** The triage list has a `Select all` checkbox and one + checkbox per row. A toolbar performs `delete`, `save`, `reply`, `mark read`, + `mark unread`, and `star` over every checked id. The toolbar and rows live in + the `/partials/inbox` fragment, so it keeps working after every HTMX swap. +- **Move to Gmail labels (FR-9).** `internal/mailops` maps an app folder to a + Gmail label and performs the move over IMAP (IPv4 forced, same as the read + path): + + | App folder | Gmail label | + |---|---| + | `inbox` | `INBOX` | + | `delete` | `Delete` | + | `saved` | `Saved` | + | `replied` | `Replied-to/
` (FR-15) | + + The destination label is created lazily. The move uses the IMAP `MOVE` + extension and falls back to `COPY` + `STORE \Deleted` + `UID EXPUNGE`. +- **Deletes are recoverable.** A delete moves the message to the `Delete` label; + it is never hard-deleted. The Delete, Saved, and Replied-to folder views show + a **Restore** button that moves the message back to `INBOX`. +- **Bulk delete confirmation.** The Delete button carries an HTMX confirmation. + The server additionally refuses a delete without `confirm=yes`, so a request + that bypasses the UI cannot delete (or move) anything. +- **Spam confirmation (§10).** Spam is recommended, never auto-deleted. If the + selection contains any message whose latest classification is `Spam`, the + server refuses unless `spam_confirm=yes`. The UI sets it after the user + confirms; a direct call without it is rejected and the message stays put. +- **Location tracking (migration v4).** `messages.folder` is the app view and + `messages.mailbox` is the exact Gmail label. Gmail assigns a different `UID` + per label, so a message is always located by its stable `X-GM-MSGID` + (`UID SEARCH X-GM-MSGID …`), never by a stored `UID`. +- **FR-13 logging.** Every action writes one `move` or `mark` row per message + (message id, actor, destination label, source label) plus one `bulk_` + summary row. + +## 2. DoD evidence + +### 2.1 Automated tests + +`gofmt -l` clean, `go vet ./...` clean, `go test ./...` green (in +`golang:1.25-alpine`). New coverage: + +| Package | Tests | +|---|---| +| `internal/mailfetch` | `CreateMailbox` tolerates `ALREADYEXISTS`; `UID MOVE`; `COPY`+`STORE`+`EXPUNGE` fallback; `STORE` flags; `X-GM-MSGID` search; rejects a non-numeric search id | +| `internal/mailops` | folder → label mapping (incl. replied-by-address and sanitising); delete creates the label; restore does not create `INBOX`; no-op move; rejects `uid:` ids; not-configured; mark flag mapping | +| `internal/store` | migration v4 adds `folder`/`mailbox`; **opens a hand-built v3 database**; folder/category/unclassified filters; latest classification; folder counts; `MessagesByIDs` | +| `internal/server` | bulk delete requires CSRF (403) and confirmation; spam needs its own confirmation; delete → Delete folder → restore back to inbox; action-log rows; select-all checkboxes; save + mark; empty selection | + +### 2.2 Live, over public HTTPS (`https://mail.lab.audasmedia.com.au`) + +Deployed `email-agent:latest` rebuilt and recreated; migration v4 ran in place +(`schema_version: 4`), the Phase 1–3 `email_agent_data` volume kept. + +| Check | Result | +|---|---| +| `GET /health` (public HTTPS) | `200 {"status":"ok","env":"production","database":"ok"}` | +| Login (session + CSRF cookie) | `303`, session cookie set | +| Dashboard | renders `#inbox-list`, toolbar loaded by HTMX | +| `/partials/inbox` | `select-all` present, **100** row checkboxes | +| **Missing-CSRF path** | `POST /actions/bulk` with no CSRF → **`403`** | +| Delete without confirmation | `200`, no move (message still in inbox) | +| Delete with `confirm=yes`, no `spam_confirm=yes` (message 100 is `Spam`) | `200`, no move | +| Delete with both confirmations | `200`; message 100 moved `inbox → delete`; Delete view shows the row with a **Restore** button | +| Restore | `200`; message 100 moved `delete → inbox`; Delete view no longer lists it | + +Message 100 is the single live `Spam` message (`gmail_id +1878739256124347570`). The restore located it inside the `Delete` label via +`UID SEARCH X-GM-MSGID` and moved it back, which proves the deleted message was +present in the Delete label. Live Gmail state is back where it started: the +final folder count is `inbox=100`, and only the (empty) `Delete` label remains, +which the app creates on first use. + +Container log: + +```json +{"time":"2026-10-11T10:47:09Z","level":"INFO","msg":"message moved","message_id":100,"gmail_id":"1878739256124347570","from":"INBOX","to":"Delete"} +{"time":"2026-10-11T10:47:12Z","level":"INFO","msg":"message moved","message_id":100,"gmail_id":"1878739256124347570","from":"Delete","to":"INBOX"} +``` + +Action-log rows (FR-13, copied from the live database): + +``` +(312, None, 'bulk_restore', 'Sam', 'requested=1 ok=1 failed=0 ids=100', '2026-10-11 10:47:12') +(311, 100, 'move', 'Sam', 'to=inbox label="INBOX" from="Delete"', '2026-10-11 10:47:12') +(310, None, 'bulk_delete', 'Sam', 'requested=1 ok=1 failed=0 ids=100', '2026-10-11 10:47:10') +(309, 100, 'move', 'Sam', 'to=delete label="Delete" from="INBOX"', '2026-10-11 10:47:10') +``` + +### 2.3 Select-all then delete + +The DoD is "select-all then delete works". The toolbar's `Select all` toggles +every `input[name=ids]` in the loaded group, and the Delete button posts all of +them. The server applies the action to every id and writes one summary row per +request (`requested=N ok=N failed=N`). The live run used one id so the job could +be undone immediately; the multi-id path is covered by +`TestBulkDeleteThenRestoreIsRecoverable` (two ids in one request) and by +`parseIDList` deduplicating the posted list. + +## 3. Configuration + +No new environment variables. The label names are fixed in `internal/mailops` +(`Delete`, `Saved`, `Replied-to/`). `GMAIL_USER` and `GMAIL_APP_PASSWORD` are +the only credentials, already present on the host. + +## 4. Deployment + +On the host, from the repo, with the host secrets in scope: + +```sh +set -a; . ~/.config/environment.d/10-secrets.conf; set +a +docker compose build +docker compose up -d # migration v4 runs on start; data volume kept +``` + +## 5. Rollback + +1. `git revert 318b7b4 8c04167` and `docker compose up -d --build`. +2. Migration v4 is additive (two columns + one index) and is not undone by the + revert; the Phase 3 binary ignores `folder`/`mailbox` and lists every message + flat. +3. Gmail labels created by a move (`Delete`, `Saved`, `Replied-to/…`) are + additive and empty after a restore. They can be removed in Gmail by hand. + +## 6. Not done / deferred + +- The per-row **message detail** pane still shows the Phase 1 placeholder; the + reply draft and history are Phase 5. +- Undo for a move is a manual per-row **Restore** of the Delete/Saved/Replied-to + views, not a general undo stack. +- The 1-month body-retention job (AGENTS.md §7) is still not implemented. +- `Replied-to/…` labels are populated by the `reply` bulk action now; Phase 5 + will move a message there automatically when a reply is sent.