@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
JavaScript
/**
* 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