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