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
This commit is contained in:
2026-07-05 12:16:12 +07:00
co-authored by Claude Opus 4.8
parent 2623bc8815
commit 45b0ff2358
15 changed files with 448 additions and 12 deletions
+29 -1
View File
@@ -27,6 +27,11 @@ type CopyDeps struct {
// folder's messages have been examined so far — so the UI shows movement
// while dedup decisions are made, before bodies start copying.
OnScan func(scanned, total int64)
// OnError is called for each message-level error, with a message reference
// ("UID N: subject", empty when the envelope is unavailable) and the error
// text — so the orchestrator can persist individual errors for the
// per-account error modal. Folder-level errors are reported by the caller.
OnError func(ref, msg string)
}
// CopyResult summarizes the outcome of one CopyFolder run.
@@ -44,6 +49,15 @@ type CopyResult struct {
// checked between windows.
const metaScanBatch = 1000
// msgRef builds a human-readable reference for a message error: its UID plus
// subject when known, e.g. "UID 42: Invoice". Falls back to just the UID.
func msgRef(uid imap.UID, subject string) string {
if subject == "" {
return fmt.Sprintf("UID %d", uid)
}
return fmt.Sprintf("UID %d: %s", uid, subject)
}
// metaBatches tiles 1..total into contiguous, non-overlapping windows of at
// most batchSize, covering every sequence number exactly once.
func metaBatches(total, batchSize uint32) []imap.SeqRange {
@@ -95,11 +109,17 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst
type queued struct {
uid imap.UID
key string
subject string
flags []imap.Flag
internalDate time.Time
}
var todo []queued
var scanned int64
reportErr := func(ref, msg string) {
if deps.OnError != nil {
deps.OnError(ref, msg)
}
}
// Scan metadata in bounded windows instead of one FETCH 1:*, so each
// command is short (the server stays responsive) and ctx is checked on
// every window boundary — not just between messages of one giant command.
@@ -122,20 +142,26 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst
buf, err := msg.Collect()
if err != nil {
res.Errors++
reportErr("", "read message metadata: "+err.Error())
continue
}
scanned++
key := MessageKey(buf.Envelope, buf.RFC822Size)
subject := ""
if buf.Envelope != nil {
subject = buf.Envelope.Subject
}
already, err := deps.IsMigrated(key)
if err != nil {
res.Errors++
reportErr(msgRef(buf.UID, subject), "dedup lookup: "+err.Error())
} else if already {
res.Skipped++
if deps.OnProgress != nil {
deps.OnProgress(res.Copied, res.Skipped)
}
} else {
todo = append(todo, queued{uid: buf.UID, key: key, flags: buf.Flags, internalDate: buf.InternalDate})
todo = append(todo, queued{uid: buf.UID, key: key, subject: subject, flags: buf.Flags, internalDate: buf.InternalDate})
}
if deps.OnScan != nil {
deps.OnScan(scanned, total)
@@ -153,10 +179,12 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst
}
if err := streamOne(src, dst, dstFolder, q.uid, q.flags, q.internalDate); err != nil {
res.Errors++
reportErr(msgRef(q.uid, q.subject), "copy message: "+err.Error())
continue
}
if err := deps.MarkMigrated(dstFolder, q.key); err != nil {
res.Errors++
reportErr(msgRef(q.uid, q.subject), "mark migrated: "+err.Error())
continue
}
res.Copied++