UNPKG

uhbarp-gmail-mcp-server

Version:

Gmail MCP Server for managing Gmail through natural language interactions with full OAuth2 authentication support

539 lines (538 loc) 20.1 kB
import { gmailAuth } from './gmail-auth.js'; import { logger } from './api.js'; import mimeTypes from 'mime-types'; /** * Gmail operations manager class */ export class GmailOperations { constructor() { this.gmail = null; } /** * Get authenticated Gmail client */ async getGmailClient() { if (!this.gmail) { 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 */ async sendEmail(message) { try { 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 } }); logger.log(`Email sent successfully: ${response.data.id}`); return { id: response.data.id, threadId: response.data.threadId }; } catch (error) { logger.error('Error sending email:', error); throw new Error(`Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 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'}`); } } /** * Search emails */ async searchEmails(criteria = {}) { try { 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(`Searching emails 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 }; } catch (error) { logger.error('Error searching emails:', error); throw new Error(`Failed to search emails: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 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'}`); } } /** * 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'}`); } } /** * Get all Gmail labels */ async getLabels() { try { const gmail = await this.getGmailClient(); logger.log('Retrieving Gmail labels'); const response = await gmail.users.labels.list({ userId: 'me' }); return response.data.labels || []; } catch (error) { logger.error('Error retrieving labels:', error); throw new Error(`Failed to retrieve labels: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Create a new label */ async createLabel(name, visible = true) { try { const gmail = await this.getGmailClient(); logger.log(`Creating label: ${name}`); const response = await gmail.users.labels.create({ userId: 'me', requestBody: { name, labelListVisibility: visible ? 'labelShow' : 'labelHide', messageListVisibility: 'show' } }); return response.data; } catch (error) { logger.error('Error creating label:', error); throw new Error(`Failed to create label: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Update a label */ async updateLabel(labelId, name, visible) { try { const gmail = await this.getGmailClient(); logger.log(`Updating label: ${labelId}`); const requestBody = {}; if (name) requestBody.name = name; if (visible !== undefined) { requestBody.labelListVisibility = visible ? 'labelShow' : 'labelHide'; } const response = await gmail.users.labels.update({ userId: 'me', id: labelId, requestBody }); return response.data; } catch (error) { logger.error('Error updating label:', error); throw new Error(`Failed to update label: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Delete a label */ async deleteLabel(labelId) { try { const gmail = await this.getGmailClient(); logger.log(`Deleting label: ${labelId}`); await gmail.users.labels.delete({ userId: 'me', id: labelId }); } catch (error) { logger.error('Error deleting label:', error); throw new Error(`Failed to delete label: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Batch operations on multiple emails */ async batchMarkAsRead(messageIds) { try { const gmail = await this.getGmailClient(); logger.log(`Batch marking ${messageIds.length} emails as read`); await gmail.users.messages.batchModify({ userId: 'me', requestBody: { ids: messageIds, removeLabelIds: ['UNREAD'] } }); } catch (error) { logger.error('Error batch marking emails as read:', error); throw new Error(`Failed to batch mark emails as read: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Batch move emails to label */ async batchMoveToLabel(messageIds, labelId, removeLabelIds) { try { const gmail = await this.getGmailClient(); logger.log(`Batch moving ${messageIds.length} emails to label ${labelId}`); await gmail.users.messages.batchModify({ userId: 'me', requestBody: { ids: messageIds, addLabelIds: [labelId], removeLabelIds: removeLabelIds || [] } }); } catch (error) { logger.error('Error batch moving emails:', error); throw new Error(`Failed to batch move emails: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Batch delete emails */ async batchDelete(messageIds) { try { const gmail = await this.getGmailClient(); logger.log(`Batch deleting ${messageIds.length} emails`); await gmail.users.messages.batchDelete({ userId: 'me', requestBody: { ids: messageIds } }); } catch (error) { logger.error('Error batch deleting emails:', error); throw new Error(`Failed to batch delete 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();