email sync: API passes full config at enqueue, auto-reconnect on IMAP drops

Sidecar/queue runner no longer needs job-specific context. API server
resolves account details, IMAP auth, and user email at enqueue time —
all persisted in the job file. Handler reads directly from job meta.

Removed "Load account" step. Sync step auto-reconnects up to 10 times
when Gmail drops the connection, resuming from saved UIDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 07:02:51 +00:00
co-authored by Claude Opus 4.6
parent 170bd6d41b
commit 4ececbe748
4 changed files with 336 additions and 221 deletions
+54 -2
View File
@@ -36,6 +36,39 @@ export const accountsRouter = createRouter();
accountsRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const accounts = await getEmailAccounts(user.id);
// Check for stale syncing/queued accounts with no active job
const staleIds: number[] = [];
const hasActiveAccounts = accounts.some((a) => a.status === 'syncing' || a.status === 'queued');
let activeJobAccountIds = new Set<number>();
if (hasActiveAccounts) {
try {
const jobs = await sidecar.listJobs();
activeJobAccountIds = new Set(
jobs
.filter((j) => j.type === 'email-sync' && (j.status === 'queued' || j.status === 'running'))
.map((j) => (j.meta as Record<string, unknown> | undefined)?.emailAccountId as number)
.filter(Boolean),
);
} catch {
// Sidecar unavailable — all syncing/queued accounts are stale
}
for (const a of accounts) {
if ((a.status === 'syncing' || a.status === 'queued') && !activeJobAccountIds.has(a.id)) {
staleIds.push(a.id);
}
}
// Reset stale accounts in background
if (staleIds.length > 0) {
for (const id of staleIds) {
updateEmailAccountStatus(id, 'connected').catch(() => {});
}
}
}
return ctx.json(
accounts.map((a) => ({
id: a.id,
@@ -43,7 +76,7 @@ accountsRouter.get('/', async (ctx) => {
email: a.email,
displayName: a.displayName,
enabled: a.enabled,
status: a.status,
status: staleIds.includes(a.id) ? 'connected' : a.status,
createdAt: a.createdAt,
})),
);
@@ -104,6 +137,10 @@ accountsRouter.post('/:id/sync', async (ctx) => {
if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing');
if (account.status === 'synced') throw BAD_REQUEST('Account is already synced — incremental syncs run automatically');
// Resolve auth before enqueueing
const authResult = await resolveAuth(user.id, account.authType, account.email, account.credentials as Record<string, unknown>);
if (!authResult.ok) throw BAD_REQUEST(authResult.error);
// Set status immediately so the UI reflects the queued state
await updateEmailAccountStatus(id, 'queued');
@@ -111,7 +148,22 @@ accountsRouter.post('/:id/sync', async (ctx) => {
lane: 'email',
type: 'email-sync',
userId: user.email,
meta: { emailAccountId: id },
meta: {
emailAccountId: id,
userEmail: user.email,
account: {
id: account.id,
userId: account.userId,
email: account.email,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSecure: account.imapSecure,
provider: account.provider,
authType: account.authType,
credentials: account.credentials,
},
imapAuth: { user: account.email, ...authResult.auth },
},
});
return ctx.json({ ok: true, jobId: job.id }, 201);