UNPKG

@emit-ia/gdrive-mcp-server

Version:

Google Drive MCP Server - Model Context Protocol server for Google Drive and Gmail APIs

518 lines 20.7 kB
/** * Gmail Service for MCP Server * * This service provides Gmail operations using OAuth2 authentication with automatic * token management to prevent the common 6-month expiration issues. It includes * message listing, sending, reading, searching, and profile management. */ import { google } from 'googleapis'; import { config } from './config.js'; import { Logger } from './logger.js'; /** * Gmail service class that handles all Gmail API operations * * Uses OAuth2 authentication (required for personal Gmail access) with automatic * token refresh to maintain long-term connectivity without user intervention. */ export class GmailService { gmail; auth; lastTokenRefresh = new Date(); tokenRefreshInterval = null; requestQueue = []; isProcessingQueue = false; lastRequestTime = 0; requestCount = 0; quotaResetTime = Date.now(); constructor() { // Set up OAuth2 authentication for Gmail access this.initializeAuth(); // Initialize Gmail API client this.gmail = google.gmail({ version: 'v1', auth: this.auth }); // Start automatic token maintenance to prevent expiration this.setupTokenMaintenance(); } /** * Initialize OAuth2 authentication for Gmail * * OAuth2 is required for personal Gmail access (Service Accounts don't work * well with personal Gmail accounts). This sets up the auth client with * broader scopes for full message access and batch processing capabilities. */ initializeAuth() { // Require OAuth2 credentials for Gmail access if (!config.googleClientId || !config.googleClientSecret) { throw new Error("Gmail requires OAuth2 authentication. Please set:\n" + "- GOOGLE_CLIENT_ID\n" + "- GOOGLE_CLIENT_SECRET\n" + "- GOOGLE_REFRESH_TOKEN (optional but recommended)\n\n" + "Service accounts cannot access personal Gmail accounts."); } // Create OAuth2 client with configured credentials and broader scopes this.auth = new google.auth.OAuth2(config.googleClientId, config.googleClientSecret, config.googleRedirectUri); // Set scopes for full Gmail access (not just metadata) this.auth.scopes = [ 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.modify' ]; // Set refresh token if available for automatic token renewal if (config.googleRefreshToken) { this.auth.setCredentials({ refresh_token: config.googleRefreshToken, }); } else { Logger.warn("No refresh token provided for Gmail. " + "You may need to re-authenticate periodically."); } } /** * Set up automatic token maintenance to prevent Gmail token expiration * * This is crucial for long-running applications as Gmail tokens typically * expire after 6 months if not refreshed regularly. We refresh every 30 minutes * to keep the token active and avoid authentication issues. */ setupTokenMaintenance() { if (!config.googleRefreshToken) { Logger.gmail("No refresh token available - token maintenance disabled"); return; } // Refresh token every 30 minutes to keep it active and prevent expiration this.tokenRefreshInterval = setInterval(async () => { try { await this.validateAndRefreshToken(); Logger.gmail("Token refreshed successfully during maintenance"); } catch (error) { Logger.error("Token maintenance failed:", error instanceof Error ? error.message : String(error)); } }, 30 * 60 * 1000); // 30 minutes // Perform initial token validation on startup this.validateAndRefreshToken().catch(error => { Logger.error("Initial token validation failed:", error instanceof Error ? error.message : String(error)); }); } /** * Validate and refresh the Gmail access token * * This method forces a token refresh by requesting a new access token, * which keeps the refresh token active and prevents expiration. */ async validateAndRefreshToken() { if (!config.googleRefreshToken) { throw new Error("No refresh token available for validation"); } try { // Force a token refresh by getting access token info const tokenInfo = await this.auth.getAccessToken(); if (tokenInfo.token) { this.lastTokenRefresh = new Date(); Logger.gmail(`Token validated and refreshed at ${this.lastTokenRefresh.toISOString()}`); } else { throw new Error("Failed to obtain access token"); } } catch (error) { Logger.error("Token validation failed:", error instanceof Error ? error.message : String(error)); throw error; } } /** * Manually refresh the Gmail access token * * This method allows external callers to force a token refresh, * useful for troubleshooting or ensuring fresh credentials. */ async refreshToken() { try { await this.validateAndRefreshToken(); return { success: true, lastRefresh: this.lastTokenRefresh, message: "Token refreshed successfully" }; } catch (error) { return { success: false, lastRefresh: this.lastTokenRefresh, message: error instanceof Error ? error.message : String(error) }; } } /** * Get the current status of the Gmail authentication token * * This provides visibility into token health and maintenance status, * useful for monitoring and troubleshooting authentication issues. */ getTokenStatus() { const minutesSinceRefresh = (Date.now() - this.lastTokenRefresh.getTime()) / (1000 * 60); return { hasRefreshToken: !!config.googleRefreshToken, lastRefresh: this.lastTokenRefresh, minutesSinceRefresh: Math.round(minutesSinceRefresh), maintenanceActive: this.tokenRefreshInterval !== null }; } /** * Clean up resources and stop token maintenance * * This method should be called when the service is no longer needed * to prevent memory leaks from the token refresh interval. */ destroy() { if (this.tokenRefreshInterval) { clearInterval(this.tokenRefreshInterval); this.tokenRefreshInterval = null; Logger.gmail("Token maintenance stopped"); } } /** * Rate limiting helper - ensures we don't exceed Gmail API limits * Gmail limits: 250 quota units per user per second, 1 billion per day * Each message fetch = 5 units, send = 100 units */ async waitForRateLimit(quotaUnits = 5) { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; const timeSinceReset = now - this.quotaResetTime; // Reset quota counter every second if (timeSinceReset >= 1000) { this.requestCount = 0; this.quotaResetTime = now; } // Check if adding this request would exceed quota if (this.requestCount + quotaUnits > 250) { const waitTime = 1000 - timeSinceReset; if (waitTime > 0) { Logger.gmail(`Rate limit reached, waiting ${waitTime}ms`); await new Promise(resolve => setTimeout(resolve, waitTime)); this.requestCount = 0; this.quotaResetTime = Date.now(); } } // Ensure minimum 100ms between requests to be extra safe if (timeSinceLastRequest < 100) { await new Promise(resolve => setTimeout(resolve, 100 - timeSinceLastRequest)); } this.requestCount += quotaUnits; this.lastRequestTime = Date.now(); } /** * List Gmail messages with optional search query and automatic rate limiting * * @param query - Gmail search query (e.g., 'is:unread', 'from:example@gmail.com') * @param maxResults - Maximum number of messages to return * @returns Promise resolving to message list */ async listMessages(query, maxResults = 10) { try { await this.waitForRateLimit(1); // List operation = 1 quota unit const response = await this.gmail.users.messages.list({ userId: 'me', q: query, maxResults, }); return response.data; } catch (error) { Logger.error('Error listing messages:', error); throw error; } } /** * Get a specific Gmail message by ID with rate limiting * * @param messageId - The ID of the message to retrieve * @returns Promise resolving to full message details */ async getMessage(messageId) { try { await this.waitForRateLimit(5); // Message get = 5 quota units const response = await this.gmail.users.messages.get({ userId: 'me', id: messageId, format: 'full', }); return response.data; } catch (error) { Logger.error('Error getting message:', error); throw error; } } /** * Send a Gmail message with rate limiting * * @param to - Recipient email address * @param subject - Email subject line * @param body - Email body content * @param from - Optional sender email (uses authenticated user by default) * @returns Promise resolving to sent message details */ async sendMessage(to, subject, body, from) { try { await this.waitForRateLimit(100); // Send operation = 100 quota units // Construct RFC 2822 email format const email = [ `To: ${to}`, from ? `From: ${from}` : '', `Subject: ${subject}`, '', body ].join('\n'); // Encode email in base64 for Gmail API const base64Email = Buffer.from(email).toString('base64'); const response = await this.gmail.users.messages.send({ userId: 'me', requestBody: { raw: base64Email, }, }); return response.data; } catch (error) { Logger.error('Error sending message:', error); throw error; } } async markAsRead(messageId) { try { await this.waitForRateLimit(5); // Modify operation = 5 quota units const response = await this.gmail.users.messages.modify({ userId: 'me', id: messageId, requestBody: { removeLabelIds: ['UNREAD'], }, }); return response.data; } catch (error) { Logger.error('Error marking message as read:', error); throw error; } } async markAsUnread(messageId) { try { await this.waitForRateLimit(5); // Modify operation = 5 quota units const response = await this.gmail.users.messages.modify({ userId: 'me', id: messageId, requestBody: { addLabelIds: ['UNREAD'], }, }); return response.data; } catch (error) { Logger.error('Error marking message as unread:', error); throw error; } } async getProfile() { try { const response = await this.gmail.users.getProfile({ userId: 'me', }); return response.data; } catch (error) { Logger.error('Error getting profile:', error); throw error; } } /** * Search Gmail messages and return full message details * * @param query - Gmail search query * @param maxResults - Maximum number of messages to return * @returns Promise resolving to array of detailed message objects */ async searchMessages(query, maxResults = 50) { try { const response = await this.gmail.users.messages.list({ userId: 'me', q: query, maxResults, }); if (!response.data.messages) { return []; } // Get full message details for each result const messages = await Promise.all(response.data.messages.map(async (message) => { const fullMessage = await this.getMessage(message.id); return { id: fullMessage.id, threadId: fullMessage.threadId, snippet: fullMessage.snippet, subject: this.getHeaderValue(fullMessage, 'Subject'), from: this.getHeaderValue(fullMessage, 'From'), to: this.getHeaderValue(fullMessage, 'To'), date: this.getHeaderValue(fullMessage, 'Date'), body: this.extractTextFromMessage(fullMessage), }; })); return messages; } catch (error) { Logger.error('Error searching messages:', error); throw error; } } /** * Extract plain text content from a Gmail message * * Gmail messages can have complex structure with multiple parts. * This method finds and decodes the plain text content. * * @param message - The Gmail message object * @returns The extracted plain text content */ extractTextFromMessage(message) { if (!message.payload) return ''; // Check if message has direct body content if (message.payload.body && message.payload.body.data) { return Buffer.from(message.payload.body.data, 'base64').toString(); } // Check message parts for plain text content if (message.payload.parts) { for (const part of message.payload.parts) { if (part.mimeType === 'text/plain' && part.body && part.body.data) { return Buffer.from(part.body.data, 'base64').toString(); } } } return ''; } /** * Extract a specific header value from a Gmail message * * @param message - The Gmail message object * @param headerName - The name of the header to extract (case-insensitive) * @returns The header value or empty string if not found */ getHeaderValue(message, headerName) { if (!message.payload || !message.payload.headers) return ''; const header = message.payload.headers.find((h) => h.name.toLowerCase() === headerName.toLowerCase()); return header ? header.value : ''; } /** * Batch process Gmail messages with intelligent rate limiting * * @param messageIds - Array of message IDs to process * @param batchSize - Number of messages to process in each batch * @returns Promise resolving to processed message details */ async batchProcessMessages(messageIds, batchSize = 20) { const results = []; const totalBatches = Math.ceil(messageIds.length / batchSize); Logger.gmail(`Starting batch processing of ${messageIds.length} messages in ${totalBatches} batches`); for (let i = 0; i < messageIds.length; i += batchSize) { const batch = messageIds.slice(i, i + batchSize); const batchNumber = Math.floor(i / batchSize) + 1; Logger.gmail(`Processing batch ${batchNumber}/${totalBatches} (${batch.length} messages)`); try { const batchResults = await Promise.all(batch.map(async (messageId) => { try { const message = await this.getMessage(messageId); return { id: message.id, threadId: message.threadId, snippet: message.snippet, subject: this.getHeaderValue(message, 'Subject'), from: this.getHeaderValue(message, 'From'), to: this.getHeaderValue(message, 'To'), date: this.getHeaderValue(message, 'Date'), body: this.extractTextFromMessage(message), size: message.sizeEstimate }; } catch (error) { Logger.error(`Failed to process message ${messageId}:`, error); return { id: messageId, error: error instanceof Error ? error.message : String(error) }; } })); results.push(...batchResults); // Progress update const processed = results.length; Logger.gmail(`Processed ${processed}/${messageIds.length} messages (${Math.round(processed / messageIds.length * 100)}%)`); // Delay between batches to be respectful of rate limits if (i + batchSize < messageIds.length) { await new Promise(resolve => setTimeout(resolve, 500)); } } catch (error) { Logger.error(`Batch ${batchNumber} failed:`, error); } } const successful = results.filter(r => !r.error).length; const failed = results.filter(r => r.error).length; Logger.gmail(`Batch processing complete: ${successful} successful, ${failed} failed`); return { results, summary: { total: messageIds.length, successful, failed, processed: results.length } }; } /** * Smart Gmail analysis for large accounts - processes messages efficiently * * @param analysisType - Type of analysis to perform * @param options - Analysis options and parameters * @returns Promise resolving to analysis results */ async smartAnalysis(analysisType, options = {}) { const maxMessages = options.maxMessages || 1000; const daysBack = options.daysBack || 30; Logger.gmail(`Starting smart analysis: ${analysisType} (max: ${maxMessages} messages, ${daysBack} days back)`); let query = ''; switch (analysisType) { case 'recent': const afterDate = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000); query = `after:${afterDate.getFullYear()}/${afterDate.getMonth() + 1}/${afterDate.getDate()}`; break; case 'unread': query = 'is:unread'; break; case 'important': query = 'is:important'; break; case 'senders': query = options.query || ''; break; } try { // Get message list with query const messageList = await this.listMessages(query, Math.min(maxMessages, 500)); if (!messageList.messages || messageList.messages.length === 0) { return { analysis: analysisType, results: [], summary: { total: 0 } }; } const messageIds = messageList.messages.map((msg) => msg.id); Logger.gmail(`Found ${messageIds.length} messages for analysis`); // Process in smart batches based on account size const batchSize = messageIds.length > 100 ? 15 : 25; const processedData = await this.batchProcessMessages(messageIds, batchSize); return { analysis: analysisType, query, ...processedData }; } catch (error) { Logger.error(`Smart analysis failed for ${analysisType}:`, error); throw error; } } } //# sourceMappingURL=gmail.js.map