UNPKG

gmail-mcp-server

Version:

Gmail MCP Server with on-demand authentication for SIYA/Claude Desktop. Complete Gmail integration with multi-user support and OAuth2 security.

912 lines (911 loc) 36.9 kB
import { gmailAuth } from './gmail-auth.js'; import { logger } from './api.js'; import * as fs from 'fs'; import * as path from 'path'; import mimeTypes from 'mime-types'; import { EnhancedSearchManager } from './enhanced-search.js'; import { ResilienceManager } from './error-handling-resilience.js'; import { PerformanceOptimizationManager } from './performance-optimization.js'; /** * Gmail operations manager class */ export class GmailOperations { constructor() { this.gmail = null; this.enhancedSearchManager = new EnhancedSearchManager(80); // Default 80% threshold this.resilienceManager = new ResilienceManager(); this.performanceManager = new PerformanceOptimizationManager({ maxSize: 2000, ttlMs: 1800000 }, // 30 min cache { maxBatchSize: 50, batchTimeoutMs: 3000 }, // Optimized batch settings { maxConnections: 5, idleTimeoutMs: 300000 } // Connection pool ); } /** * Reset cached Gmail client (called when authentication changes) */ resetClient() { this.gmail = null; } /** * Get system status including resilience metrics */ getSystemStatus() { return this.resilienceManager.getSystemStatus(); } /** * Get performance metrics */ getPerformanceMetrics() { return this.performanceManager.getPerformanceMetrics(); } /** * Get authenticated Gmail client */ async getGmailClient() { // Always get fresh client to handle re-authentication with different accounts this.gmail = await gmailAuth.getGmailClient(); return this.gmail; } /** * Encode string to base64url */ encodeBase64Url(str) { return Buffer.from(str) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } /** * Decode base64url string */ decodeBase64Url(str) { // Add padding if needed const padding = 4 - (str.length % 4); const paddedStr = str + '='.repeat(padding % 4); return Buffer.from(paddedStr.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString(); } /** * Create email message in RFC 2822 format */ createRawMessage(message) { const lines = []; // Headers lines.push(`To: ${message.to.join(', ')}`); if (message.cc && message.cc.length > 0) { lines.push(`Cc: ${message.cc.join(', ')}`); } if (message.bcc && message.bcc.length > 0) { lines.push(`Bcc: ${message.bcc.join(', ')}`); } if (message.replyTo) { lines.push(`Reply-To: ${message.replyTo}`); } // Subject with proper encoding for international characters const encodedSubject = message.subject .replace(/[^\x00-\x7F]/g, (match) => `=?UTF-8?B?${Buffer.from(match).toString('base64')}?=`); lines.push(`Subject: ${encodedSubject}`); lines.push('MIME-Version: 1.0'); if (message.attachments && message.attachments.length > 0) { // Multipart with attachments const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); lines.push(''); // Message body part lines.push(`--${boundary}`); if (message.html && message.text) { // Multipart alternative for HTML and text const altBoundary = `alt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); lines.push(''); lines.push(`--${altBoundary}`); lines.push('Content-Type: text/plain; charset=UTF-8'); lines.push('Content-Transfer-Encoding: 8bit'); lines.push(''); lines.push(message.text); lines.push(''); lines.push(`--${altBoundary}`); lines.push('Content-Type: text/html; charset=UTF-8'); lines.push('Content-Transfer-Encoding: 8bit'); lines.push(''); lines.push(message.html); lines.push(''); lines.push(`--${altBoundary}--`); } else { lines.push(`Content-Type: ${message.html ? 'text/html' : 'text/plain'}; charset=UTF-8`); lines.push('Content-Transfer-Encoding: 8bit'); lines.push(''); lines.push(message.html || message.text || ''); lines.push(''); } // Attachments for (const attachment of message.attachments) { lines.push(`--${boundary}`); const contentType = attachment.contentType || mimeTypes.lookup(attachment.filename) || 'application/octet-stream'; lines.push(`Content-Type: ${contentType}`); lines.push('Content-Transfer-Encoding: base64'); lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`); lines.push(''); let content; if (Buffer.isBuffer(attachment.content)) { // Already a Buffer - use directly content = attachment.content; } else if (typeof attachment.content === 'string') { // Check if it's a file path or base64 content if (this.isFilePath(attachment.content)) { // It's a file path - read the file try { content = fs.readFileSync(attachment.content); } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Failed to read attachment file '${attachment.content}': File not found. Please provide the ABSOLUTE/FULL path to the file (e.g., '/Users/username/Documents/${attachment.filename}' or 'C:\\Users\\username\\Documents\\${attachment.filename}'). Relative paths like 'uploads/${attachment.filename}' will not work.`); } else { throw new Error(`Failed to read attachment file '${attachment.content}': ${error}`); } } } else { // It's base64 encoded content - decode it properly try { content = Buffer.from(attachment.content, 'base64'); } catch (error) { throw new Error(`Failed to decode base64 attachment '${attachment.filename}': ${error}`); } } } else { throw new Error(`Invalid attachment content type for '${attachment.filename}'. Expected Buffer, base64 string, or file path.`); } // Convert to base64 for email transmission lines.push(content.toString('base64')); lines.push(''); } lines.push(`--${boundary}--`); } else if (message.html && message.text) { // Multipart alternative for HTML and text const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); lines.push(''); lines.push(`--${boundary}`); lines.push('Content-Type: text/plain; charset=UTF-8'); lines.push('Content-Transfer-Encoding: 8bit'); lines.push(''); lines.push(message.text); lines.push(''); lines.push(`--${boundary}`); lines.push('Content-Type: text/html; charset=UTF-8'); lines.push('Content-Transfer-Encoding: 8bit'); lines.push(''); lines.push(message.html); lines.push(''); lines.push(`--${boundary}--`); } else { // Simple message lines.push(`Content-Type: ${message.html ? 'text/html' : 'text/plain'}; charset=UTF-8`); lines.push('Content-Transfer-Encoding: 8bit'); lines.push(''); lines.push(message.html || message.text || ''); } return lines.join('\r\n'); } /** * Send an email with resilience */ async sendEmail(message) { return this.resilienceManager.executeResilientOperation(async () => { const gmail = await this.getGmailClient(); const rawMessage = this.createRawMessage(message); const encodedMessage = this.encodeBase64Url(rawMessage); logger.log(`Sending email to: ${message.to.join(', ')}`); logger.log(`Subject: ${message.subject}`); const response = await gmail.users.messages.send({ userId: 'me', requestBody: { raw: encodedMessage } }); if (!response.data.id || !response.data.threadId) { throw new Error('Failed to send email: Invalid response from Gmail API'); } logger.log(`Email sent successfully: ${response.data.id}`); return { id: response.data.id, threadId: response.data.threadId }; }, 'send_email', { useCircuitBreaker: true, timeout: 15000, // 15 second timeout for sending customRetryConfig: { maxAttempts: 3, baseDelayMs: 2000 } }); } /** * Get email by ID with caching and batch optimization */ async getEmail(messageId, format = 'full') { try { // Use cached version when possible const cacheKey = `email:${messageId}:${format}`; let cached = await this.performanceManager['cache'].get(cacheKey); if (!cached) { const gmail = await this.getGmailClient(); logger.log(`Retrieving email: ${messageId}`); const response = await gmail.users.messages.get({ userId: 'me', id: messageId, format }); cached = response.data; await this.performanceManager['cache'].set(cacheKey, cached, { compress: true, priority: 'normal' }); } return cached; } catch (error) { logger.error('Error retrieving email:', error); throw new Error(`Failed to retrieve email: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get email attachment */ async getAttachment(messageId, attachmentId) { try { const gmail = await this.getGmailClient(); logger.log(`Retrieving attachment: ${attachmentId} from message: ${messageId}`); const response = await gmail.users.messages.attachments.get({ userId: 'me', messageId, id: attachmentId }); return { data: response.data.data, size: response.data.size }; } catch (error) { logger.error('Error retrieving attachment:', error); throw new Error(`Failed to retrieve attachment: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get email attachment and save to local file system * Converts base64 data to binary file and returns file path */ async getAttachmentToLocal(messageId, attachmentId, customPath) { try { const gmail = await this.getGmailClient(); logger.log(`Downloading attachment to local: ${attachmentId} from message: ${messageId}`); // First, get the message to find available attachments const messageResponse = await gmail.users.messages.get({ userId: 'me', id: messageId, format: 'full' }); // Find all attachments in the message const attachments = this.findAllAttachments(messageResponse.data); if (attachments.length === 0) { throw new Error('No attachments found in this message'); } logger.log(`Found ${attachments.length} attachments in message`); // If attachmentId is "0" or a number, treat it as an index let targetAttachment; if (/^\d+$/.test(attachmentId)) { const index = parseInt(attachmentId); if (index >= attachments.length) { throw new Error(`Attachment index ${index} not found. Message has ${attachments.length} attachments (0-${attachments.length - 1})`); } targetAttachment = attachments[index]; logger.log(`Using attachment at index ${index}: ${targetAttachment.filename}`); } else { // Find attachment by ID targetAttachment = attachments.find(att => att.attachmentId === attachmentId); if (!targetAttachment) { const availableIds = attachments.map(att => att.attachmentId).join(', '); throw new Error(`Attachment ID ${attachmentId} not found. Available IDs: ${availableIds}`); } } // Get attachment data using the correct attachment ID const attachmentResponse = await gmail.users.messages.attachments.get({ userId: 'me', messageId, id: targetAttachment.attachmentId }); // Create downloads directory if it doesn't exist const downloadsDir = customPath || path.resolve(process.cwd(), 'downloads'); if (!fs.existsSync(downloadsDir)) { fs.mkdirSync(downloadsDir, { recursive: true }); } // Generate unique filename with timestamp const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const uniqueFilename = `${timestamp}_${targetAttachment.filename}`; const filePath = path.join(downloadsDir, uniqueFilename); // Decode base64 and save to file const base64Data = attachmentResponse.data.data; const binaryData = Buffer.from(base64Data, 'base64'); fs.writeFileSync(filePath, binaryData); logger.log(`Attachment saved to: ${filePath}`); return { filePath: path.resolve(filePath), filename: targetAttachment.filename, size: attachmentResponse.data.size, contentType: targetAttachment.contentType, fileUrl: `file://${path.resolve(filePath)}` }; } catch (error) { logger.error('Error downloading attachment to local:', error); throw new Error(`Failed to download attachment to local: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Find all attachments in message payload */ findAllAttachments(message) { const attachments = []; const findInParts = (parts) => { for (const part of parts) { if (part.body?.attachmentId && part.body.size && part.body.size > 0) { const filename = this.extractFilename(part) || 'attachment'; const contentType = this.extractContentType(part) || 'application/octet-stream'; attachments.push({ attachmentId: part.body.attachmentId, filename, contentType, size: part.body.size }); } if (part.parts) { findInParts(part.parts); } } }; if (message.payload?.parts) { findInParts(message.payload.parts); } return attachments; } /** * Find attachment part in message payload */ findAttachmentPart(message, attachmentId) { const findInParts = (parts) => { for (const part of parts) { if (part.body?.attachmentId === attachmentId) { return part; } if (part.parts) { const found = findInParts(part.parts); if (found) return found; } } return null; }; if (message.payload?.parts) { return findInParts(message.payload.parts); } return null; } /** * Extract filename from message part headers */ extractFilename(part) { if (!part?.headers) return 'attachment'; const contentDisposition = part.headers.find(h => h.name?.toLowerCase() === 'content-disposition'); if (contentDisposition?.value) { const match = contentDisposition.value.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (match) { return match[1].replace(/['"]/g, ''); } } return 'attachment'; } /** * Extract content type from message part headers */ extractContentType(part) { if (!part?.headers) return 'application/octet-stream'; const contentType = part.headers.find(h => h.name?.toLowerCase() === 'content-type'); return contentType?.value || 'application/octet-stream'; } /** * Check if a string is likely a file path */ isFilePath(str) { // Check for common file path patterns // Unix/Linux/Mac paths starting with / or ./ // Windows paths with drive letters or UNC paths // Relative paths const filePathPatterns = [ /^\/[^\/]/, // Unix absolute path /^\.\//, // Relative path starting with ./ /^\.\.\//, // Relative path starting with ../ /^~\//, // Home directory path /^[A-Za-z]:[\\\/]/, // Windows drive letter /^\\\\/, // Windows UNC path /^[^\/\\]*\.[a-zA-Z0-9]{1,10}$/, // Simple filename with extension /[\/\\]/ // Contains path separators ]; // Also check if it doesn't look like base64 const isLikelyBase64 = /^[A-Za-z0-9+\/]+=*$/.test(str) && str.length % 4 === 0; // If it matches file path patterns and doesn't look like base64, it's probably a file path return filePathPatterns.some(pattern => pattern.test(str)) && !isLikelyBase64; } /** * List all attachments in a message */ async listAttachments(messageId) { try { const gmail = await this.getGmailClient(); logger.log(`Listing attachments for message: ${messageId}`); const messageResponse = await gmail.users.messages.get({ userId: 'me', id: messageId, format: 'full' }); const attachments = this.findAllAttachments(messageResponse.data); return attachments.map((attachment, index) => ({ index, ...attachment })); } catch (error) { logger.error('Error listing attachments:', error); throw new Error(`Failed to list attachments: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Enhanced search emails with natural language processing, fuzzy matching, resilience, and caching */ async searchEmails(criteria = {}) { // Check cache first for search results return await this.performanceManager.getCachedSearchResults(criteria, async () => { // If enhanced search is requested or query looks like natural language if (criteria.useEnhancedSearch || this.isNaturalLanguageQuery(criteria.query || '')) { return this.resilienceManager.executeResilientOperation(() => this.performEnhancedSearch(criteria), 'enhanced_search', { feature: 'enhanced_search', fallback: () => this.performTraditionalSearch(criteria), timeout: 20000, // 20 second timeout for enhanced search customRetryConfig: { maxAttempts: 2, // Fewer retries for search baseDelayMs: 1000 } }); } // Traditional search with basic resilience return this.resilienceManager.executeResilientOperation(() => this.performTraditionalSearch(criteria), 'traditional_search', { timeout: 10000, // 10 second timeout for basic search customRetryConfig: { maxAttempts: 3, baseDelayMs: 1000 } }); }); } /** * Perform enhanced search with natural language processing */ async performEnhancedSearch(criteria) { const gmail = await this.getGmailClient(); // First, get a broader set of emails to search through const baseQuery = this.buildBaseQuery(criteria); logger.log(`Enhanced search - fetching emails with base query: ${baseQuery}`); const response = await gmail.users.messages.list({ userId: 'me', q: baseQuery, maxResults: Math.min(criteria.maxResults || 100, 500) // Get more emails for enhanced search }); if (!response.data.messages) { return { messages: [], enhancedResults: { structuredQuery: { text: criteria.query || '' }, searchResults: [], crossReferences: [] } }; } // Get full message details for enhanced processing const allEmails = await Promise.all(response.data.messages.map(msg => this.getEmail(msg.id, 'metadata'))); // Perform enhanced search const naturalQuery = criteria.query || ''; const enhancedResults = await this.enhancedSearchManager.processNaturalLanguageQuery(naturalQuery, allEmails); // Convert enhanced results back to EmailInfo format const messages = enhancedResults.searchResults.map(result => result.email); // Limit results if specified const limitedMessages = criteria.maxResults ? messages.slice(0, criteria.maxResults) : messages; return { messages: limitedMessages, nextPageToken: response.data.nextPageToken || undefined, enhancedResults: criteria.includeCrossReferences !== false ? enhancedResults : undefined }; } /** * Perform traditional Gmail search */ async performTraditionalSearch(criteria) { const gmail = await this.getGmailClient(); // Build search query const queryParts = []; if (criteria.query) queryParts.push(criteria.query); if (criteria.from) queryParts.push(`from:${criteria.from}`); if (criteria.to) queryParts.push(`to:${criteria.to}`); if (criteria.subject) queryParts.push(`subject:${criteria.subject}`); if (criteria.after) queryParts.push(`after:${criteria.after}`); if (criteria.before) queryParts.push(`before:${criteria.before}`); if (criteria.hasAttachment) queryParts.push('has:attachment'); if (criteria.label) queryParts.push(`label:${criteria.label}`); if (criteria.isUnread) queryParts.push('is:unread'); const query = queryParts.join(' '); logger.log(`Traditional search with query: ${query}`); const response = await gmail.users.messages.list({ userId: 'me', q: query, maxResults: criteria.maxResults || 100 }); if (!response.data.messages) { return { messages: [] }; } // Get full message details const messages = await Promise.all(response.data.messages.map(msg => this.getEmail(msg.id, 'metadata'))); return { messages, nextPageToken: response.data.nextPageToken || undefined }; } /** * Build base query for enhanced search to get a broader set of emails */ buildBaseQuery(criteria) { const queryParts = []; // Include basic filters that are always useful if (criteria.label) queryParts.push(`label:${criteria.label}`); if (criteria.after) queryParts.push(`after:${criteria.after}`); if (criteria.before) queryParts.push(`before:${criteria.before}`); if (criteria.hasAttachment) queryParts.push('has:attachment'); if (criteria.isUnread) queryParts.push('is:unread'); // Don't include the main query here - let enhanced search handle it return queryParts.join(' '); } /** * Detect if a query looks like natural language */ isNaturalLanguageQuery(query) { if (!query) return false; const naturalLanguageIndicators = [ // Time references /\b(few weeks ago|last week|last month|yesterday|today|this week|this month)\b/i, // Action words /\b(find|search|locate|get|retrieve|show me|look for)\b/i, // Context words /\b(about|regarding|concerning|related to|similar to)\b/i, // Entity patterns /\bPAN\s+[A-Z]{5}\d{4}[A-Z]\b/i, /\bDIN\s+[A-Z0-9]+\b/i, // Government references /\b(gov\.in|government|ministry|department|tax|income tax)\b/i ]; return naturalLanguageIndicators.some(pattern => pattern.test(query)); } /** * Mark email as read/unread */ async markEmail(messageId, read) { try { const gmail = await this.getGmailClient(); logger.log(`Marking email ${messageId} as ${read ? 'read' : 'unread'}`); if (read) { await gmail.users.messages.modify({ userId: 'me', id: messageId, requestBody: { removeLabelIds: ['UNREAD'] } }); } else { await gmail.users.messages.modify({ userId: 'me', id: messageId, requestBody: { addLabelIds: ['UNREAD'] } }); } } catch (error) { logger.error('Error marking email:', error); throw new Error(`Failed to mark email: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Move email to label/folder */ async moveToLabel(messageId, labelId, removeLabelIds) { try { const gmail = await this.getGmailClient(); logger.log(`Moving email ${messageId} to label ${labelId}`); await gmail.users.messages.modify({ userId: 'me', id: messageId, requestBody: { addLabelIds: [labelId], removeLabelIds: removeLabelIds || [] } }); } catch (error) { logger.error('Error moving email:', error); throw new Error(`Failed to move email: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Delete email */ async deleteEmail(messageId) { try { const gmail = await this.getGmailClient(); logger.log(`Deleting email: ${messageId}`); await gmail.users.messages.delete({ userId: 'me', id: messageId }); } catch (error) { logger.error('Error deleting email:', error); throw new Error(`Failed to delete email: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Create a draft email */ async createDraft(message) { try { const gmail = await this.getGmailClient(); const rawMessage = this.createRawMessage(message); const encodedMessage = this.encodeBase64Url(rawMessage); logger.log(`Creating draft for: ${message.to.join(', ')}`); logger.log(`Subject: ${message.subject}`); const response = await gmail.users.drafts.create({ userId: 'me', requestBody: { message: { raw: encodedMessage } } }); logger.log(`Draft created successfully: ${response.data.id}`); return { id: response.data.id, message: response.data.message }; } catch (error) { logger.error('Error creating draft:', error); throw new Error(`Failed to create draft: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * List draft emails */ async listDrafts(maxResults = 50) { try { const gmail = await this.getGmailClient(); logger.log('Listing draft emails'); const response = await gmail.users.drafts.list({ userId: 'me', maxResults }); if (!response.data.drafts) { return { drafts: [] }; } // Get full draft details const drafts = await Promise.all(response.data.drafts.map(async (draft) => { const fullDraft = await this.getDraft(draft.id); return fullDraft; })); return { drafts, nextPageToken: response.data.nextPageToken || undefined }; } catch (error) { logger.error('Error listing drafts:', error); throw new Error(`Failed to list drafts: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get draft by ID */ async getDraft(draftId) { try { const gmail = await this.getGmailClient(); logger.log(`Retrieving draft: ${draftId}`); const response = await gmail.users.drafts.get({ userId: 'me', id: draftId }); return { id: response.data.id, message: response.data.message }; } catch (error) { logger.error('Error retrieving draft:', error); throw new Error(`Failed to retrieve draft: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Update draft email */ async updateDraft(draftId, message) { try { const gmail = await this.getGmailClient(); const rawMessage = this.createRawMessage(message); const encodedMessage = this.encodeBase64Url(rawMessage); logger.log(`Updating draft: ${draftId}`); logger.log(`Subject: ${message.subject}`); const response = await gmail.users.drafts.update({ userId: 'me', id: draftId, requestBody: { message: { raw: encodedMessage } } }); logger.log(`Draft updated successfully: ${response.data.id}`); return { id: response.data.id, message: response.data.message }; } catch (error) { logger.error('Error updating draft:', error); throw new Error(`Failed to update draft: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Delete draft email */ async deleteDraft(draftId) { try { const gmail = await this.getGmailClient(); logger.log(`Deleting draft: ${draftId}`); await gmail.users.drafts.delete({ userId: 'me', id: draftId }); logger.log(`Draft deleted successfully: ${draftId}`); } catch (error) { logger.error('Error deleting draft:', error); throw new Error(`Failed to delete draft: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Send draft email */ async sendDraft(draftId) { try { const gmail = await this.getGmailClient(); logger.log(`Sending draft: ${draftId}`); const response = await gmail.users.drafts.send({ userId: 'me', requestBody: { id: draftId } }); logger.log(`Draft sent successfully: ${response.data.id}`); return { id: response.data.id, threadId: response.data.threadId }; } catch (error) { logger.error('Error sending draft:', error); throw new Error(`Failed to send draft: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * List emails in inbox, sent, or custom label */ async listEmails(labelId = 'INBOX', maxResults = 50) { try { const gmail = await this.getGmailClient(); logger.log(`Listing emails in label: ${labelId}`); const response = await gmail.users.messages.list({ userId: 'me', labelIds: [labelId], maxResults }); if (!response.data.messages) { return { messages: [] }; } // Get message details const messages = await Promise.all(response.data.messages.map(msg => this.getEmail(msg.id, 'metadata'))); return { messages, nextPageToken: response.data.nextPageToken || undefined }; } catch (error) { logger.error('Error listing emails:', error); throw new Error(`Failed to list emails: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Extract email content from payload */ extractEmailContent(payload) { const result = { attachments: [] }; const extractParts = (parts) => { for (const part of parts) { if (part.parts) { extractParts(part.parts); } else if (part.body && part.body.data) { const mimeType = part.mimeType; const data = this.decodeBase64Url(part.body.data); if (mimeType === 'text/plain') { result.text = data; } else if (mimeType === 'text/html') { result.html = data; } } // Handle attachments if (part.filename && part.body && (part.body.attachmentId || part.body.data)) { result.attachments.push({ filename: part.filename, mimeType: part.mimeType, size: part.body.size, attachmentId: part.body.attachmentId }); } } }; if (payload.parts) { extractParts(payload.parts); } else if (payload.body && payload.body.data) { const mimeType = payload.mimeType; const data = this.decodeBase64Url(payload.body.data); if (mimeType === 'text/plain') { result.text = data; } else if (mimeType === 'text/html') { result.html = data; } } return result; } } // Export singleton instance export const gmailOperations = new GmailOperations();