interacmanagernew / files /gmailService.ts
MichaelEdou
Fix Gmail scan date range: stop scanning emails from day before start date
d2cc9d2
// 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;