replace imap gmail sync with mbsync + maildir import, app password UI

- rewrite gmail-sync handler: mbsync downloads to local Maildir, then import to sqlite
- add app password field to google integration config and API
- gmail sync section independent from oauth in settings UI
- live mbsync progress streaming to job status
- recoverable failure email with instructions for overquota/auth errors
- sync meta persisted to job on failure for richer notifications

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent 927267e041
commit 583580eb10
6 changed files with 472 additions and 256 deletions
+36 -3
View File
@@ -81,11 +81,16 @@ function kickLane(lane: string) {
processNextInLane(lane);
}
function scheduleRetry(lane: string, delayMs: number) {
setTimeout(() => kickLane(lane), delayMs);
}
async function processNextInLane(lane: string) {
try {
const jobs = await listAllJobs();
const now = Date.now();
const next = jobs
.filter((j) => j.lane === lane && j.status === 'queued')
.filter((j) => j.lane === lane && j.status === 'queued' && (!j.retryAt || j.retryAt <= now))
.sort((a, b) => a.createdAt - b.createdAt)[0];
if (!next) {
@@ -98,7 +103,7 @@ async function processNextInLane(lane: string) {
console.error(`[queue] Lane ${lane} processing error:`, err);
} finally {
const jobs = await listAllJobs();
const hasMore = jobs.some((j) => j.lane === lane && j.status === 'queued');
const hasMore = jobs.some((j) => j.lane === lane && j.status === 'queued' && (!j.retryAt || j.retryAt <= Date.now()));
if (hasMore) {
processNextInLane(lane);
} else {
@@ -119,8 +124,10 @@ async function runJob(job: Job) {
job.status = 'running';
job.startedAt = Date.now();
job.retryAt = undefined;
await writeJob(job);
console.log(`[queue] Running job ${job.id} (${job.type})`);
const isRetry = (job.retries ?? 0) > 0;
console.log(`[queue] ${isRetry ? 'Resuming' : 'Running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`);
const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) };
@@ -134,6 +141,9 @@ async function runJob(job: Job) {
const handlerStep = handler.steps[i]!;
const step = fresh.steps[i]!;
// Skip already-completed steps on retry
if (step.status === 'completed') continue;
fresh.currentStep = i;
step.status = 'running';
step.startedAt = Date.now();
@@ -171,9 +181,32 @@ async function runJob(job: Job) {
step.status = 'failed';
step.error = errorMessage;
step.completedAt = Date.now();
// Check if handler supports retry
const retries = (fresh.retries ?? 0) + 1;
if (handler.retry && retries <= handler.retry.maxRetries) {
// Schedule retry: reset failed step to pending, re-queue
step.status = 'pending';
step.error = undefined;
step.startedAt = undefined;
step.completedAt = undefined;
step.progress = undefined;
fresh.status = 'queued';
fresh.error = undefined;
fresh.completedAt = undefined;
fresh.startedAt = undefined;
fresh.retries = retries;
fresh.retryAt = Date.now() + handler.retry.delayMs;
await writeJob(fresh);
console.log(`[queue] Job ${fresh.id} will retry (${retries}/${handler.retry.maxRetries}) in ${handler.retry.delayMs / 1000}s — failed at step "${step.name}": ${errorMessage}`);
scheduleRetry(fresh.lane, handler.retry.delayMs);
return;
}
fresh.status = 'failed';
fresh.error = `Step "${step.name}" failed: ${errorMessage}`;
fresh.completedAt = Date.now();
fresh.meta = { ...fresh.meta, ...sharedMeta };
await writeJob(fresh);
console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage);
if (fresh.notify !== false) await notifyFailure(fresh);