Files
email_agent/docs/phase4-triage.md
Sam Rolfe ae53374ad1 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 <noreply@paperclip.ing>
2026-10-11 21:47:36 +11:00

144 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<address the mail arrived at>` (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_<action>`
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.