Skip to main content
DelightBridge syncs email threads from Gmail accounts to provide a unified customer support inbox. This guide explains how Gmail OAuth works, the sync strategies used, and how to troubleshoot common issues.

Gmail OAuth Setup

Each service (Gmail account) must be connected through OAuth to enable email syncing.

Required OAuth Scopes

DelightBridge requires the following Gmail API scopes:
  • https://www.googleapis.com/auth/gmail.readonly - Read email messages and threads
  • https://www.googleapis.com/auth/gmail.send - Send email replies
  • https://www.googleapis.com/auth/gmail.modify - Modify email labels (for read/unread status)
These scopes are configured in the Google Cloud Console when setting up OAuth credentials. See OAuth Setup for detailed instructions.

Connecting a Gmail Account

1

Create a Service

Admins create a service in the Settings modal with a name, color, and brand configuration.
2

Connect Gmail

Click Connect Gmail to initiate the OAuth flow. This redirects to Google’s consent screen.
3

Grant Permissions

Authorize DelightBridge to access the Gmail account with the required scopes.
4

OAuth Callback

Google redirects back to /api/services/oauth/callback with an authorization code.
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: body.toString(),
});

const token = await tokenRes.json();
5

Store Tokens

The access token and refresh token are stored in the gmail_accounts table:
await db.update(gmailAccounts).set({
  email: connectedEmail,
  accessToken: token.access_token,
  refreshToken: token.refresh_token,
}).where(eq(gmailAccounts.id, serviceId));
6

Initial Sync

After connection, trigger a full sync to import existing email threads.
The refresh token is only provided on the first OAuth authorization. If you lose it, you must revoke access in Google account settings and reconnect.

Token Management

DelightBridge automatically manages access token refresh to maintain Gmail API access.

Access Token Refresh

The refreshAccessToken() function in gmail.ts:5 handles token refresh:
export async function refreshAccessToken(accountId: string) {
  const [account] = await db
    .select({ refreshToken: gmailAccounts.refreshToken })
    .from(gmailAccounts)
    .where(eq(gmailAccounts.id, accountId));

  if (!account?.refreshToken) {
    throw new Error('Missing refresh token');
  }

  const body = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID,
    client_secret: process.env.GOOGLE_CLIENT_SECRET,
    refresh_token: account.refreshToken,
    grant_type: 'refresh_token',
  });

  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });

  const json = await res.json();
  
  await db.update(gmailAccounts)
    .set({ accessToken: json.access_token })
    .where(eq(gmailAccounts.id, accountId));

  return json.access_token;
}

Automatic Token Refresh

Gmail API requests automatically refresh tokens when they receive a 401 Unauthorized response:
const res = await make(accessToken);

if (res.status === 401 && !forceRefresh) {
  token = await refreshAccessToken(accountId);
  continue;
}
This is implemented in gmail-sync.ts:118 within the gmailRequest() function.

Sync Strategies

DelightBridge uses two sync strategies: full sync and incremental sync.

Full Sync

Full sync fetches the most recent threads from Gmail and is used:
  • On initial connection
  • When incremental sync fails (e.g., history ID expired)
  • When manually triggered
The syncAccountFull() function in gmail-sync.ts:285 implements full sync:
export async function syncAccountFull(accountId: string, maxThreads = 100) {
  const threadIds: string[] = [];
  let pageToken = '';

  // Fetch thread list from Gmail
  while (threadIds.length < maxThreads) {
    const query = new URLSearchParams({ maxResults: '100' });
    if (pageToken) query.set('pageToken', pageToken);
    
    const listRes = await gmailRequest(
      accountId, 
      `/threads?${query.toString()}`
    );
    const listJson = await listRes.json();
    
    const ids = (listJson.threads ?? [])
      .map(thread => thread.id)
      .filter(Boolean);
    threadIds.push(...ids);
    
    pageToken = listJson.nextPageToken ?? '';
    if (!pageToken || ids.length === 0) break;
  }

  // Sync each thread
  for (const gmailThreadId of uniqueThreadIds) {
    await syncThread(accountId, account.email, gmailThreadId);
  }
}
Full sync defaults to 100 threads but can be configured. It’s designed to be efficient for initial setup without overwhelming the database.

