Files
imap-copier/docs/superpowers/specs/2026-07-05-account-errors-modal-design.md
T
vasyanskandClaude Opus 4.8 45b0ff2358 feat(errors): per-account error modal with persisted error list
Accounts finishing done_with_errors showed only a count and a single
last_error. This adds a modal listing every concrete error of the
account's most recent run.

- migration 0005: account_errors table (kind folder|message|account,
  folder, message_ref, error, created_at; ON DELETE CASCADE; indexed)
- store: AddAccountError / ClearAccountErrors / ListAccountErrors
- copy: OnError callback captures per-message error text (previously
  only counted), with a "UID N: subject" reference
- orchestrator: clear errors at run start; persist folder/message/
  account errors; cap 500 rows/account/run with a suppressed-note row
- api: GET /api/tasks/{id}/accounts/{accountId}/errors
- web: AccountErrorsModal, clickable ERRORS count, api + styles

Verified: migration applies on Postgres 18; store add/list/clear and
cascade tests pass against real pg; backend build/vet/test green; web
tsc+vite build and oxlint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9Eq4JtWjyNTv5qat3B3mM
2026-07-05 12:16:12 +07:00

4.0 KiB

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: <subject>"
  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.