feat: stream the metadata scan instead of Collect

CopyFolder now streams envelope metadata via Next() in a first pass (dedup +
queue new messages), then streams bodies for new ones in a second pass —
no more blocking Collect of the whole folder with zero feedback, and memory
stays flat (only new-message meta is held).

- imapx: two-pass streaming CopyFolder + CopyDeps.OnScan(scanned,total)
- orchestrator: throttled 'scan' events during the metadata pass
- web: per-account 'scanning folder: X/N' line under the progress bar;
  scan events kept out of the log to avoid flooding

Verified on greenmail: idempotency and internal-date preservation still hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
2026-07-02 13:35:08 +07:00
parent 39285a2ee7
commit a2234077df
4 changed files with 89 additions and 22 deletions
+4
View File
@@ -282,6 +282,10 @@
letter-spacing: 0.04em;
}
.pscan {
color: var(--info);
}
/* clear-log button: mirrors the .panel-label tab on the right edge */
.log-clear {
position: absolute;
+20 -2
View File
@@ -15,6 +15,9 @@ type LiveProgress = {
startTs: number
startCount: number
speed: number // messages/sec, averaged since the account's run started
scanFolder?: string
scanned?: number
scanTotal?: number
}
function fmtDuration(sec: number): string {
@@ -89,11 +92,20 @@ export function TaskDetail({ id }: { id: number }) {
useEffect(
() =>
connectTaskWS(id, (ev: TaskEvent) => {
setLog((l) => [{ type: ev.type, text: describeEvent(ev) }, ...l].slice(0, 300))
// `scan` is high-frequency and shown in the progress cell, not the log.
if (ev.type !== 'scan') {
setLog((l) => [{ type: ev.type, text: describeEvent(ev) }, ...l].slice(0, 300))
}
const d = (ev.data ?? {}) as Record<string, number | string | undefined>
const accId = typeof d.account_id === 'number' ? d.account_id : undefined
if (ev.type === 'plan' && accId != null) {
if (ev.type === 'scan' && accId != null) {
setLive((prev) => {
const cur = prev[accId]
const base: LiveProgress = cur ?? { copied: 0, skipped: 0, total: 0, startTs: Date.now(), startCount: 0, speed: 0 }
return { ...prev, [accId]: { ...base, scanFolder: d.folder as string | undefined, scanned: Number(d.scanned ?? 0), scanTotal: Number(d.folder_total ?? 0) } }
})
} else if (ev.type === 'plan' && accId != null) {
const total = Number(d.total ?? 0)
const now = Date.now()
setLive((prev) => ({
@@ -453,6 +465,7 @@ export function TaskDetail({ id }: { id: number }) {
const done = lv.copied + lv.skipped
const pct = Math.min(100, Math.floor((done / lv.total) * 100))
const eta = lv.speed > 0 ? (lv.total - done) / lv.speed : Infinity
const scanning = lv.scanned != null && lv.scanTotal != null && lv.scanned < lv.scanTotal
return (
<div className="acct-progress">
<div className="pbar">
@@ -462,6 +475,11 @@ export function TaskDetail({ id }: { id: number }) {
{done}/{lv.total} ({pct}%) · {lv.speed >= 1 ? Math.round(lv.speed) : lv.speed.toFixed(1)}/s · ETA {fmtDuration(eta)}
{lv.folder ? ` · ${lv.folder}` : ''}
</span>
{scanning && (
<span className="pmeta pscan mono-num">
scanning {lv.scanFolder}: {lv.scanned}/{lv.scanTotal}
</span>
)}
</div>
)
})()}