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:
+29
-1
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user