import { createRouter } from '@@/create-router'; import { BAD_REQUEST, NOT_FOUND } from '@@/custom-errors'; import { getEmailAccounts, getEmailAccount, createEmailAccount, deleteEmailAccount, updateEmailAccountStatus } from '../db/queries'; import { getValidGoogleAccessToken } from '@@/api/integrations/google-auth'; import { validateImapConnection } from './imap-validate'; import { startSync, isSyncing, getSyncStates } from './sync-runner'; import { imapSyncSteps } from './imap-sync'; import { gmailSyncSteps } from './gmail-api'; import { openEmailDb } from './store'; import { hasSyncedBefore } from './sync-meta'; import { performResync } from './resync'; type CreateAccountBody = { provider: string; email: string; displayName?: string; imapHost: string; imapPort: number; imapSecure: boolean; authType: string; credentials: Record; }; type ValidateBody = { imapHost: string; imapPort: number; imapSecure: boolean; authType: string; email: string; credentials: Record; }; 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(); if (hasActiveAccounts) { // In-process now: a running sync is one this process started, not a queued job row. An account marked // syncing with nothing running is stale — the usual cause is a restart mid-sync. activeJobAccountIds = new Set( getSyncStates() .filter((s) => s.status === 'running') .map((s) => s.accountId), ); 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, provider: a.provider, email: a.email, displayName: a.displayName, enabled: a.enabled, status: staleIds.includes(a.id) ? 'connected' : a.status, createdAt: a.createdAt, })), ); }); accountsRouter.post('/', async (ctx) => { const user = ctx.get('user'); const body = ctx.get('body') as CreateAccountBody; if (!body.provider || !body.email || !body.imapHost || !body.imapPort || !body.authType) { throw BAD_REQUEST('Missing required fields'); } const authResult = await resolveAuth(user.id, body.authType, body.email, body.credentials); if (!authResult.ok) throw BAD_REQUEST(authResult.error); const validation = await validateImapConnection({ host: body.imapHost, port: body.imapPort, secure: body.imapSecure, user: body.email, ...authResult.auth, }); if (!validation.ok) throw BAD_REQUEST(`IMAP connection failed: ${validation.error}`); const account = await createEmailAccount({ userId: user.id, provider: body.provider, email: body.email, displayName: body.displayName, imapHost: body.imapHost, imapPort: body.imapPort, imapSecure: body.imapSecure, authType: body.authType, credentials: body.credentials, }); return ctx.json({ id: account.id, provider: account.provider, email: account.email }, 201); }); accountsRouter.delete('/:id', async (ctx) => { const user = ctx.get('user'); const id = Number(ctx.req.param('id')); const deleted = await deleteEmailAccount(id, user.id); if (!deleted) throw NOT_FOUND('Account not found'); return ctx.json({ ok: true }); }); accountsRouter.post('/:id/sync', async (ctx) => { const user = ctx.get('user'); const id = Number(ctx.req.param('id')); const account = await getEmailAccount(id); if (!account || account.userId !== user.id) throw NOT_FOUND('Account not found'); if (account.status === 'queued') throw BAD_REQUEST('Sync is already queued'); if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing'); // Determine if this is a first sync or resync // One answer for both providers now. This used to fork: Gmail read SQLite, IMAP read the Postgres // column, which is how the two drifted apart in the first place. let isFirstSync = true; { const db = openEmailDb(user.email, account.email); try { isFirstSync = !hasSyncedBefore(db, account.syncMeta as Record); } finally { db.close(); } } // Resync: call directly, no job queue if (!isFirstSync) { await updateEmailAccountStatus(id, 'syncing'); try { const result = await performResync({ accountId: id, userEmail: user.email, userId: user.id }); await updateEmailAccountStatus(id, 'synced'); return ctx.json({ ok: true, saved: result.saved }); } catch (err) { await updateEmailAccountStatus(id, 'synced').catch(() => {}); throw err; } } // First sync: enqueue a job const authResult = await resolveAuth( user.id, account.authType, account.email, account.credentials as Record, ); if (!authResult.ok) throw BAD_REQUEST(authResult.error); await updateEmailAccountStatus(id, 'queued'); // Gmail API sync needs OAuth; a gmail account with an app password syncs over IMAP instead. const useGmailApi = account.provider === 'gmail' && account.authType === 'oauth'; if (isSyncing(id)) return ctx.json({ ok: true, alreadyRunning: true }); startSync({ accountId: id, // The OWNER's email, not the account's — the store path is keyed by it. ownerEmail: user.email, steps: useGmailApi ? gmailSyncSteps : imapSyncSteps, onDone: (ok) => updateEmailAccountStatus(id, ok ? 'synced' : 'error').then(() => undefined), 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 }, 201); }); accountsRouter.post('/validate', async (ctx) => { const user = ctx.get('user'); const body = ctx.get('body') as ValidateBody; if (!body.imapHost || !body.imapPort || !body.authType || !body.email) { throw BAD_REQUEST('Missing required fields'); } const authResult = await resolveAuth(user.id, body.authType, body.email, body.credentials); if (!authResult.ok) return ctx.json({ ok: false, error: authResult.error }); const result = await validateImapConnection({ host: body.imapHost, port: body.imapPort, secure: body.imapSecure, user: body.email, ...authResult.auth, }); return ctx.json(result); }); type AuthResult = { ok: true; auth: { pass?: string; accessToken?: string } } | { ok: false; error: string }; async function resolveAuth( userId: number, authType: string, email: string, credentials: Record, ): Promise { if (authType === 'oauth') { const integrationId = credentials.userIntegrationId as number | undefined; if (!integrationId) return { ok: false, error: 'Missing userIntegrationId for OAuth' }; let accessToken: string | null; try { accessToken = await getValidGoogleAccessToken(userId); } catch (err) { return { ok: false, error: `Token refresh failed — reconnect Google account: ${err instanceof Error ? err.message : err}`, }; } if (!accessToken) return { ok: false, error: 'No access token available — reconnect Google account' }; return { ok: true, auth: { accessToken } }; } if (authType === 'password') { const pass = credentials.password as string | undefined; if (!pass) return { ok: false, error: 'Missing password' }; return { ok: true, auth: { pass } }; } return { ok: false, error: `Unknown auth type: ${authType}` }; }