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
JavaScript
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
// 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
// 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
// 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
};