UNPKG

ms365-mcp-server

Version:

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

1,070 lines (1,068 loc) 177 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 = 300 * 1000; // 5 minute cache for better performance 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 outlook auth module dynamically to avoid circular imports const { outlookAuth } = await import('./outlook-auth.js'); // Get graph client (handles token refresh automatically) this.graphClient = await outlookAuth.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}`); } /** * Utility method to properly escape OData filter values */ escapeODataValue(value) { // Escape single quotes by doubling them return value.replace(/'/g, "''"); } /** * Utility method to validate and format date for OData filters * Microsoft Graph expects DateTimeOffset without quotes in OData filters */ formatDateForOData(dateString) { try { const date = new Date(dateString); if (isNaN(date.getTime())) { throw new Error(`Invalid date: ${dateString}`); } // Remove milliseconds and format for OData DateTimeOffset (no quotes needed) return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); } catch (error) { logger.error(`Error formatting date ${dateString}:`, error); throw new Error(`Invalid date format: ${dateString}. Use YYYY-MM-DD format.`); } } /** * Build OData filter query (STRICT - only filterable fields) * CRITICAL: Cannot mix with $search operations per Graph API limitations */ buildFilterQuery(criteria) { const filters = []; // ✅ SAFE: from/emailAddress fields are filterable if (criteria.from && criteria.from.includes('@')) { // Only exact email matches in filters - names require $search const escapedFrom = this.escapeODataValue(criteria.from); filters.push(`from/emailAddress/address eq '${escapedFrom}'`); } // NOTE: toRecipients filtering is not supported by Microsoft Graph OData API // Manual filtering will be applied after retrieving results if (criteria.cc && criteria.cc.includes('@')) { const escapedCc = this.escapeODataValue(criteria.cc); filters.push(`ccRecipients/any(c: c/emailAddress/address eq '${escapedCc}')`); } // ✅ SAFE: Date filters with proper DateTimeOffset format (no quotes) if (criteria.after) { const afterDate = this.formatDateForOData(criteria.after); filters.push(`receivedDateTime ge ${afterDate}`); } if (criteria.before) { const beforeDate = this.formatDateForOData(criteria.before); filters.push(`receivedDateTime le ${beforeDate}`); } // ✅ SAFE: Boolean filters 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}'`); } // ❌ REMOVED: subject filtering - must use $search for subject content // ❌ REMOVED: from name filtering - must use $search for partial names return filters.join(' and '); } /** * Build search query for Microsoft Graph API (STRICT - only searchable fields) * CRITICAL: Cannot mix with $filter operations per Graph API limitations * $search ONLY works on: subject, body, from (not to, cc, categories, etc.) */ buildSearchQuery(criteria) { const searchTerms = []; // ✅ SAFE: General text search (searches subject, body, from automatically) if (criteria.query) { // Escape quotes and use proper search syntax const escapedQuery = criteria.query.replace(/"/g, '\\"'); if (escapedQuery.includes(' ')) { searchTerms.push(`"${escapedQuery}"`); } else { searchTerms.push(escapedQuery); } } // ✅ SAFE: Subject search (explicit field supported by $search) if (criteria.subject) { const escapedSubject = criteria.subject.replace(/"/g, '\\"'); if (escapedSubject.includes(' ')) { searchTerms.push(`subject:"${escapedSubject}"`); } else { searchTerms.push(`subject:${escapedSubject}`); } } // ✅ SAFE: Enhanced From search with smart email handling if (criteria.from) { const escapedFrom = criteria.from.replace(/"/g, '\\"'); if (criteria.from.includes('@')) { // ENHANCED: Smart email search - prioritize local part for better fuzzy matching const emailParts = criteria.from.split('@'); const localPart = emailParts[0]; // Use local part (username) for fuzzy matching - more reliable than exact email if (localPart && localPart.length > 2) { searchTerms.push(`from:${localPart}`); } else { // Fallback to exact email if local part is too short searchTerms.push(`from:${escapedFrom}`); } } else { // Name search - use quotes for multi-word names if (escapedFrom.includes(' ')) { searchTerms.push(`from:"${escapedFrom}"`); } else { searchTerms.push(`from:${escapedFrom}`); } } } // ❌ REMOVED: to/cc searches - NOT supported by $search // These will be handled by manual filtering after retrieval 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 }; // Handle threading - set conversationId for replies/forwards if (message.conversationId) { draftBody.conversationId = message.conversationId; } // Handle in-reply-to for proper threading if (message.inReplyTo) { draftBody.internetMessageHeaders = [ { name: 'In-Reply-To', value: message.inReplyTo } ]; } 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; } } /** * Update a draft email */ async updateDraftEmail(draftId, updates) { try { const graphClient = await this.getGraphClient(); // Prepare update payload const updateBody = {}; if (updates.subject) { updateBody.subject = updates.subject; } if (updates.body) { updateBody.body = { contentType: updates.bodyType === 'html' ? 'html' : 'text', content: updates.body }; } if (updates.to) { updateBody.toRecipients = updates.to.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })); } if (updates.cc) { updateBody.ccRecipients = updates.cc.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })); } if (updates.bcc) { updateBody.bccRecipients = updates.bcc.map(email => ({ emailAddress: { address: email, name: email.split('@')[0] } })); } if (updates.importance) { updateBody.importance = updates.importance; } if (updates.replyTo) { updateBody.replyTo = [{ emailAddress: { address: updates.replyTo, name: updates.replyTo.split('@')[0] } }]; } // Update attachments if provided if (updates.attachments) { updateBody.attachments = updates.attachments.map(att => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: att.name, contentBytes: att.contentBytes, contentType: att.contentType || 'application/octet-stream' })); } // Update the draft const result = await graphClient .api(`/me/messages/${draftId}`) .patch(updateBody); logger.log(`Draft email ${draftId} updated successfully`); return { id: result.id || draftId, status: 'draft_updated' }; } catch (error) { logger.error(`Error updating draft email ${draftId}:`, error); throw error; } } /** * Send a draft email */ async sendDraftEmail(draftId) { try { const graphClient = await this.getGraphClient(); // Send the draft await graphClient .api(`/me/messages/${draftId}/send`) .post({}); logger.log(`Draft email ${draftId} sent successfully`); return { id: draftId, status: 'sent' }; } catch (error) { logger.error(`Error sending draft email ${draftId}:`, error); throw error; } } /** * Verify draft threading by checking if draft appears in conversation */ async verifyDraftThreading(draftId, originalConversationId) { try { const graphClient = await this.getGraphClient(); // Get the draft details const draft = await graphClient .api(`/me/messages/${draftId}`) .select('id,subject,conversationId,parentFolderId,internetMessageHeaders,isDraft') .get(); // Check if conversation IDs match const conversationMatch = draft.conversationId === originalConversationId; // Get all messages in the conversation to see if draft appears let conversationMessages = []; try { const convResult = await graphClient .api('/me/messages') .filter(`conversationId eq '${originalConversationId}'`) .select('id,subject,isDraft,parentFolderId') .get(); conversationMessages = convResult.value || []; } catch (convError) { logger.log(`Could not fetch conversation messages: ${convError}`); } const draftInConversation = conversationMessages.some((msg) => msg.id === draftId); return { isThreaded: conversationMatch && draftInConversation, details: { draftConversationId: draft.conversationId, originalConversationId, conversationMatch, draftInConversation, draftFolder: draft.parentFolderId, conversationMessagesCount: conversationMessages.length, internetMessageHeaders: draft.internetMessageHeaders } }; } catch (error) { logger.error('Error verifying draft threading:', error); return { isThreaded: false, details: { error: String(error) } }; } } /** * List draft emails */ async listDrafts(maxResults = 50) { try { const graphClient = await this.getGraphClient(); const result = await graphClient .api('/me/mailFolders/drafts/messages') .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink,isDraft') .orderby('createdDateTime 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 || '', attachments: [] })) || []; return { messages, hasMore: !!result['@odata.nextLink'] }; } catch (error) { logger.error('Error listing draft emails:', error); throw error; } } /** * Create a threaded reply draft from a specific message */ async createReplyDraft(originalMessageId, body, replyToAll = false, bodyType = 'text') { try { const graphClient = await this.getGraphClient(); logger.log(`Creating reply draft for message: ${originalMessageId}`); logger.log(`Reply to all: ${replyToAll}`); // First, get the original message to include its content in the reply const originalMessage = await graphClient .api(`/me/messages/${originalMessageId}`) .select('id,subject,from,toRecipients,ccRecipients,conversationId,parentFolderId,internetMessageId,internetMessageHeaders,conversationIndex,body,sentDateTime') .get(); // Build the complete reply body with original content const originalBodyContent = originalMessage.body?.content || ''; const fromDisplay = originalMessage.from?.emailAddress?.name || originalMessage.from?.emailAddress?.address || ''; const sentDate = new Date(originalMessage.sentDateTime).toLocaleString(); // Helper function to escape HTML characters const escapeHtml = (text) => { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); }; // Helper function to convert text to HTML const textToHtml = (text) => { return escapeHtml(text).replace(/\n/g, '<br>'); }; // Process the user's body based on the specified type let processedUserBody = body || ''; if (bodyType === 'text' && processedUserBody) { processedUserBody = textToHtml(processedUserBody); } const completeReplyBody = `${processedUserBody} <br><br> <div style="border-left: 2px solid #ccc; padding-left: 10px; margin-top: 10px;"> <p><strong>From:</strong> ${fromDisplay}<br> <strong>Sent:</strong> ${sentDate}<br> <strong>Subject:</strong> ${originalMessage.subject}</p> <hr style="border: none; border-top: 1px solid #ccc; margin: 10px 0;"> ${originalBodyContent} </div>`; // First, try using the official Microsoft Graph createReply endpoint for proper threading try { const endpoint = replyToAll ? `/me/messages/${originalMessageId}/createReplyAll` : `/me/messages/${originalMessageId}/createReply`; logger.log(`Using official Graph API endpoint: ${endpoint}`); const replyDraft = await graphClient .api(endpoint) .post({ message: { body: { contentType: 'html', content: completeReplyBody } } }); logger.log(`Reply draft created successfully with ID: ${replyDraft.id}`); logger.log(`Draft conversation ID: ${replyDraft.conversationId}`); logger.log(`Draft appears as threaded reply in conversation with original content`); return { id: replyDraft.id, subject: replyDraft.subject, conversationId: replyDraft.conversationId, toRecipients: replyDraft.toRecipients?.map((r) => r.emailAddress.address), ccRecipients: replyDraft.ccRecipients?.map((r) => r.emailAddress.address), bodyPreview: replyDraft.bodyPreview, isDraft: replyDraft.isDraft, parentFolderId: replyDraft.parentFolderId }; } catch (officialApiError) { logger.log(`Official API failed: ${officialApiError}, trying fallback approach`); logger.log(`Fallback: Creating manual reply draft with enhanced threading`); logger.log(`Original message conversation ID: ${originalMessage.conversationId}`); logger.log(`Original message folder: ${originalMessage.parentFolderId}`); // Build proper References header from existing chain let referencesHeader = originalMessage.internetMessageId || originalMessage.id; if (originalMessage.internetMessageHeaders) { const existingReferences = originalMessage.internetMessageHeaders.find((h) => h.name.toLowerCase() === 'references'); if (existingReferences && existingReferences.value) { referencesHeader = `${existingReferences.value} ${referencesHeader}`; } } const currentUserEmail = await this.getCurrentUserEmail(); const draftBody = { subject: originalMessage.subject?.startsWith('Re:') ? originalMessage.subject : `Re: ${originalMessage.subject}`, body: { contentType: 'html', content: completeReplyBody }, conversationId: originalMessage.conversationId, internetMessageHeaders: [ { name: 'X-In-Reply-To', value: originalMessage.internetMessageId || originalMessage.id }, { name: 'X-References', value: referencesHeader }, { name: 'X-Thread-Topic', value: originalMessage.subject?.replace(/^Re:\s*/i, '') || '' } ] }; // Include conversation index if available for proper Outlook threading if (originalMessage.conversationIndex) { draftBody.internetMessageHeaders.push({ name: 'X-Thread-Index', value: originalMessage.conversationIndex }); } // Set recipients based on reply type if (replyToAll) { draftBody.toRecipients = [ ...(originalMessage.from ? [{ emailAddress: originalMessage.from.emailAddress }] : []), ...(originalMessage.toRecipients || []).filter((r) => r.emailAddress.address !== currentUserEmail) ]; draftBody.ccRecipients = originalMessage.ccRecipients || []; } else { draftBody.toRecipients = originalMessage.from ? [{ emailAddress: originalMessage.from.emailAddress }] : []; } // Create the fallback draft const replyDraft = await graphClient .api('/me/messages') .post(draftBody); logger.log(`Fallback reply draft created with ID: ${replyDraft.id}`); logger.log(`Draft conversation ID: ${replyDraft.conversationId}`); // Try to move the draft to the same folder as the original message for better threading if (originalMessage.parentFolderId && originalMessage.parentFolderId !== 'drafts') { try { await graphClient .api(`/me/messages/${replyDraft.id}/move`) .post({ destinationId: originalMessage.parentFolderId }); logger.log(`Draft moved to original message folder: ${originalMessage.parentFolderId}`); } catch (moveError) { logger.log(`Could not move draft to original folder: ${moveError}`); // This is not critical, draft will remain in drafts folder } } return { id: replyDraft.id, subject: replyDraft.subject, conversationId: replyDraft.conversationId, toRecipients: replyDraft.toRecipients?.map((r) => r.emailAddress.address), ccRecipients: replyDraft.ccRecipients?.map((r) => r.emailAddress.address), bodyPreview: replyDraft.bodyPreview, isDraft: replyDraft.isDraft, parentFolderId: originalMessage.parentFolderId }; } } catch (error) { throw new Error(`Error creating reply draft: ${error}`); } } /** * Create a threaded forward draft from a specific message */ async createForwardDraft(originalMessageId, comment, bodyType = 'text') { try { const graphClient = await this.getGraphClient(); logger.log(`Creating forward draft for message: ${originalMessageId}`); // First, try using the official Microsoft Graph createForward endpoint for proper threading try { logger.log(`Using official Graph API endpoint: /me/messages/${originalMessageId}/createForward`); // Helper function to escape HTML characters const escapeHtml = (text) => { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); }; // Helper function to convert text to HTML const textToHtml = (text) => { return escapeHtml(text).replace(/\n/g, '<br>'); }; // Process the comment based on the specified type let processedComment = comment || ''; if (bodyType === 'text' && processedComment) { processedComment = textToHtml(processedComment); } const forwardDraft = await graphClient .api(`/me/messages/${originalMessageId}/createForward`) .post({ message: { body: { contentType: 'html', content: processedComment } } }); logger.log(`Forward draft created successfully with ID: ${forwardDraft.id}`); logger.log(`Draft conversation ID: ${forwardDraft.conversationId}`); logger.log(`Draft appears as threaded forward in conversation`); return { id: forwardDraft.id, subject: forwardDraft.subject, conversationId: forwardDraft.conversationId, bodyPreview: forwardDraft.bodyPreview, isDraft: forwardDraft.isDraft, parentFolderId: forwardDraft.parentFolderId }; } catch (officialApiError) { logger.log(`Official API failed: ${officialApiError}, trying fallback approach`); // Fallback to manual creation if the official endpoint fails const originalMessage = await graphClient .api(`/me/messages/${originalMessageId}`) .select('id,subject,from,toRecipients,ccRecipients,conversationId,parentFolderId,internetMessageId,internetMessageHeaders,conversationIndex,body,sentDateTime') .get(); logger.log(`Fallback: Creating manual forward draft with enhanced threading`); logger.log(`Original message conversation ID: ${originalMessage.conversationId}`); logger.log(`Original message folder: ${originalMessage.parentFolderId}`); // Build proper References header from existing chain let referencesHeader = originalMessage.internetMessageId || originalMessage.id; if (originalMessage.internetMessageHeaders) { const existingReferences = originalMessage.internetMessageHeaders.find((h) => h.name.toLowerCase() === 'references'); if (existingReferences && existingReferences.value) { referencesHeader = `${existingReferences.value} ${referencesHeader}`; } } // Helper function to escape HTML characters for fallback const escapeHtml = (text) => { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); }; // Helper function to convert text to HTML for fallback const textToHtml = (text) => { return escapeHtml(text).replace(/\n/g, '<br>'); }; // Process the comment based on the specified type for fallback let processedComment = comment || ''; if (bodyType === 'text' && processedComment) { processedComment = textToHtml(processedComment); } const forwardedBody = `${processedComment ? processedComment + '<br><br>' : ''}---------- Forwarded message ----------<br>From: ${originalMessage.from?.emailAddress?.name || originalMessage.from?.emailAddress?.address}<br>Date: ${originalMessage.sentDateTime}<br>Subject: ${originalMessage.subject}<br>To: ${originalMessage.toRecipients?.map((r) => r.emailAddress.address).join(', ')}<br><br>${originalMessage.body?.content || ''}`; const draftBody = { subject: originalMessage.subject?.startsWith('Fwd:') ? originalMessage.subject : `Fwd: ${originalMessage.subject}`, body: { contentType: 'html', content: forwardedBody }, conversationId: originalMessage.conversationId, internetMessageHeaders: [ { name: 'X-References', value: referencesHeader }, { name: 'X-Thread-Topic', value: originalMessage.subject?.replace(/^(Re:|Fwd?):\s*/i, '') || '' } ] }; // Include conversation index if available for proper Outlook threading if (originalMessage.conversationIndex) { draftBody.internetMessageHeaders.push({ name: 'X-Thread-Index', value: originalMessage.conversationIndex }); } // Create the fallback draft const forwardDraft = await graphClient .api('/me/messages') .post(draftBody); logger.log(`Fallback forward draft created with ID: ${forwardDraft.id}`); logger.log(`Draft conversation ID: ${forwardDraft.conversationId}`); // Try to move the draft to the same folder as the original message for better threading if (originalMessage.parentFolderId && originalMessage.parentFolderId !== 'drafts') { try { await graphClient .api(`/me/messages/${forwardDraft.id}/move`) .post({ destinationId: originalMessage.parentFolderId }); logger.log(`Draft moved to original message folder: ${originalMessage.parentFolderId}`); } catch (moveError) { logger.log(`Could not move draft to original folder: ${moveError}`); // This is not critical, draft will remain in drafts folder } } return { id: forwardDraft.id, subject: forwardDraft.subject, conversationId: forwardDraft.conversationId, bodyPreview: forwardDraft.bodyPreview, isDraft: forwardDraft.isDraft, parentFolderId: originalMessage.parentFolderId }; } } catch (error) { throw new Error(`Error creating forward draft: ${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; } } /** * GRAPH API COMPLIANT SEARCH - Respects Microsoft's strict limitations * Strategy: Use EITHER $filter OR $search, never both */ async searchEmails(criteria = {}) { return await this.executeWithAuth(async () => { logger.log(`🔍 GRAPH API COMPLIANT SEARCH with criteria:`, JSON.stringify(criteria, null, 2)); // Create cache key from criteria const cacheKey = JSON.stringify(criteria); const cachedResults = this.getCachedResults(cacheKey); if (cachedResults) { logger.log('📦 Returning cached results'); return cachedResults; } const maxResults = criteria.maxResults || 50; let allMessages = []; // STRATEGY 1: Use $filter for structured queries (exact matches, dates, booleans) // ENHANCED: Treat 'from' fields as searchable for better reliability const hasFilterableFields = !!((criteria.to && criteria.to.includes('@')) || (criteria.cc && criteria.cc.includes('@')) || criteria.after || criteria.before || criteria.hasAttachment !== undefined || criteria.isUnread !== undefined || criteria.importance); // STRATEGY 2: Use $search for text searches (subject, body content, all from searches) // ENHANCED: Always use search for 'from' field for better fuzzy matching const hasSearchableFields = !!(criteria.query || criteria.subject || criteria.from // Always use search for from field (both names and emails) ); try { // STRATEGY 0: FOLDER SEARCH - Handle folder searches with optimized methods if (criteria.folder) { logger.log('🔍 Using OPTIMIZED FOLDER SEARCH strategy'); allMessages = await this.performOptimizedFolderSearch(criteria, maxResults); } // STRATEGY A: Pure Filter Strategy (when no search fields present) else if (hasFilterableFields && !hasSearchableFields) { logger.log('🔍 Using PURE FILTER strategy (structured queries only)'); allMessages = await this.performPureFilterSearch(criteria, maxResults); } // STRATEGY B: Pure Search Strategy (when no filter fields present) else if (hasSearchableFields && !hasFilterableFields) { logger.log('🔍 Using PURE SEARCH strategy (text search only)'); allMessages = await this.performPureSearchQuery(criteria, maxResults); } // STRATEGY C: Hybrid Strategy (filter first, then search within results) else if (hasFilterableFields && hasSearchableFields) { logger.log('🔍 Using HYBRID strategy (filter first, then search within results)'); allMessages = await this.performHybridFilterThenSearch(criteria, maxResults); } // STRATEGY D: Fallback to basic list with manual filtering else { logger.log('🔍 Using FALLBACK strategy (basic list with manual filtering)'); const basicResult = await this.performBasicSearch(criteria); allMessages = basicResult.messages; } // Apply manual filtering for unsupported fields (to/cc names, complex logic) // Note: Skip manual filtering for folder searches as they handle it internally const filteredMessages = criteria.folder ? allMessages : this.applyManualFiltering(allMessages, criteria); // Sort by relevance and date const sortedMessages = this.sortSearchResults(filteredMessages, criteria); // Apply maxResults limit const finalMessages = sortedMessages.slice(0, maxResults); // ENHANCED: Smart fallback for empty results with 'from' field if (finalMessages.length === 0 && criteria.from && criteria.from.includes('@')) { logger.log('🔄 No results found with exact search, trying fuzzy fallback...'); // Extract local part for fuzzy search const localPart = criteria.from.split('@')[0]; if (localPart && localPart.length > 2) { const fallbackCriteria = { ...criteria, from: localPart }; const fallbackResult = await this.performPureSearchQuery(fallbackCriteria, maxResults); if (fallbackResult.length > 0) { logger.log(`🎯 Fuzzy fallback found ${fallbackResult.length} results`); const searchResult = { messages: fallbackResult.slice(0, maxResults), hasMore: fallbackResult.length > maxResults }; this.setCachedResults(cacheKey, searchResult); return searchResult; } } } const searchResult = { messages: finalMessages, hasMore: sortedMessages.length > maxResults };