# Account Errors Modal — Design Date: 2026-07-05 Status: approved ## Problem When an account finishes `done_with_errors`, the UI shows only an error **count** (e.g. `2`) and a single persisted `last_error`. There is no way to see the individual errors — which folder, which message, and the actual error text. The event log holds them live but is lost on reload, and message-level errors (`res.Errors`) currently record only a counter, discarding their text entirely. ## Goal Click the ERRORS count of an account (when `> 0`) to open a modal listing the concrete errors of that account's **most recent run**: folder, kind, message reference, error text, and timestamp. ## Scope decisions - **Coverage:** errors of the latest run only. The list is cleared at the start of each run (like the per-account counters). - **Granularity:** every error is its own record — folder-level, message-level, and account-level (connect/login) are distinct rows. - **Cap:** at most 500 error rows per account per run (guard against a corrupt mailbox producing thousands). On overflow a final synthetic row records "… N more errors suppressed". ## Data model New table (chosen over a JSONB column on `accounts`): a table gives atomic `INSERT` per error with no read-modify-write races, trivial per-run clearing, `ON DELETE CASCADE` with the account, and matches the existing `runs` / `migrated_messages` shape. Migration `0005_account_errors`: ``` account_errors( id BIGSERIAL PRIMARY KEY, account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, run_id BIGINT, -- run the error belongs to (context) kind TEXT NOT NULL, -- 'folder' | 'message' | 'account' folder TEXT NOT NULL DEFAULT '', message_ref TEXT NOT NULL DEFAULT '', -- e.g. "UID 42: " error TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) CREATE INDEX ON account_errors(account_id); ``` Down migration: `DROP TABLE account_errors`. ## Backend **Store** (`internal/store/account_errors.go`): - `AddAccountError(ctx, accountID, runID int64, kind, folder, ref, msg string) error` - `ClearAccountErrors(ctx, accountID int64) error` - `ListAccountErrors(ctx, accountID int64) ([]AccountError, error)` — ordered by id. - `AccountError` struct mirrors the row. **Orchestrator** (`internal/orchestrator/orchestrator.go`): - At account start (next to `ResetAccountCounters`): `ClearAccountErrors`. - New `CopyDeps.OnError(kind, folder, ref, msg string)` callback. `CopyFolder` invokes it on every message-level error (the `res.Errors++` sites in `copy.go`) and folder-level error, passing UID/subject where available. The orchestrator's `OnError` impl persists via `AddAccountError`, enforcing the 500-row cap with an in-worker counter. - Folder-level copy error (already persisted to `last_error`) also emits `OnError(kind="folder", ...)`. - `accountFailed` (connect/login/decrypt) emits `OnError(kind="account", ...)`. - `last_error` behavior is unchanged (still the latest single error for the inline row hint). **HTTP** (`internal/httpapi`): - `GET /api/tasks/{id}/accounts/{accountId}/errors` → JSON `[]AccountError`. ## Frontend - `web/src/api.ts`: `listAccountErrors(taskId, accountId)`. - ERRORS cell becomes a button when the count `> 0`; opens `AccountErrorsModal`. - `web/src/components/AccountErrorsModal.tsx` (built on existing `Modal`, styled like `RunLogModal`): fetches on open, renders rows `time · folder · kind · message_ref · error`, with loading / empty / fetch-error states. ## Testing - Store: `AddAccountError` / `ClearAccountErrors` / `ListAccountErrors` against Postgres (existing store test harness). - `CopyFolder`: `OnError` is invoked when a per-message step fails (e.g. `IsMigrated` returns an error) — unit test with fakes, no server needed. - HTTP: handler returns the account's errors as JSON. ## Out of scope - Cross-run error history. - Retrying individual failed messages from the modal. - Exporting errors.