Spaces:
Running
Running
| // packages/server/src/services/gmailService.ts | |
| // | |
| // Gmail API integration for fetching Interac e-Transfer emails | |
| // Install: npm install googleapis | |
| import { google, gmail_v1 } from 'googleapis'; | |
| import type { ScanDateRange } from '../../shared/types/scan'; | |
| import database from '../db/database'; | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // OAUTH CLIENT SETUP | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID!; | |
| const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET!; | |
| const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3001/api/auth/google/callback'; | |
| export function createOAuthClient() { | |
| return new google.auth.OAuth2( | |
| GOOGLE_CLIENT_ID, | |
| GOOGLE_CLIENT_SECRET, | |
| GOOGLE_REDIRECT_URI | |
| ); | |
| } | |
| /** Generate the Google OAuth consent URL */ | |
| export function getAuthUrl(): string { | |
| const oauth2Client = createOAuthClient(); | |
| return oauth2Client.generateAuthUrl({ | |
| access_type: 'offline', // Get refresh_token | |
| prompt: 'consent', // Force consent to always get refresh_token | |
| scope: [ | |
| 'https://www.googleapis.com/auth/gmail.readonly', | |
| 'https://www.googleapis.com/auth/userinfo.email', | |
| 'https://www.googleapis.com/auth/userinfo.profile', | |
| ], | |
| }); | |
| } | |
| /** Exchange authorization code for tokens */ | |
| export async function exchangeCode(code: string) { | |
| const oauth2Client = createOAuthClient(); | |
| const { tokens } = await oauth2Client.getToken(code); | |
| oauth2Client.setCredentials(tokens); | |
| // Get user profile info | |
| const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client }); | |
| const { data: profile } = await oauth2.userinfo.get(); | |
| return { | |
| tokens, | |
| profile: { | |
| google_id: profile.id!, | |
| email: profile.email!, | |
| name: profile.name || profile.email!, | |
| avatar_url: profile.picture || null, | |
| }, | |
| }; | |
| } | |
| /** Get an authenticated Gmail client for a specific user */ | |
| export function getGmailClient(userId: string): gmail_v1.Gmail { | |
| const user = database.getUserById(userId); | |
| if (!user) throw new Error(`User ${userId} not found`); | |
| if (!user.access_token) throw new Error('User has no Gmail tokens β please re-authenticate'); | |
| const oauth2Client = createOAuthClient(); | |
| oauth2Client.setCredentials({ | |
| access_token: user.access_token, | |
| refresh_token: user.refresh_token, | |
| expiry_date: user.token_expires ? new Date(user.token_expires).getTime() : undefined, | |
| }); | |
| // Auto-refresh handler: save new tokens when Google refreshes them | |
| oauth2Client.on('tokens', (tokens) => { | |
| console.log('[Gmail] Token refreshed for user', userId); | |
| database.updateUserTokens({ | |
| id: userId, | |
| access_token: tokens.access_token || user.access_token, | |
| refresh_token: tokens.refresh_token || user.refresh_token, | |
| token_expires: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : user.token_expires, | |
| }); | |
| }); | |
| return google.gmail({ version: 'v1', auth: oauth2Client }); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // GMAIL QUERY BUILDER | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Build a Gmail search query for Interac emails within a date range. | |
| * Gmail's after:/before: use YYYY/MM/DD format and are date-only (not datetime). | |
| * after: is inclusive (includes emails from that date onward). | |
| * before: is exclusive β add 1 day to include the end date. | |
| */ | |
| export function buildGmailQuery(dateRange: ScanDateRange): string { | |
| const start = new Date(dateRange.startDate); | |
| const end = new Date(dateRange.endDate); | |
| // after: is already inclusive β use start date as-is | |
| const afterDate = new Date(start); | |
| // Make before: inclusive of end date by adding 1 day | |
| const beforeDate = new Date(end); | |
| beforeDate.setDate(beforeDate.getDate() + 1); | |
| const fmt = (d: Date) => `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; | |
| return `from:notify@payments.interac.ca after:${fmt(afterDate)} before:${fmt(beforeDate)}`; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // EMAIL FETCHING | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Fetch all matching message IDs from Gmail using pagination. | |
| * Returns an array of Gmail message ID strings. | |
| */ | |
| export async function fetchAllMessageIds( | |
| gmail: gmail_v1.Gmail, | |
| query: string | |
| ): Promise<string[]> { | |
| const messageIds: string[] = []; | |
| let pageToken: string | undefined; | |
| console.log(`[Gmail] Searching: "${query}"`); | |
| do { | |
| const response = await gmail.users.messages.list({ | |
| userId: 'me', | |
| q: query, | |
| maxResults: 500, | |
| pageToken, | |
| }); | |
| const messages = response.data.messages || []; | |
| messageIds.push(...messages.map((m) => m.id!)); | |
| pageToken = response.data.nextPageToken || undefined; | |
| console.log(`[Gmail] Found ${messages.length} messages (total: ${messageIds.length})`); | |
| } while (pageToken); | |
| console.log(`[Gmail] Total messages found: ${messageIds.length}`); | |
| return messageIds; | |
| } | |
| /** | |
| * Fetch the full body of a single email by message ID. | |
| * Returns the decoded text/plain or text/html body. | |
| */ | |
| export async function fetchEmailBody( | |
| gmail: gmail_v1.Gmail, | |
| messageId: string | |
| ): Promise<{ body: string; subject: string; internalDate: string }> { | |
| const response = await gmail.users.messages.get({ | |
| userId: 'me', | |
| id: messageId, | |
| format: 'full', | |
| }); | |
| const message = response.data; | |
| const headers = message.payload?.headers || []; | |
| const subject = headers.find((h) => h.name?.toLowerCase() === 'subject')?.value || ''; | |
| const internalDate = message.internalDate || ''; | |
| // Extract body: try text/plain first, then text/html, then multipart | |
| let body = ''; | |
| if (message.payload?.body?.data) { | |
| // Simple single-part message | |
| body = decodeBase64Url(message.payload.body.data); | |
| } else if (message.payload?.parts) { | |
| // Multipart message β look for text/plain or text/html | |
| body = extractBodyFromParts(message.payload.parts); | |
| } | |
| return { body, subject, internalDate }; | |
| } | |
| /** Recursively extract text body from multipart MIME parts */ | |
| function extractBodyFromParts(parts: gmail_v1.Schema$MessagePart[]): string { | |
| // Prefer text/plain for cleaner AI parsing | |
| for (const part of parts) { | |
| if (part.mimeType === 'text/plain' && part.body?.data) { | |
| return decodeBase64Url(part.body.data); | |
| } | |
| } | |
| // Fall back to text/html | |
| for (const part of parts) { | |
| if (part.mimeType === 'text/html' && part.body?.data) { | |
| return decodeBase64Url(part.body.data); | |
| } | |
| } | |
| // Recurse into nested multipart | |
| for (const part of parts) { | |
| if (part.parts) { | |
| const result = extractBodyFromParts(part.parts); | |
| if (result) return result; | |
| } | |
| } | |
| return ''; | |
| } | |
| /** Decode Gmail's base64url-encoded body */ | |
| function decodeBase64Url(data: string): string { | |
| const base64 = data.replace(/-/g, '+').replace(/_/g, '/'); | |
| return Buffer.from(base64, 'base64').toString('utf-8'); | |
| } | |
| export const gmailService = { | |
| createOAuthClient, | |
| getAuthUrl, | |
| exchangeCode, | |
| getGmailClient, | |
| buildGmailQuery, | |
| fetchAllMessageIds, | |
| fetchEmailBody, | |
| }; | |
| export default gmailService; | |