Incremental Sync

Incremental sync uses Gmail’s History API to fetch only changes since the last sync. This is the primary sync method for connected accounts. The syncAccountIncremental() function in gmail-sync.ts:329 implements incremental sync:
export async function syncAccountIncremental(accountId: string) {
  const [account] = await db
    .select({
      id: gmailAccounts.id,
      email: gmailAccounts.email,
      refreshToken: gmailAccounts.refreshToken,
      lastHistoryId: gmailAccounts.lastHistoryId,
    })
    .from(gmailAccounts)
    .where(eq(gmailAccounts.id, accountId));

  // Fall back to full sync if no history ID
  if (!account.lastHistoryId) {
    return syncAccountFull(accountId, 50);
  }

  let pageToken = '';
  const threadIdSet = new Set<string>();

  // Fetch history changes
  while (true) {
    const query = new URLSearchParams({
      startHistoryId: account.lastHistoryId,
      historyTypes: 'messageAdded',
      maxResults: '500',
    });
    if (pageToken) query.set('pageToken', pageToken);

    const res = await gmailRequest(
      accountId, 
      `/history?${query.toString()}`
    );
    const json = await res.json();

    // Extract thread IDs from history
    for (const history of json.history ?? []) {
      for (const entry of history.messagesAdded ?? []) {
        const tid = entry.message?.threadId;
        if (tid) threadIdSet.add(tid);
      }
    }

    pageToken = json.nextPageToken ?? '';
    if (!pageToken) break;
  }

  // Sync only changed threads
  for (const gmailThreadId of threadIdSet) {
    await syncThread(accountId, account.email, gmailThreadId);
  }
}
Gmail history IDs expire after about 7 days. If lastHistoryId is too old, the History API returns a 404 error. DelightBridge catches this and automatically performs a full sync:
catch (error) {
  if (error instanceof Error && error.message.includes('(404)')) {
    return syncAccountFull(accountId, 100);
  }
  throw error;
}

History ID Tracking

After each sync, DelightBridge updates the lastHistoryId in the database:
async function setAccountHistory(accountId: string, historyId: string | null) {
  if (!historyId) return;
  await db
    .update(gmailAccounts)
    .set({ lastHistoryId: historyId })
    .where(eq(gmailAccounts.id, accountId));
}
This enables efficient incremental syncing by tracking exactly where the last sync ended.

Thread Syncing

The syncThread() function in gmail-sync.ts:161 handles syncing individual threads:
1

Fetch Thread Data

Request the full thread with all messages:
const threadRes = await gmailRequest(
  accountId, 
  `/threads/${gmailThreadId}?format=full`
);
const threadJson = await threadRes.json();
2

Parse Messages

Extract headers, body content, and metadata from each message:
const fromValue = extractHeader(payload, 'From');
const toValue = extractHeader(payload, 'To');
const subject = extractHeader(payload, 'Subject');
const body = extractHtml(payload);
3

Determine Direction

Classify messages as inbound (from customer) or outbound (from support):
const direction: 'inbound' | 'outbound' =
  normalizeEmail(from.email) === normalizeEmail(accountEmail) 
    ? 'outbound' 
    : 'inbound';
4

Upsert Thread Record

Create or update the thread in email_threads:
await db.insert(emailThreads).values({
  id: threadId,
  accountId,
  gmailThreadId,
  subject: latest.subject || '(no subject)',
  customerEmail: firstInbound.fromEmail,
  customerName: firstInbound.fromName,
  status: nextStatus,
  isRead: !hasUnread,
  lastMessageAt: latest.sentAt,
}).onConflictDoUpdate({
  target: emailThreads.id,
  set: { /* updated fields */ }
});
5

