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>
7.3 KiB
Phase 4 — Triage actions (delete, move, mark; bulk select)
Status: done and deployed on the host running email-agent. AUD-70,
parent 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 allcheckbox and one checkbox per row. A toolbar performsdelete,save,reply,mark read,mark unread, andstarover every checked id. The toolbar and rows live in the/partials/inboxfragment, so it keeps working after every HTMX swap. -
Move to Gmail labels (FR-9).
internal/mailopsmaps an app folder to a Gmail label and performs the move over IMAP (IPv4 forced, same as the read path):App folder Gmail label inboxINBOXdeleteDeletesavedSavedrepliedReplied-to/<address the mail arrived at>(FR-15)The destination label is created lazily. The move uses the IMAP
MOVEextension and falls back toCOPY+STORE \Deleted+UID EXPUNGE. -
Deletes are recoverable. A delete moves the message to the
Deletelabel; it is never hard-deleted. The Delete, Saved, and Replied-to folder views show a Restore button that moves the message back toINBOX. -
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 unlessspam_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.folderis the app view andmessages.mailboxis the exact Gmail label. Gmail assigns a differentUIDper label, so a message is always located by its stableX-GM-MSGID(UID SEARCH X-GM-MSGID …), never by a storedUID. -
FR-13 logging. Every action writes one
moveormarkrow per message (message id, actor, destination label, source label) plus onebulk_<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:
{"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:
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
git revert 318b7b4 8c04167anddocker compose up -d --build.- Migration v4 is additive (two columns + one index) and is not undone by the
revert; the Phase 3 binary ignores
folder/mailboxand lists every message flat. - 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 thereplybulk action now; Phase 5 will move a message there automatically when a reply is sent.