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
Create a Service
Admins create a service in the Settings modal with a name, color, and brand configuration.
Connect Gmail
Click Connect Gmail to initiate the OAuth flow. This redirects to Google’s consent screen.
Grant Permissions
Authorize DelightBridge to access the Gmail account with the required scopes.
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 ();
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 ));
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 );
}
}
Why incremental sync falls back to full sync
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:
Fetch Thread Data
Request the full thread with all messages: const threadRes = await gmailRequest (
accountId ,
`/threads/ ${ gmailThreadId } ?format=full`
);
const threadJson = await threadRes . json ();
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 );
Determine Direction
Classify messages as inbound (from customer) or outbound (from support): const direction : 'inbound' | 'outbound' =
normalizeEmail ( from . email ) === normalizeEmail ( accountEmail )
? 'outbound'
: 'inbound' ;
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 */ }
});
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
Error: missing_refresh_token
The refresh token wasn’t provided or was lost. Revoke access in Google account settings (Security → Third-party apps) and reconnect the Gmail account.
Error: email_already_connected
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:
Gmail account is connected (has refresh token)
Cron job is running
No errors in sync endpoint logs
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)
Error: Gmail request failed after retries
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
Frequent token refresh failures
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.