Upsert Email Messages

Store each message in the emails table:
await db.insert(emails).values({
  id: emailId,
  threadId,
  gmailMessageId: message.gmailMessageId,
  fromEmail: message.fromEmail,
  fromName: message.fromName,
  toEmail: message.toEmail,
  body: message.body,
  direction: message.direction,
  sentAt: message.sentAt,
}).onConflictDoUpdate({ /* ... */ });

Automated Sync

DelightBridge runs incremental sync automatically through a cron job.

Cron Endpoint

The /api/cron/sync-gmail route triggers sync for all connected accounts:
export async function GET(req: NextRequest) {
  // Verify cron secret
  const authHeader = req.headers.get('authorization');
  const expectedAuth = `Bearer ${process.env.CRON_SECRET}`;
  if (authHeader !== expectedAuth) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const result = await syncAllConnectedAccountsIncremental();
  return NextResponse.json(result);
}
Set up a cron job in your deployment platform (e.g., Vercel Cron) to call this endpoint every 5-10 minutes with the CRON_SECRET authorization header.

Batch Sync with Error Handling

The syncAccountsByIdsIncremental() function in gmail-sync.ts:422 syncs multiple accounts with backoff on failures:
for (const accountId of accountIds) {
  try {
    const result = await syncAccountIncremental(account.id);
    results.push({
      accountId: account.id,
      ok: true,
      syncedThreads: result.syncedThreads,
      upsertedMessages: result.upsertedMessages,
    });
    consecutiveFailures = 0;
  } catch (error) {
    consecutiveFailures += 1;
    
    // Exponential backoff on consecutive failures
    const backoff = Math.min(
      failureBaseDelayMs * 2 ** (consecutiveFailures - 1),
      failureMaxDelayMs
    );
    await sleep(backoff);
  }
}
This prevents cascading failures if one account has issues.

Retry Logic

The gmailRequest() function implements robust retry logic:
const maxAttempts = 3;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
  const res = await make(token);

  // Auto-refresh on 401
  if (res.status === 401 && !forceRefresh) {
    token = await refreshAccessToken(accountId);
    continue;
  }

  // Retry on rate limit or server errors
  if ((res.status === 429 || res.status >= 500) && attempt < maxAttempts - 1) {
    await sleep(300 * 2 ** attempt);
    continue;
  }

  return res;
}
This handles:
  • Token expiration (401)
  • Rate limiting (429)
  • Temporary server errors (500+)

Troubleshooting

OAuth Connection Issues

The refresh token wasn’t provided or was lost. Revoke access in Google account settings (Security → Third-party apps) and reconnect the Gmail account.
This Gmail account is already connected to another service. Each Gmail account can only be connected once.
OAuth state validation failed. This can happen if:
  • Cookies are disabled
  • The OAuth flow took too long
  • CSRF protection triggered
Try connecting again in a private/incognito window.

Sync Issues

Check the following:
  1. Gmail account is connected (has refresh token)
  2. Cron job is running
  3. No errors in sync endpoint logs
  4. Account hasn’t hit API quota limits
Manually trigger sync with POST /api/services/{id}/sync
Incremental sync only processes new history events. If history ID expired, full sync runs automatically but is limited to 100 threads. Trigger another full sync if needed.
Google limits API requests per day. If you hit the limit:
  • Reduce sync frequency
  • Request quota increase in Google Cloud Console
  • Use selective sync for specific labels (requires code changes)
This indicates persistent API issues. Check:
  • Google Cloud Console for API status
  • OAuth credentials are valid
  • Network connectivity from your server
  • Gmail API is enabled in Cloud Console

Token Issues

The refresh token may be invalid or revoked. Reconnect the Gmail account to get a new refresh token.
If a user revokes access in their Google account, the refresh token becomes invalid. DelightBridge will return “Missing refresh token” errors. Reconnect to restore access.

Build docs developers (and LLMs) love