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

7.3 KiB
Raw Permalink Blame History

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 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:

{"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

  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.