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
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 = 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();