← back to Norma
app/api/cron/gmail-sync/route.ts
314 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getGmailClientForMailbox, getHeader, decodeBody, decodeHtmlBody } from '@/lib/gmail';
import { verifyCronAuth } from '@/lib/cron-auth';
/**
* GET /api/cron/gmail-sync
* Automated Gmail sync for all active mailboxes.
* Called by system cron every 15 minutes during business hours.
*/
// Rate limit: 200ms delay between individual message fetches to avoid Gmail API throttling
const MESSAGE_FETCH_DELAY_MS = 200;
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function GET(request: NextRequest) {
const auth = verifyCronAuth(request);
if (auth instanceof NextResponse) return auth;
console.log('[gmail-sync] Cron sync starting...');
try {
// Get all active mailboxes that don't need reauth
const mbRes = await query(
`SELECT id, email, sync_cursor, last_synced_at, oauth_tokens, needs_reauth
FROM mailboxes
WHERE is_active = true AND (needs_reauth IS NULL OR needs_reauth = false)
ORDER BY email`,
);
if (mbRes.rows.length === 0) {
console.log('[gmail-sync] No active mailboxes found');
return NextResponse.json({
success: true,
synced: 0,
errors: [],
mailboxes: 0,
details: [],
});
}
const details: Array<{
email: string;
mailboxId: string;
fetched: number;
inserted: number;
skipped: number;
error?: string;
}> = [];
const errors: string[] = [];
let totalSynced = 0;
for (const mailbox of mbRes.rows) {
const logId = await createSyncLog(mailbox.id);
try {
// Check if mailbox has OAuth tokens
if (!mailbox.oauth_tokens?.refresh_token) {
const errMsg = `Mailbox ${mailbox.email}: No OAuth tokens / refresh token`;
console.warn(`[gmail-sync] ${errMsg}`);
errors.push(errMsg);
// Mark needs reauth
await query(
`UPDATE mailboxes SET needs_reauth = true, updated_at = now() WHERE id = $1`,
[mailbox.id],
);
await completeSyncLog(logId, 0, 'error', errMsg);
details.push({
email: mailbox.email,
mailboxId: mailbox.id,
fetched: 0,
inserted: 0,
skipped: 0,
error: errMsg,
});
continue;
}
// Get Gmail client for this specific mailbox
const gmail = await getGmailClientForMailbox(mailbox.id, mailbox.oauth_tokens);
if (!gmail) {
const errMsg = `Mailbox ${mailbox.email}: Failed to create Gmail client`;
console.error(`[gmail-sync] ${errMsg}`);
errors.push(errMsg);
await query(
`UPDATE mailboxes SET needs_reauth = true, updated_at = now() WHERE id = $1`,
[mailbox.id],
);
await completeSyncLog(logId, 0, 'error', errMsg);
details.push({
email: mailbox.email,
mailboxId: mailbox.id,
fetched: 0,
inserted: 0,
skipped: 0,
error: errMsg,
});
continue;
}
// Build Gmail query from cursor
const cursor = mailbox.sync_cursor;
let q = '';
if (cursor) {
q = `after:${cursor.replace(/-/g, '/')}`;
}
const maxResults = 100; // Reasonable limit per cron run
let fetched = 0;
let inserted = 0;
let skipped = 0;
let pageToken: string | undefined;
let newestDate: string | null = null;
let msgErrors = 0;
// Page through messages
do {
const list = await gmail.users.messages.list({
userId: 'me',
maxResults: Math.min(maxResults - fetched, 50),
q: q || undefined,
pageToken,
});
const messageIds = list.data.messages || [];
if (messageIds.length === 0) break;
pageToken = list.data.nextPageToken || undefined;
for (const stub of messageIds) {
if (fetched >= maxResults) break;
fetched++;
try {
// Rate limiting between individual message fetches
if (fetched > 1) {
await sleep(MESSAGE_FETCH_DELAY_MS);
}
const detail = await gmail.users.messages.get({
userId: 'me',
id: stub.id!,
format: 'full',
});
const headers = detail.data.payload?.headers;
const bodyText = decodeBody(detail.data.payload!);
const bodyHtml = decodeHtmlBody(detail.data.payload!);
const dateSent = getHeader(headers, 'Date');
const toRaw = getHeader(headers, 'To');
const ccRaw = getHeader(headers, 'Cc');
// Track newest for cursor
if (detail.data.internalDate && !newestDate) {
const ms = parseInt(detail.data.internalDate, 10);
if (!isNaN(ms)) {
newestDate = new Date(ms).toISOString().split('T')[0];
}
}
const result = await query(
`INSERT INTO gmail_messages
(mailbox_id, gmail_id, thread_id, subject, from_address, to_addresses,
cc_addresses, date_sent, snippet, body_text, body_html, labels, attachments, is_read)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (mailbox_id, gmail_id) DO UPDATE SET
body_text = COALESCE(EXCLUDED.body_text, gmail_messages.body_text),
body_html = COALESCE(EXCLUDED.body_html, gmail_messages.body_html)
RETURNING id`,
[
mailbox.id,
detail.data.id,
detail.data.threadId,
getHeader(headers, 'Subject'),
getHeader(headers, 'From'),
toRaw ? toRaw.split(',').map((a: string) => a.trim()) : [],
ccRaw ? ccRaw.split(',').map((a: string) => a.trim()) : [],
dateSent ? new Date(dateSent) : null,
detail.data.snippet,
bodyText || null,
bodyHtml || null,
detail.data.labelIds || [],
JSON.stringify([]),
!(detail.data.labelIds || []).includes('UNREAD'),
],
);
if (result.rowCount && result.rowCount > 0) {
inserted++;
} else {
skipped++;
}
} catch (err) {
msgErrors++;
console.error(
`[gmail-sync] ${mailbox.email} - Failed message ${stub.id}:`,
(err as Error).message,
);
}
}
if (fetched >= maxResults) break;
} while (pageToken);
// Update cursor and last_synced_at
if (newestDate) {
await query(
`UPDATE mailboxes SET last_synced_at = now(), sync_cursor = $2, updated_at = now(), needs_reauth = false WHERE id = $1`,
[mailbox.id, newestDate],
);
} else {
await query(
`UPDATE mailboxes SET last_synced_at = now(), updated_at = now(), needs_reauth = false WHERE id = $1`,
[mailbox.id],
);
}
totalSynced += inserted;
await completeSyncLog(logId, inserted, 'success', null);
console.log(
`[gmail-sync] ${mailbox.email}: fetched=${fetched} inserted=${inserted} skipped=${skipped} errors=${msgErrors}`,
);
details.push({
email: mailbox.email,
mailboxId: mailbox.id,
fetched,
inserted,
skipped,
});
if (msgErrors > 0) {
errors.push(`${mailbox.email}: ${msgErrors} message-level errors`);
}
} catch (err) {
const errMsg = `${mailbox.email}: ${(err as Error).message}`;
console.error(`[gmail-sync] Mailbox sync failed:`, errMsg);
errors.push(errMsg);
// Check for auth-related errors
const errorMessage = (err as Error).message || '';
if (
errorMessage.includes('invalid_grant') ||
errorMessage.includes('Token has been expired') ||
errorMessage.includes('Token has been revoked') ||
errorMessage.includes('401')
) {
await query(
`UPDATE mailboxes SET needs_reauth = true, updated_at = now() WHERE id = $1`,
[mailbox.id],
);
}
await completeSyncLog(logId, 0, 'error', (err as Error).message);
details.push({
email: mailbox.email,
mailboxId: mailbox.id,
fetched: 0,
inserted: 0,
skipped: 0,
error: (err as Error).message,
});
}
}
console.log(
`[gmail-sync] Cron complete: ${details.length} mailboxes, ${totalSynced} messages synced, ${errors.length} errors`,
);
return NextResponse.json({
success: true,
synced: totalSynced,
errors,
mailboxes: details.length,
details,
});
} catch (err) {
console.error('[gmail-sync] Cron fatal error:', (err as Error).message);
return NextResponse.json(
{ error: `Gmail sync cron failed: ${(err as Error).message}` },
{ status: 500 },
);
}
}
/* ─── Sync Log Helpers ──────────────────────────────────────────────────── */
async function createSyncLog(mailboxId: string): Promise<string> {
const result = await query(
`INSERT INTO gmail_sync_log (mailbox_id, status) VALUES ($1, 'running') RETURNING id`,
[mailboxId],
);
return result.rows[0].id;
}
async function completeSyncLog(
logId: string,
messagesSynced: number,
status: 'success' | 'error',
error: string | null,
): Promise<void> {
await query(
`UPDATE gmail_sync_log
SET completed_at = now(), messages_synced = $2, status = $3, error = $4
WHERE id = $1`,
[logId, messagesSynced, status, error],
);
}