# 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.