UNPKG

ms365-mcp-server

Version:

Microsoft 365 MCP Server for managing Microsoft 365 email through natural language interactions with full OAuth2 authentication support

1,033 lines (1,032 loc) 45.4 kB
import { logger } from './api.js'; import mimeTypes from 'mime-types'; /** * Microsoft 365 operations manager class */ export class MS365Operations { constructor() { this.graphClient = null; this.searchCache = new Map(); this.CACHE_DURATION = 60 * 1000; // 1 minute cache this.MAX_RETRIES = 3; this.BASE_DELAY = 1000; // 1 second } /** * Execute API call with retry logic and exponential backoff */ async executeWithRetry(operation, context = 'API call') { let lastError; for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { try { return await operation(); } catch (error) { lastError = error; // Don't retry on authentication errors or client errors (4xx) if (error.code === 'InvalidAuthenticationToken' || (error.status >= 400 && error.status < 500)) { throw error; } if (attempt === this.MAX_RETRIES) { logger.error(`${context} failed after ${this.MAX_RETRIES} attempts:`, error); throw error; } // Exponential backoff with jitter const delay = this.BASE_DELAY * Math.pow(2, attempt - 1) + Math.random() * 1000; logger.log(`${context} failed (attempt ${attempt}), retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } throw lastError; } /** * Set the Microsoft Graph client externally */ setGraphClient(client) { this.graphClient = client; } /** * Get authenticated Microsoft Graph client with proactive token refresh */ async getGraphClient() { if (!this.graphClient) { // Import the enhanced auth module dynamically to avoid circular imports const { enhancedMS365Auth } = await import('./ms365-auth-enhanced.js'); // Proactive token check - refresh if expiring within 5 minutes const tokenInfo = await enhancedMS365Auth.getTokenExpirationInfo(); if (tokenInfo.expiresInMinutes < 5) { logger.log(`Token expires in ${tokenInfo.expiresInMinutes} minutes, refreshing proactively...`); await enhancedMS365Auth.refreshTokenIfNeeded(); } this.graphClient = await enhancedMS365Auth.getGraphClient(); } return this.graphClient; } /** * Clear expired cache entries */ clearExpiredCache() { const now = Date.now(); for (const [key, value] of this.searchCache.entries()) { if (now - value.timestamp > this.CACHE_DURATION) { this.searchCache.delete(key); } } } /** * Get cached search results if available and not expired */ getCachedResults(cacheKey) { this.clearExpiredCache(); const cached = this.searchCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) { logger.log(`Cache hit for search: ${cacheKey}`); return cached.results; } return null; } /** * Cache search results */ setCachedResults(cacheKey, results) { this.searchCache.set(cacheKey, { results, timestamp: Date.now() }); logger.log(`Cached results for search: ${cacheKey}`); } /** * Build filter query for Microsoft Graph API */ buildFilterQuery(criteria) { const filters = []; if (criteria.from) { filters.push(`from/emailAddress/address eq '${criteria.from}'`); } // Note: Cannot filter on toRecipients or ccRecipients as they are complex collections // These will be handled by search API or manual filtering if (criteria.subject) { filters.push(`contains(subject,'${criteria.subject}')`); } if (criteria.after) { const afterDate = new Date(criteria.after).toISOString(); filters.push(`receivedDateTime ge ${afterDate}`); } if (criteria.before) { const beforeDate = new Date(criteria.before).toISOString(); filters.push(`receivedDateTime le ${beforeDate}`); } if (criteria.hasAttachment !== undefined) { filters.push(`hasAttachments eq ${criteria.hasAttachment}`); } if (criteria.isUnread !== undefined) { filters.push(`isRead eq ${!criteria.isUnread}`); } if (criteria.importance) { filters.push(`importance eq '${criteria.importance}'`); } return filters.join(' and '); } /** * Build search query for Microsoft Graph API */ buildSearchQuery(criteria) { const searchTerms = []; if (criteria.query) { searchTerms.push(criteria.query); } // Build email-specific search terms using proper Microsoft Graph syntax const emailSearchTerms = []; if (criteria.from) { // Check if it looks like an email address for exact matching if (criteria.from.includes('@')) { emailSearchTerms.push(`from:${criteria.from}`); } else { // For names, use general search which is more flexible with partial matching // This will trigger manual filtering which supports partial names better if (criteria.query) { searchTerms.push(`${criteria.query} AND ${criteria.from}`); } else { searchTerms.push(criteria.from); } } } if (criteria.to) { emailSearchTerms.push(`to:${criteria.to}`); } if (criteria.cc) { emailSearchTerms.push(`cc:${criteria.cc}`); } // Combine email search terms with OR logic if (emailSearchTerms.length > 0) { searchTerms.push(emailSearchTerms.join(' OR ')); } if (criteria.subject) { // For subject searches, use proper syntax based on content const subjectTerm = criteria.subject.trim(); if (subjectTerm.includes(' ') || subjectTerm.includes('"')) { // If contains spaces or quotes, wrap in quotes and escape any internal quotes const escapedSubject = subjectTerm.replace(/"/g, '\\"'); searchTerms.push(`subject:"${escapedSubject}"`); } else { // Simple single word, no quotes needed searchTerms.push(`subject:${subjectTerm}`); } } return searchTerms.join(' AND '); } /** * Send an email */ async sendEmail(message) { try { const graphClient = await this.getGraphClient(); // Prepare recipients const toRecipients = message.to.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })); const ccRecipients = message.cc?.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })) || []; const bccRecipients = message.bcc?.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })) || []; // Prepare attachments const attachments = message.attachments?.map(att => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: att.name, contentBytes: att.contentBytes, contentType: att.contentType || 'application/octet-stream' })) || []; // Prepare email body const emailBody = { subject: message.subject, body: { contentType: message.bodyType === 'html' ? 'html' : 'text', content: message.body || '' }, toRecipients, ccRecipients, bccRecipients, importance: message.importance || 'normal', attachments: attachments.length > 0 ? attachments : undefined }; if (message.replyTo) { emailBody.replyTo = [{ emailAddress: { address: message.replyTo, name: message.replyTo.split('@')[0] } }]; } // Send email const result = await graphClient .api('/me/sendMail') .post({ message: emailBody }); logger.log('Email sent successfully'); return { id: result?.id || 'sent', status: 'sent' }; } catch (error) { logger.error('Error sending email:', error); throw error; } } /** * Save a draft email */ async saveDraftEmail(message) { try { const graphClient = await this.getGraphClient(); // Prepare recipients const toRecipients = message.to.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })); const ccRecipients = message.cc?.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })) || []; const bccRecipients = message.bcc?.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })) || []; // Prepare attachments const attachments = message.attachments?.map(att => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: att.name, contentBytes: att.contentBytes, contentType: att.contentType || 'application/octet-stream' })) || []; // Prepare draft email body const draftBody = { subject: message.subject, body: { contentType: message.bodyType === 'html' ? 'html' : 'text', content: message.body || '' }, toRecipients, ccRecipients, bccRecipients, importance: message.importance || 'normal', attachments: attachments.length > 0 ? attachments : undefined }; if (message.replyTo) { draftBody.replyTo = [{ emailAddress: { address: message.replyTo, name: message.replyTo.split('@')[0] } }]; } // Save draft email const result = await graphClient .api('/me/messages') .post(draftBody); logger.log('Draft email saved successfully'); return { id: result.id, status: 'draft' }; } catch (error) { logger.error('Error saving draft email:', error); throw error; } } /** * Get email by ID */ async getEmail(messageId, includeAttachments = false) { try { const graphClient = await this.getGraphClient(); logger.log('Fetching email details...'); // First get the basic email info with attachments expanded const email = await graphClient .api(`/me/messages/${messageId}`) .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink,body,attachments') .expand('attachments') .get(); logger.log(`Email details retrieved. hasAttachments flag: ${email.hasAttachments}`); logger.log(`Email subject: ${email.subject}`); logger.log(`Direct attachments count: ${email.attachments?.length || 0}`); const emailInfo = { id: email.id, subject: email.subject || '', from: { name: email.from?.emailAddress?.name || '', address: email.from?.emailAddress?.address || '' }, toRecipients: email.toRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], ccRecipients: email.ccRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], receivedDateTime: email.receivedDateTime, sentDateTime: email.sentDateTime, bodyPreview: email.bodyPreview || '', isRead: email.isRead || false, hasAttachments: email.hasAttachments || false, importance: email.importance || 'normal', conversationId: email.conversationId || '', parentFolderId: email.parentFolderId || '', webLink: email.webLink || '', attachments: email.attachments?.map((attachment) => ({ id: attachment.id, name: attachment.name, contentType: attachment.contentType, size: attachment.size, isInline: attachment.isInline, contentId: attachment.contentId })) || [] }; if (email.body) { emailInfo.body = email.body.content || ''; } // Always try to get attachments if requested if (includeAttachments) { logger.log('Attempting to fetch attachments...'); try { // First check if we got attachments from the expanded query if (email.attachments && email.attachments.length > 0) { logger.log(`Found ${email.attachments.length} attachments from expanded query`); emailInfo.attachments = email.attachments.map((attachment) => ({ id: attachment.id, name: attachment.name, contentType: attachment.contentType, size: attachment.size, isInline: attachment.isInline, contentId: attachment.contentId })); emailInfo.hasAttachments = true; } else { // Try getting attachments directly logger.log('Method 1: Direct attachment query...'); const attachments = await graphClient .api(`/me/messages/${messageId}/attachments`) .select('id,name,contentType,size,isInline,contentId') .get(); logger.log(`Method 1 results: Found ${attachments.value?.length || 0} attachments`); if (attachments && attachments.value && attachments.value.length > 0) { emailInfo.attachments = attachments.value.map((attachment) => ({ id: attachment.id, name: attachment.name, contentType: attachment.contentType, size: attachment.size, isInline: attachment.isInline, contentId: attachment.contentId })); emailInfo.hasAttachments = true; logger.log(`Successfully retrieved ${emailInfo.attachments.length} attachments`); } else { logger.log('No attachments found with either method'); emailInfo.attachments = []; emailInfo.hasAttachments = false; } } } catch (attachmentError) { logger.error('Error getting attachments:', attachmentError); logger.error('Error details:', JSON.stringify(attachmentError, null, 2)); emailInfo.attachments = []; emailInfo.hasAttachments = false; } } return emailInfo; } catch (error) { logger.error('Error getting email:', error); logger.error('Error details:', JSON.stringify(error, null, 2)); throw error; } } /** * Search emails with criteria */ async searchEmails(criteria = {}) { return await this.executeWithAuth(async () => { const graphClient = await this.getGraphClient(); // Create cache key from criteria const cacheKey = JSON.stringify(criteria); const cachedResults = this.getCachedResults(cacheKey); if (cachedResults) { return cachedResults; } // For complex searches, use a simpler approach try { // Start with a basic query to get recent emails const apiCall = graphClient.api('/me/messages') .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink') .orderby('receivedDateTime desc') .top(criteria.maxResults || 100); const result = await apiCall.get(); let messages = result.value?.map((email) => ({ id: email.id, subject: email.subject || '', from: { name: email.from?.emailAddress?.name || '', address: email.from?.emailAddress?.address || '' }, toRecipients: email.toRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], ccRecipients: email.ccRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], receivedDateTime: email.receivedDateTime, sentDateTime: email.sentDateTime, bodyPreview: email.bodyPreview || '', isRead: email.isRead || false, hasAttachments: email.hasAttachments || false, importance: email.importance || 'normal', conversationId: email.conversationId || '', parentFolderId: email.parentFolderId || '', webLink: email.webLink || '' })) || []; // Apply manual filtering for all criteria messages = this.applyManualFiltering(messages, criteria); // For emails with attachments, get attachment counts for (const message of messages) { if (message.hasAttachments) { try { const attachments = await graphClient .api(`/me/messages/${message.id}/attachments`) .select('id') .get(); message.attachments = new Array(attachments.value?.length || 0); } catch (error) { logger.error(`Error getting attachment count for message ${message.id}:`, error); message.attachments = []; } } } const searchResult = { messages, hasMore: !!result['@odata.nextLink'] }; this.setCachedResults(cacheKey, searchResult); return searchResult; } catch (error) { logger.error('Error in email search:', error); throw error; } }, 'searchEmails'); } /** * Apply manual filtering to search results (used when $filter can't be used with $search) */ applyManualFiltering(messages, criteria) { return messages.filter(message => { // Apply text search filters manually if (criteria.query) { const searchText = criteria.query.toLowerCase(); const messageText = `${message.subject} ${message.bodyPreview} ${message.from.name} ${message.from.address}`.toLowerCase(); if (!messageText.includes(searchText)) return false; } if (criteria.from) { const searchTerm = criteria.from.toLowerCase().trim(); const fromName = message.from.name.toLowerCase(); const fromAddress = message.from.address.toLowerCase(); // Multiple matching strategies for better partial name support const matches = [ // Direct name or email match fromName.includes(searchTerm), fromAddress.includes(searchTerm), // Split search term and check if all parts exist in name searchTerm.split(/\s+/).every(part => fromName.includes(part)), // Check if any word in the name starts with the search term fromName.split(/\s+/).some(namePart => namePart.startsWith(searchTerm)), // Check if search term matches any word in the name exactly fromName.split(/\s+/).some(namePart => namePart === searchTerm), // Handle "Last, First" format fromName.replace(/,\s*/g, ' ').includes(searchTerm), // Handle initials (e.g., "M Kumar" for "Madan Kumar") searchTerm.split(/\s+/).length === 2 && fromName.split(/\s+/).length >= 2 && fromName.split(/\s+/)[0].startsWith(searchTerm.split(/\s+/)[0][0]) && fromName.includes(searchTerm.split(/\s+/)[1]) ]; if (!matches.some(match => match)) return false; } if (criteria.to) { const toMatch = message.toRecipients.some(recipient => recipient.address.toLowerCase() === criteria.to.toLowerCase()); if (!toMatch) return false; } if (criteria.cc) { // Handle case where ccRecipients might be undefined const ccMatch = message.ccRecipients && message.ccRecipients.length > 0 && message.ccRecipients.some(recipient => recipient.address.toLowerCase() === criteria.cc.toLowerCase()); if (!ccMatch) return false; } if (criteria.subject) { if (!message.subject.toLowerCase().includes(criteria.subject.toLowerCase())) return false; } // Apply date filters if (criteria.after) { const afterDate = new Date(criteria.after); const messageDate = new Date(message.receivedDateTime); if (messageDate < afterDate) return false; } if (criteria.before) { const beforeDate = new Date(criteria.before); const messageDate = new Date(message.receivedDateTime); if (messageDate > beforeDate) return false; } // Apply attachment filter if (criteria.hasAttachment !== undefined && message.hasAttachments !== criteria.hasAttachment) { return false; } // Apply read status filter if (criteria.isUnread !== undefined && message.isRead === criteria.isUnread) { return false; } // Apply importance filter if (criteria.importance && message.importance !== criteria.importance) { return false; } return true; }); } /** * List emails in a folder */ async listEmails(folderId = 'inbox', maxResults = 50) { try { const graphClient = await this.getGraphClient(); const result = await graphClient .api(`/me/mailFolders/${folderId}/messages`) .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink') .orderby('receivedDateTime desc') .top(maxResults) .get(); const messages = result.value?.map((email) => ({ id: email.id, subject: email.subject || '', from: { name: email.from?.emailAddress?.name || '', address: email.from?.emailAddress?.address || '' }, toRecipients: email.toRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], ccRecipients: email.ccRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], receivedDateTime: email.receivedDateTime, sentDateTime: email.sentDateTime, bodyPreview: email.bodyPreview || '', isRead: email.isRead || false, hasAttachments: email.hasAttachments || false, importance: email.importance || 'normal', conversationId: email.conversationId || '', parentFolderId: email.parentFolderId || '', webLink: email.webLink || '' })) || []; return { messages, hasMore: !!result['@odata.nextLink'] }; } catch (error) { logger.error('Error listing emails:', error); throw error; } } /** * Mark email as read or unread */ async markEmail(messageId, isRead) { try { const graphClient = await this.getGraphClient(); await graphClient .api(`/me/messages/${messageId}`) .patch({ isRead: isRead }); logger.log(`Email ${messageId} marked as ${isRead ? 'read' : 'unread'}`); } catch (error) { logger.error('Error marking email:', error); throw error; } } /** * Move email to folder */ async moveEmail(messageId, destinationFolderId) { try { const graphClient = await this.getGraphClient(); await graphClient .api(`/me/messages/${messageId}/move`) .post({ destinationId: destinationFolderId }); logger.log(`Email ${messageId} moved to folder ${destinationFolderId}`); } catch (error) { logger.error('Error moving email:', error); throw error; } } /** * Delete email */ async deleteEmail(messageId) { try { const graphClient = await this.getGraphClient(); await graphClient .api(`/me/messages/${messageId}`) .delete(); logger.log(`Email ${messageId} deleted`); } catch (error) { logger.error('Error deleting email:', error); throw error; } } /** * Get attachment */ async getAttachment(messageId, attachmentId) { try { const graphClient = await this.getGraphClient(); logger.log(`Fetching attachment ${attachmentId} from message ${messageId}...`); // First get attachment metadata const attachment = await graphClient .api(`/me/messages/${messageId}/attachments/${attachmentId}`) .get(); logger.log(`Retrieved attachment metadata: ${attachment.name} (${attachment.contentType}, ${attachment.size} bytes)`); // Determine content type if not provided let contentType = attachment.contentType; if (!contentType && attachment.name) { contentType = mimeTypes.lookup(attachment.name) || 'application/octet-stream'; logger.log(`Inferred content type for ${attachment.name}: ${contentType}`); } // Validate content bytes if (!attachment.contentBytes) { logger.error('No content bytes found in attachment'); throw new Error('Attachment content is empty'); } // Return the attachment data const result = { name: attachment.name || 'unnamed_attachment', contentType: contentType || 'application/octet-stream', contentBytes: attachment.contentBytes, size: attachment.size || 0 }; logger.log(`Successfully processed attachment: ${result.name} (${result.contentType}, ${result.size} bytes)`); return result; } catch (error) { logger.error('Error getting attachment:', error); // Provide more specific error messages if (error.status === 404) { throw new Error(`Attachment not found: ${error.message}`); } else if (error.status === 401) { throw new Error('Authentication failed. Please re-authenticate.'); } else if (error.status === 403) { throw new Error('Permission denied to access this attachment.'); } else { throw new Error(`Failed to get attachment: ${error.message}`); } } } /** * List mail folders */ async listFolders() { try { const graphClient = await this.getGraphClient(); const result = await graphClient .api('/me/mailFolders') .select('id,displayName,totalItemCount,unreadItemCount,parentFolderId') .get(); return result.value?.map((folder) => ({ id: folder.id, displayName: folder.displayName || '', totalItemCount: folder.totalItemCount || 0, unreadItemCount: folder.unreadItemCount || 0, parentFolderId: folder.parentFolderId })) || []; } catch (error) { logger.error('Error listing folders:', error); throw error; } } /** * Get contacts */ async getContacts(maxResults = 100) { try { const graphClient = await this.getGraphClient(); const result = await graphClient .api('/me/contacts') .select('id,displayName,emailAddresses,jobTitle,companyName,department,officeLocation,businessPhones,mobilePhone') .top(maxResults) .get(); return result.value?.map((contact) => ({ id: contact.id, displayName: contact.displayName || '', emailAddresses: contact.emailAddresses?.map((email) => ({ name: email.name, address: email.address })) || [], jobTitle: contact.jobTitle, companyName: contact.companyName, department: contact.department, officeLocation: contact.officeLocation, businessPhones: contact.businessPhones || [], mobilePhone: contact.mobilePhone })) || []; } catch (error) { logger.error('Error getting contacts:', error); throw error; } } /** * Search contacts */ async searchContacts(query, maxResults = 50) { try { const graphClient = await this.getGraphClient(); const result = await graphClient .api('/me/contacts') .select('id,displayName,emailAddresses,jobTitle,companyName,department,officeLocation,businessPhones,mobilePhone') .filter(`contains(displayName,'${query}') or contains(emailAddresses/any(e:e/address),'${query}')`) .top(maxResults) .get(); return result.value?.map((contact) => ({ id: contact.id, displayName: contact.displayName || '', emailAddresses: contact.emailAddresses?.map((email) => ({ name: email.name, address: email.address })) || [], jobTitle: contact.jobTitle, companyName: contact.companyName, department: contact.department, officeLocation: contact.officeLocation, businessPhones: contact.businessPhones || [], mobilePhone: contact.mobilePhone })) || []; } catch (error) { logger.error('Error searching contacts:', error); throw error; } } /** * Get current user's email address */ async getCurrentUserEmail() { try { const graphClient = await this.getGraphClient(); const user = await graphClient .api('/me') .select('mail,userPrincipalName') .get(); return user.mail || user.userPrincipalName || ''; } catch (error) { logger.error('Error getting current user email:', error); throw error; } } /** * Search for emails addressed to the current user (both TO and CC recipients) */ async searchEmailsToMe(additionalCriteria = {}) { try { const userEmail = await this.getCurrentUserEmail(); const graphClient = await this.getGraphClient(); // Create cache key from criteria const cacheKey = JSON.stringify({ ...additionalCriteria, userEmail }); const cachedResults = this.getCachedResults(cacheKey); if (cachedResults) { return cachedResults; } try { // Start with a basic query to get emails const apiCall = graphClient.api('/me/messages') .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink'); // Add search query for user's email in TO or CC using proper syntax apiCall.search(`"${userEmail}"`); // Add simple filters that are supported by the API if (additionalCriteria.after) { apiCall.filter(`receivedDateTime ge ${new Date(additionalCriteria.after).toISOString()}`); } if (additionalCriteria.before) { apiCall.filter(`receivedDateTime le ${new Date(additionalCriteria.before).toISOString()}`); } if (additionalCriteria.isUnread !== undefined) { apiCall.filter(`isRead eq ${!additionalCriteria.isUnread}`); } if (additionalCriteria.importance) { apiCall.filter(`importance eq '${additionalCriteria.importance}'`); } // Set page size const pageSize = Math.min(additionalCriteria.maxResults || 100, 100); apiCall.top(pageSize); const result = await apiCall.get(); const messages = result.value?.map((email) => ({ id: email.id, subject: email.subject || '', from: { name: email.from?.emailAddress?.name || '', address: email.from?.emailAddress?.address || '' }, toRecipients: email.toRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], ccRecipients: email.ccRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], receivedDateTime: email.receivedDateTime, sentDateTime: email.sentDateTime, bodyPreview: email.bodyPreview || '', isRead: email.isRead || false, hasAttachments: email.hasAttachments || false, importance: email.importance || 'normal', conversationId: email.conversationId || '', parentFolderId: email.parentFolderId || '', webLink: email.webLink || '', attachments: [] })) || []; // Filter messages to only include those where the user is in TO or CC let filteredMessages = messages.filter(message => { const isInTo = message.toRecipients.some(recipient => recipient.address.toLowerCase() === userEmail.toLowerCase()); const isInCc = message.ccRecipients.some(recipient => recipient.address.toLowerCase() === userEmail.toLowerCase()); return isInTo || isInCc; }); // Apply hasAttachment filter manually if specified if (additionalCriteria.hasAttachment !== undefined) { filteredMessages = filteredMessages.filter(message => message.hasAttachments === additionalCriteria.hasAttachment); } // Sort messages by receivedDateTime in descending order filteredMessages.sort((a, b) => new Date(b.receivedDateTime).getTime() - new Date(a.receivedDateTime).getTime()); // For emails with attachments, get attachment counts for (const message of filteredMessages) { if (message.hasAttachments) { try { const attachments = await graphClient .api(`/me/messages/${message.id}/attachments`) .select('id') .get(); message.attachments = new Array(attachments.value?.length || 0); } catch (error) { logger.error(`Error getting attachment count for message ${message.id}:`, error); message.attachments = []; } } } const searchResult = { messages: filteredMessages, hasMore: !!result['@odata.nextLink'] }; this.setCachedResults(cacheKey, searchResult); return searchResult; } catch (error) { logger.error('Error in email search:', error); throw error; } } catch (error) { logger.error('Error searching emails addressed to me:', error); throw error; } } /** * Get multiple emails by their IDs efficiently */ async getEmailsByIds(messageIds) { const emails = []; const batchSize = 20; for (let i = 0; i < messageIds.length; i += batchSize) { const batch = messageIds.slice(i, i + batchSize); const batchPromises = batch.map(async (id) => { try { const graphClient = await this.getGraphClient(); const email = await graphClient .api(`/me/messages/${id}`) .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink') .get(); const emailInfo = { id: email.id, subject: email.subject || '', from: { name: email.from?.emailAddress?.name || '', address: email.from?.emailAddress?.address || '' }, toRecipients: email.toRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], ccRecipients: email.ccRecipients?.map((recipient) => ({ name: recipient.emailAddress?.name || '', address: recipient.emailAddress?.address || '' })) || [], receivedDateTime: email.receivedDateTime, sentDateTime: email.sentDateTime, bodyPreview: email.bodyPreview || '', isRead: email.isRead || false, hasAttachments: email.hasAttachments || false, importance: email.importance || 'normal', conversationId: email.conversationId || '', parentFolderId: email.parentFolderId || '', webLink: email.webLink || '', attachments: [] // Initialize empty attachments array }; return emailInfo; } catch (error) { logger.error(`Error getting email ${id}:`, error); return null; } }); const batchResults = await Promise.all(batchPromises); const batchEmails = batchResults.filter((email) => email !== null); emails.push(...batchEmails); } return emails; } /** * Clear cached graph client (used when authentication fails) */ clearGraphClient() { this.graphClient = null; logger.log('Cleared cached graph client due to authentication failure'); } /** * Execute operation with authentication retry */ async executeWithAuth(operation, operationName) { try { return await this.executeWithRetry(operation, operationName); } catch (error) { // Check if it's an authentication error if (error.code === 'InvalidAuthenticationToken' || error.code === 'Unauthorized' || error.status === 401) { logger.log(`Authentication failed for ${operationName}, clearing cache and retrying...`); this.clearGraphClient(); // Retry once with fresh authentication try { return await this.executeWithRetry(operation, `${operationName} (retry after auth failure)`); } catch (retryError) { logger.error(`${operationName} failed even after authentication retry:`, retryError); throw new Error(`Authentication failed. Please re-authenticate using the "authenticate_with_device_code" tool. Details: ${retryError.message}`); } } throw error; } } } export const ms365Operations = new MS365Operations();