// 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 { 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;