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.

667 lines (666 loc) 25.6 kB
import { gmailAuth } from './gmail-auth.js'; import { logger } from './api.js'; import mimeTypes from 'mime-types'; import { EnhancedSearchManager } from './enhanced-search.js'; import { ResilienceManager } from './error-handling-resilience.js'; /** * Gmail operations manager class */ export class GmailOperations { constructor() { this.gmail = null; this.enhancedSearchManager = new EnhancedSearchManager(80); // Default 80% threshold this.resilienceManager = new ResilienceManager(); } /** * Reset cached Gmail client (called when authentication changes) */ resetClient() { this.gmail = null; } /** * Get system status including resilience metrics */ getSystemStatus() { return this.resilienceManager.getSystemStatus(); } /** * 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(''); const content = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content, attachment.encoding || 'utf8'); 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 */ async getEmail(messageId, format = 'full') { try { const gmail = await this.getGmailClient(); logger.log(`Retrieving email: ${messageId}`); const response = await gmail.users.messages.get({ userId: 'me', id: messageId, format }); return response.data; } 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'}`); } } /** * Enhanced search emails with natural language processing, fuzzy matching, and resilience */ async searchEmails(criteria = {}) { // 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();