// Sync scheduling, owned by the sidecar. // // These syncs used to be platform queue jobs, which gave them retries and a row in the Jobs list. That was // the wrong home twice over: the credentials travelled through Postgres job metadata to get there, and a // mailbox sync has nothing to do with what the Jobs screen is for. They run here now, tracked in memory, // and the Jobs list is left to the things it actually describes. // // Deliberately NOT a queue: one run per account at a time, no persistence, no retry. A failed sync is // retried by the ten-minute cron like any other, and a sync interrupted by a restart resumes from the // stored sync cursor rather than from the beginning. /** What a sync reports as it goes. `total: 0` means "unknown" — IMAP does not tell us up front. */ export type SyncProgress = { current: number; total: number; label: string }; export type SyncState = { accountId: number; status: 'running' | 'completed' | 'failed'; progress: SyncProgress | null; startedAt: number; finishedAt?: number; error?: string; }; const states = new Map(); /** The steps of a converted job handler: each is run in order with a synthesized context. */ export type SyncStep = { name: string; run: (ctx: SyncCtx) => Promise | void }; /** * What the moved handlers expect on their context. `job.userId` is the OWNER'S EMAIL, not a numeric id — * the queue called it userId and the handlers use it to resolve the mail store path * (DATA_PATH//email_accounts/…). Getting this wrong reads as an empty mailbox rather than an error. */ export type SyncCtx = { /** Same shape the queue's StepContext used, so the moved handlers read it unchanged. */ meta: Record; job: { userId: string }; updateProgress: (p: SyncProgress) => Promise | void; }; export function isSyncing(accountId: number): boolean { return states.get(accountId)?.status === 'running'; } export function getSyncStates(): SyncState[] { return [...states.values()]; } /** * Run `steps` in the background for one account. Returns immediately — an initial mailbox sync takes * minutes to hours, so nothing waits on it. Re-entrant calls for an account already syncing are ignored. */ export function startSync(params: { accountId: number; ownerEmail: string; meta: Record; steps: SyncStep[]; onDone?: (ok: boolean) => Promise | void; }): SyncState { const existing = states.get(params.accountId); if (existing?.status === 'running') return existing; const state: SyncState = { accountId: params.accountId, status: 'running', progress: null, startedAt: Date.now(), }; states.set(params.accountId, state); const ctx: SyncCtx = { meta: params.meta, job: { userId: params.ownerEmail }, updateProgress: (p) => { state.progress = p; }, }; void (async () => { try { for (const step of params.steps) await step.run(ctx); state.status = 'completed'; } catch (err) { state.status = 'failed'; state.error = err instanceof Error ? err.message : String(err); console.error(`[email-sync] account ${params.accountId} failed:`, state.error); } finally { state.finishedAt = Date.now(); await params.onDone?.(state.status === 'completed'); } })(); return state; }