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
+34 -9
View File
@@ -16,6 +16,12 @@ import (
var ErrNotTested = errors.New("accounts not fully tested")
var ErrAlreadyRunning = errors.New("task already running")
// maxAccountErrors caps how many individual error rows one account records per
// run, so a corrupt mailbox producing thousands of failures can't bloat the
// account_errors table. The cap'th row is a synthetic "further errors
// suppressed" note.
const maxAccountErrors = 500
// folderPlan is one source folder scheduled for copy and its destination name.
type folderPlan struct {
src, dst string
@@ -264,6 +270,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
_ = o.store.SetAccountStatus(ctx, a.ID, "running")
_ = o.store.SetAccountError(ctx, a.ID, "") // clear any error from a previous run
_ = o.store.ResetAccountCounters(ctx, a.ID) // start from zero; IncAccountCounters is additive
_ = o.store.ClearAccountErrors(ctx, a.ID) // drop last run's per-error rows
// Per-account cancellable context: IMAP work uses actx (so CancelAccount
// stops it); DB writes keep the parent ctx so status/counters persist even
@@ -277,28 +284,28 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
srcPass, err := crypto.Decrypt(o.encKey, a.SrcPassEnc)
if err != nil {
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err)
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
}
dstPass, err := crypto.Decrypt(o.encKey, a.DstPassEnc)
if err != nil {
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err)
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
}
src, err := imapx.Connect(actx, srcEP)
if err != nil {
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err)
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil {
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err)
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
}
dst, err := imapx.Connect(actx, dstEP)
if err != nil {
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err)
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login(a.DstLogin, string(dstPass)).Wait(); err != nil {
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err)
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
}
// On cancel, close the connections so any in-flight network read (a slow
@@ -311,7 +318,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
folders, err := imapx.ListFolders(src)
if err != nil {
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err)
return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
}
// Planning pass: decide folders from the account's own config, then EXAMINE
@@ -341,9 +348,24 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
var curFolder string
var curTotal int64
var lastEmit, lastScanEmit time.Time
// Persist individual errors for the per-account error modal, capped so a
// corrupt mailbox can't write unbounded rows. Runs on this goroutine, so
// the counter is race-free (same reasoning as the progress vars above).
var persistedErrs int
addErr := func(kind, folder, ref, msg string) {
persistedErrs++
switch {
case persistedErrs < maxAccountErrors:
_ = o.store.AddAccountError(ctx, a.ID, runID, kind, folder, ref, msg)
case persistedErrs == maxAccountErrors:
_ = o.store.AddAccountError(ctx, a.ID, runID, "account", "", "",
"too many errors — further errors suppressed")
}
}
deps := imapx.CopyDeps{
IsMigrated: func(k string) (bool, error) { return o.store.IsMigrated(ctx, a.ID, k) },
MarkMigrated: func(folder, k string) error { return o.store.MarkMigrated(ctx, a.ID, folder, k) },
OnError: func(ref, msg string) { addErr("message", curFolder, ref, msg) },
OnProgress: func(c, s int) {
now := time.Now()
done := c + s
@@ -393,6 +415,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
slog.Warn("folder copy error", "account", a.ID, "src_login", a.SrcLogin, "folder", fp.src, "err", err)
folderErr = 1
_ = o.store.SetAccountError(ctx, a.ID, "folder \""+fp.src+"\": "+err.Error())
addErr("folder", fp.src, "", err.Error())
o.hub.Publish(wshub.Event{Type: "error", TaskID: task.ID, Data: map[string]any{
"account_id": a.ID, "src_login": a.SrcLogin, "folder": fp.src, "error": err.Error(),
}})
@@ -428,7 +451,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
return copied, skipped, errs
}
func (o *Orchestrator) accountFailed(ctx context.Context, taskID int64, a store.Account, srcEP, dstEP imapx.Endpoint, side string, err error) (int64, int64, int64) {
func (o *Orchestrator) accountFailed(ctx context.Context, taskID, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint, side string, err error) (int64, int64, int64) {
// A cancellation surfacing as an error is a cancel, not a failure.
if errors.Is(err, context.Canceled) {
_ = o.store.SetAccountStatus(ctx, a.ID, "cancelled")
@@ -442,7 +465,9 @@ func (o *Orchestrator) accountFailed(ctx context.Context, taskID int64, a store.
}
slog.Error("account failed", "account", a.ID, "side", side, "login", login, "host", host, "port", port, "err", err)
_ = o.store.SetAccountStatus(ctx, a.ID, "error")
_ = o.store.SetAccountError(ctx, a.ID, side+" "+login+"@"+host+": "+err.Error())
failMsg := side + " " + login + "@" + host + ": " + err.Error()
_ = o.store.SetAccountError(ctx, a.ID, failMsg)
_ = o.store.AddAccountError(ctx, a.ID, runID, "account", "", "", failMsg)
o.hub.Publish(wshub.Event{Type: "error", TaskID: taskID,
Data: map[string]any{"account_id": a.ID, "side": side, "login": login, "host": host, "port": port, "error": err.Error()}})
return 0, 0, 1