ms365-mcp-server 1.1.16 → 1.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,47 +116,36 @@ export class MS365Operations {
116
116
  }
117
117
  }
118
118
  /**
119
- * Build filter query for Microsoft Graph API
119
+ * Build OData filter query (STRICT - only filterable fields)
120
+ * CRITICAL: Cannot mix with $search operations per Graph API limitations
120
121
  */
121
122
  buildFilterQuery(criteria) {
122
123
  const filters = [];
123
- if (criteria.from) {
124
- // Properly escape single quotes in email addresses and names
124
+ // ✅ SAFE: from/emailAddress fields are filterable
125
+ if (criteria.from && criteria.from.includes('@')) {
126
+ // Only exact email matches in filters - names require $search
125
127
  const escapedFrom = this.escapeODataValue(criteria.from);
126
- if (criteria.from.includes('@')) {
127
- // For email addresses, use exact match
128
- filters.push(`from/emailAddress/address eq '${escapedFrom}'`);
129
- }
130
- else {
131
- // For names, use contains for partial matching
132
- filters.push(`contains(from/emailAddress/name,'${escapedFrom}')`);
133
- }
128
+ filters.push(`from/emailAddress/address eq '${escapedFrom}'`);
134
129
  }
130
+ // ✅ SAFE: toRecipients/ccRecipients with proper any() syntax
135
131
  if (criteria.to && criteria.to.includes('@')) {
136
- // Properly escape single quotes in email addresses
137
132
  const escapedTo = this.escapeODataValue(criteria.to);
138
133
  filters.push(`toRecipients/any(r: r/emailAddress/address eq '${escapedTo}')`);
139
134
  }
140
135
  if (criteria.cc && criteria.cc.includes('@')) {
141
- // Properly escape single quotes in email addresses
142
136
  const escapedCc = this.escapeODataValue(criteria.cc);
143
137
  filters.push(`ccRecipients/any(r: r/emailAddress/address eq '${escapedCc}')`);
144
138
  }
145
- if (criteria.subject) {
146
- // Properly escape single quotes in subject
147
- const escapedSubject = this.escapeODataValue(criteria.subject);
148
- filters.push(`contains(subject,'${escapedSubject}')`);
149
- }
139
+ // SAFE: Date filters with proper ISO 8601 format
150
140
  if (criteria.after) {
151
- // Fix date formatting - use proper ISO format with quotes
152
141
  const afterDate = this.formatDateForOData(criteria.after);
153
142
  filters.push(`receivedDateTime ge '${afterDate}'`);
154
143
  }
155
144
  if (criteria.before) {
156
- // Fix date formatting - use proper ISO format with quotes
157
145
  const beforeDate = this.formatDateForOData(criteria.before);
158
146
  filters.push(`receivedDateTime le '${beforeDate}'`);
159
147
  }
148
+ // ✅ SAFE: Boolean filters
160
149
  if (criteria.hasAttachment !== undefined) {
161
150
  filters.push(`hasAttachments eq ${criteria.hasAttachment}`);
162
151
  }
@@ -166,57 +155,57 @@ export class MS365Operations {
166
155
  if (criteria.importance) {
167
156
  filters.push(`importance eq '${criteria.importance}'`);
168
157
  }
158
+ // ❌ REMOVED: subject filtering - must use $search for subject content
159
+ // ❌ REMOVED: from name filtering - must use $search for partial names
169
160
  return filters.join(' and ');
170
161
  }
171
162
  /**
172
- * Build search query for Microsoft Graph API
163
+ * Build search query for Microsoft Graph API (STRICT - only searchable fields)
164
+ * CRITICAL: Cannot mix with $filter operations per Graph API limitations
165
+ * $search ONLY works on: subject, body, from (not to, cc, categories, etc.)
173
166
  */
174
167
  buildSearchQuery(criteria) {
175
168
  const searchTerms = [];
169
+ // ✅ SAFE: General text search (searches subject, body, from automatically)
176
170
  if (criteria.query) {
177
- searchTerms.push(criteria.query);
178
- }
179
- // Build email-specific search terms using proper Microsoft Graph syntax
180
- const emailSearchTerms = [];
181
- if (criteria.from) {
182
- // Check if it looks like an email address for exact matching
183
- if (criteria.from.includes('@')) {
184
- emailSearchTerms.push(`from:${criteria.from}`);
171
+ // Escape quotes and use proper search syntax
172
+ const escapedQuery = criteria.query.replace(/"/g, '\\"');
173
+ if (escapedQuery.includes(' ')) {
174
+ searchTerms.push(`"${escapedQuery}"`);
185
175
  }
186
176
  else {
187
- // For names, use general search which is more flexible with partial matching
188
- // This will trigger manual filtering which supports partial names better
189
- if (criteria.query) {
190
- searchTerms.push(`${criteria.query} AND ${criteria.from}`);
191
- }
192
- else {
193
- searchTerms.push(criteria.from);
194
- }
177
+ searchTerms.push(escapedQuery);
195
178
  }
196
179
  }
197
- if (criteria.to) {
198
- emailSearchTerms.push(`to:${criteria.to}`);
199
- }
200
- if (criteria.cc) {
201
- emailSearchTerms.push(`cc:${criteria.cc}`);
202
- }
203
- // Combine email search terms with OR logic
204
- if (emailSearchTerms.length > 0) {
205
- searchTerms.push(emailSearchTerms.join(' OR '));
206
- }
180
+ // ✅ SAFE: Subject search (explicit field supported by $search)
207
181
  if (criteria.subject) {
208
- // For subject searches, use proper syntax based on content
209
- const subjectTerm = criteria.subject.trim();
210
- if (subjectTerm.includes(' ') || subjectTerm.includes('"')) {
211
- // If contains spaces or quotes, wrap in quotes and escape any internal quotes
212
- const escapedSubject = subjectTerm.replace(/"/g, '\\"');
182
+ const escapedSubject = criteria.subject.replace(/"/g, '\\"');
183
+ if (escapedSubject.includes(' ')) {
213
184
  searchTerms.push(`subject:"${escapedSubject}"`);
214
185
  }
215
186
  else {
216
- // Simple single word, no quotes needed
217
- searchTerms.push(`subject:${subjectTerm}`);
187
+ searchTerms.push(`subject:${escapedSubject}`);
218
188
  }
219
189
  }
190
+ // ✅ SAFE: From search (supported by $search for names and emails)
191
+ if (criteria.from) {
192
+ const escapedFrom = criteria.from.replace(/"/g, '\\"');
193
+ if (criteria.from.includes('@')) {
194
+ // Exact email search
195
+ searchTerms.push(`from:${escapedFrom}`);
196
+ }
197
+ else {
198
+ // Name search - use quotes for multi-word names
199
+ if (escapedFrom.includes(' ')) {
200
+ searchTerms.push(`from:"${escapedFrom}"`);
201
+ }
202
+ else {
203
+ searchTerms.push(`from:${escapedFrom}`);
204
+ }
205
+ }
206
+ }
207
+ // ❌ REMOVED: to/cc searches - NOT supported by $search
208
+ // These will be handled by manual filtering after retrieval
220
209
  return searchTerms.join(' AND ');
221
210
  }
222
211
  /**
@@ -988,12 +977,12 @@ ${originalBodyContent}
988
977
  }
989
978
  }
990
979
  /**
991
- * Advanced email search using Microsoft Graph Search API with KQL - Returns ALL matching results
980
+ * GRAPH API COMPLIANT SEARCH - Respects Microsoft's strict limitations
981
+ * Strategy: Use EITHER $filter OR $search, never both
992
982
  */
993
983
  async searchEmails(criteria = {}) {
994
984
  return await this.executeWithAuth(async () => {
995
- const graphClient = await this.getGraphClient();
996
- logger.log(`🔍 Starting email search with criteria:`, JSON.stringify(criteria, null, 2));
985
+ logger.log(`🔍 GRAPH API COMPLIANT SEARCH with criteria:`, JSON.stringify(criteria, null, 2));
997
986
  // Create cache key from criteria
998
987
  const cacheKey = JSON.stringify(criteria);
999
988
  const cachedResults = this.getCachedResults(cacheKey);
@@ -1001,99 +990,214 @@ ${originalBodyContent}
1001
990
  logger.log('📦 Returning cached results');
1002
991
  return cachedResults;
1003
992
  }
1004
- // If maxResults is specified, use it; otherwise get ALL results (with safety limit)
1005
- const maxResults = criteria.maxResults || 0; // 0 means no limit
1006
- const safetyLimit = 1000; // Maximum results to return even when maxResults is 0
1007
- const effectiveMaxResults = maxResults > 0 ? maxResults : safetyLimit;
993
+ const maxResults = criteria.maxResults || 50;
1008
994
  let allMessages = [];
1009
- let searchAttempts = 0;
1010
- const maxAttempts = 3; // Reduced from 6 to 3 for faster performance
1011
- // Add timeout mechanism (2 minutes max - much faster)
1012
- const searchTimeout = 2 * 60 * 1000; // 2 minutes
1013
- const startTime = Date.now();
1014
- logger.log(`🔍 Starting FAST persistent search with criteria: ${JSON.stringify(criteria)}`);
1015
- logger.log(`🔍 Max results requested: ${maxResults === 0 ? 'ALL (limited to ' + safetyLimit + ' for safety)' : maxResults}`);
1016
- logger.log(`🔍 Search timeout: ${searchTimeout / 1000} seconds`);
995
+ // STRATEGY 1: Use $filter for structured queries (exact matches, dates, booleans)
996
+ const hasFilterableFields = !!((criteria.from && criteria.from.includes('@')) ||
997
+ (criteria.to && criteria.to.includes('@')) ||
998
+ (criteria.cc && criteria.cc.includes('@')) ||
999
+ criteria.after ||
1000
+ criteria.before ||
1001
+ criteria.hasAttachment !== undefined ||
1002
+ criteria.isUnread !== undefined ||
1003
+ criteria.importance);
1004
+ // STRATEGY 2: Use $search for text searches (subject, body content, from names)
1005
+ const hasSearchableFields = !!(criteria.query ||
1006
+ criteria.subject ||
1007
+ (criteria.from && !criteria.from.includes('@')) // name searches
1008
+ );
1017
1009
  try {
1018
- // Strategy 1: Use FAST OData filter search (most reliable and fastest)
1019
- if (searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1020
- searchAttempts++;
1021
- logger.log(`🔍 Attempt ${searchAttempts}: Using FAST OData filter search`);
1022
- const filterResults = await this.performFilteredSearchFast(criteria);
1023
- allMessages.push(...filterResults);
1024
- if (allMessages.length > 0) {
1025
- logger.log(`✅ Found ${allMessages.length} results with FAST OData filter search`);
1026
- }
1027
- }
1028
- // Strategy 2: Use FAST basic search fallback
1029
- if (allMessages.length === 0 && searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1030
- searchAttempts++;
1031
- logger.log(`🔍 Attempt ${searchAttempts}: Using FAST basic search fallback`);
1032
- const basicResult = await this.performBasicSearchFast(criteria);
1033
- allMessages.push(...basicResult.messages);
1034
- if (allMessages.length > 0) {
1035
- logger.log(`✅ Found ${allMessages.length} results with FAST basic search`);
1036
- }
1037
- }
1038
- // Strategy 3: Quick partial search if still no results
1039
- if (allMessages.length === 0 && searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1040
- searchAttempts++;
1041
- logger.log(`🔍 Attempt ${searchAttempts}: Using quick partial search`);
1042
- const partialResults = await this.performPartialTextSearch(criteria, criteria.maxResults || 50);
1043
- allMessages.push(...partialResults);
1044
- if (allMessages.length > 0) {
1045
- logger.log(`✅ Found ${allMessages.length} results with quick partial search`);
1046
- }
1047
- }
1048
- // Check if we hit the timeout
1049
- if ((Date.now() - startTime) >= searchTimeout) {
1050
- logger.log(`⚠️ Search timeout reached after ${(Date.now() - startTime) / 1000} seconds. Returning partial results.`);
1010
+ // STRATEGY A: Pure Filter Strategy (when no search fields present)
1011
+ if (hasFilterableFields && !hasSearchableFields) {
1012
+ logger.log('🔍 Using PURE FILTER strategy (structured queries only)');
1013
+ allMessages = await this.performPureFilterSearch(criteria, maxResults);
1014
+ }
1015
+ // STRATEGY B: Pure Search Strategy (when no filter fields present)
1016
+ else if (hasSearchableFields && !hasFilterableFields) {
1017
+ logger.log('🔍 Using PURE SEARCH strategy (text search only)');
1018
+ allMessages = await this.performPureSearchQuery(criteria, maxResults);
1019
+ }
1020
+ // STRATEGY C: Hybrid Strategy (filter first, then search within results)
1021
+ else if (hasFilterableFields && hasSearchableFields) {
1022
+ logger.log('🔍 Using HYBRID strategy (filter first, then search within results)');
1023
+ allMessages = await this.performHybridFilterThenSearch(criteria, maxResults);
1024
+ }
1025
+ // STRATEGY D: Fallback to basic list with manual filtering
1026
+ else {
1027
+ logger.log('🔍 Using FALLBACK strategy (basic list with manual filtering)');
1028
+ const basicResult = await this.performBasicSearch(criteria);
1029
+ allMessages = basicResult.messages;
1051
1030
  }
1052
- // Remove duplicates and apply advanced filtering
1053
- const uniqueMessages = this.removeDuplicateMessages(allMessages);
1054
- logger.log(`📧 After deduplication: ${uniqueMessages.length} unique messages`);
1055
- const filteredMessages = this.applyAdvancedFiltering(uniqueMessages, criteria);
1056
- logger.log(`📧 After advanced filtering: ${filteredMessages.length} filtered messages`);
1031
+ // Apply manual filtering for unsupported fields (to/cc names, complex logic)
1032
+ const filteredMessages = this.applyManualFiltering(allMessages, criteria);
1057
1033
  // Sort by relevance and date
1058
1034
  const sortedMessages = this.sortSearchResults(filteredMessages, criteria);
1059
- // If still no results, try one more time with very relaxed criteria
1060
- if (sortedMessages.length === 0 && (criteria.query || criteria.from || criteria.subject) && (Date.now() - startTime) < searchTimeout) {
1061
- logger.log(`🔍 Final attempt: Ultra-relaxed search for any partial matches across ALL emails`);
1062
- const ultraRelaxedResults = await this.performUltraRelaxedSearchAll(criteria);
1063
- const finalFiltered = this.applyUltraRelaxedFiltering(ultraRelaxedResults, criteria);
1064
- if (finalFiltered.length > 0) {
1065
- logger.log(`✅ Ultra-relaxed search found ${finalFiltered.length} results`);
1066
- // Apply maxResults limit if specified
1067
- const finalMessages = maxResults > 0 ? finalFiltered.slice(0, maxResults) : finalFiltered.slice(0, safetyLimit);
1068
- const searchResult = {
1069
- messages: finalMessages,
1070
- hasMore: maxResults > 0 ? finalFiltered.length > maxResults : finalFiltered.length > safetyLimit
1071
- };
1072
- this.setCachedResults(cacheKey, searchResult);
1073
- return searchResult;
1074
- }
1075
- }
1076
- // Apply maxResults limit if specified, otherwise return all results (with safety limit)
1077
- const finalMessages = maxResults > 0 ? sortedMessages.slice(0, maxResults) : sortedMessages.slice(0, safetyLimit);
1035
+ // Apply maxResults limit
1036
+ const finalMessages = sortedMessages.slice(0, maxResults);
1078
1037
  const searchResult = {
1079
1038
  messages: finalMessages,
1080
- hasMore: maxResults > 0 ? sortedMessages.length > maxResults : sortedMessages.length > safetyLimit
1039
+ hasMore: sortedMessages.length > maxResults
1081
1040
  };
1082
1041
  this.setCachedResults(cacheKey, searchResult);
1083
- logger.log(`🔍 Search completed after ${searchAttempts} attempts: ${finalMessages.length} final results (${maxResults > 0 ? 'limited' : 'safety-limited'}) in ${(Date.now() - startTime) / 1000} seconds`);
1084
- if (finalMessages.length === 0) {
1085
- logger.log(`❌ No results found despite ${searchAttempts} search strategies. Consider broadening search criteria.`);
1086
- }
1042
+ logger.log(`🔍 COMPLIANT SEARCH completed: ${finalMessages.length} results`);
1087
1043
  return searchResult;
1088
1044
  }
1089
1045
  catch (error) {
1090
- logger.error('Error in persistent email search:', error);
1091
- // Final fallback - get some recent emails and filter them
1092
- logger.log('🔄 Final fallback: getting recent emails to filter manually');
1093
- return await this.performBasicSearchAll(criteria);
1046
+ logger.error('Error in compliant email search:', error);
1047
+ // Final fallback
1048
+ return await this.performBasicSearch(criteria);
1094
1049
  }
1095
1050
  }, 'searchEmails');
1096
1051
  }
1052
+ /**
1053
+ * PURE FILTER SEARCH - Only uses $filter, never $search
1054
+ * Use for: exact email matches, dates, booleans, importance
1055
+ */
1056
+ async performPureFilterSearch(criteria, maxResults) {
1057
+ try {
1058
+ const graphClient = await this.getGraphClient();
1059
+ const filterString = this.buildFilterQuery(criteria);
1060
+ if (!filterString) {
1061
+ logger.log('⚠️ No valid filters for pure filter search');
1062
+ return [];
1063
+ }
1064
+ logger.log(`🔍 Pure Filter Query: ${filterString}`);
1065
+ let apiCall = graphClient
1066
+ .api('/me/messages')
1067
+ .filter(filterString)
1068
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
1069
+ .orderby('receivedDateTime desc')
1070
+ .top(Math.min(maxResults, 999));
1071
+ const result = await apiCall.get();
1072
+ const messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
1073
+ logger.log(`📧 Pure Filter Search returned ${messages.length} results`);
1074
+ return messages;
1075
+ }
1076
+ catch (error) {
1077
+ logger.log(`❌ Pure Filter Search failed: ${error}`);
1078
+ return [];
1079
+ }
1080
+ }
1081
+ /**
1082
+ * PURE SEARCH QUERY - Only uses $search, never $filter
1083
+ * Use for: text searches in subject, body, from names
1084
+ */
1085
+ async performPureSearchQuery(criteria, maxResults) {
1086
+ try {
1087
+ const graphClient = await this.getGraphClient();
1088
+ const searchString = this.buildSearchQuery(criteria);
1089
+ if (!searchString) {
1090
+ logger.log('⚠️ No valid search terms for pure search query');
1091
+ return [];
1092
+ }
1093
+ logger.log(`🔍 Pure Search Query: ${searchString}`);
1094
+ // Use Graph Search API for pure text searches
1095
+ const searchRequest = {
1096
+ requests: [
1097
+ {
1098
+ entityTypes: ['message'],
1099
+ query: {
1100
+ queryString: searchString
1101
+ },
1102
+ from: 0,
1103
+ size: Math.min(maxResults, 1000),
1104
+ fields: ['id', 'subject', 'from', 'toRecipients', 'ccRecipients', 'receivedDateTime', 'sentDateTime', 'bodyPreview', 'isRead', 'hasAttachments', 'importance', 'conversationId', 'parentFolderId', 'webLink']
1105
+ }
1106
+ ]
1107
+ };
1108
+ const result = await graphClient
1109
+ .api('/search/query')
1110
+ .post(searchRequest);
1111
+ const messages = [];
1112
+ if (result.value?.[0]?.hitsContainers?.[0]?.hits) {
1113
+ for (const hit of result.value[0].hitsContainers[0].hits) {
1114
+ messages.push(this.mapEmailResult(hit.resource));
1115
+ }
1116
+ }
1117
+ logger.log(`📧 Pure Search Query returned ${messages.length} results`);
1118
+ return messages;
1119
+ }
1120
+ catch (error) {
1121
+ logger.log(`❌ Pure Search Query failed: ${error}`);
1122
+ return [];
1123
+ }
1124
+ }
1125
+ /**
1126
+ * HYBRID STRATEGY - Filter first, then search within results
1127
+ * Step 1: Use $filter to narrow down results structurally
1128
+ * Step 2: Apply manual text filtering to remaining results
1129
+ */
1130
+ async performHybridFilterThenSearch(criteria, maxResults) {
1131
+ try {
1132
+ // Step 1: Create filter-only criteria
1133
+ const filterCriteria = {
1134
+ from: criteria.from?.includes('@') ? criteria.from : undefined,
1135
+ to: criteria.to?.includes('@') ? criteria.to : undefined,
1136
+ cc: criteria.cc?.includes('@') ? criteria.cc : undefined,
1137
+ after: criteria.after,
1138
+ before: criteria.before,
1139
+ hasAttachment: criteria.hasAttachment,
1140
+ isUnread: criteria.isUnread,
1141
+ importance: criteria.importance,
1142
+ maxResults: maxResults * 3 // Get more results for subsequent filtering
1143
+ };
1144
+ // Get filtered results
1145
+ const filteredResults = await this.performPureFilterSearch(filterCriteria, maxResults * 3);
1146
+ if (filteredResults.length === 0) {
1147
+ logger.log('🔍 Hybrid: No results from filter step, trying pure search');
1148
+ return await this.performPureSearchQuery(criteria, maxResults);
1149
+ }
1150
+ logger.log(`🔍 Hybrid: Filter step returned ${filteredResults.length} results, now applying search`);
1151
+ // Step 2: Apply search criteria manually to filtered results
1152
+ const searchCriteria = {
1153
+ query: criteria.query,
1154
+ subject: criteria.subject,
1155
+ from: criteria.from?.includes('@') ? undefined : criteria.from, // Only names for search
1156
+ };
1157
+ const finalResults = this.applyManualSearchFiltering(filteredResults, searchCriteria);
1158
+ logger.log(`🔍 Hybrid: Final results after search filtering: ${finalResults.length}`);
1159
+ return finalResults.slice(0, maxResults);
1160
+ }
1161
+ catch (error) {
1162
+ logger.log(`❌ Hybrid search failed: ${error}`);
1163
+ return [];
1164
+ }
1165
+ }
1166
+ /**
1167
+ * Apply manual search filtering for text-based criteria
1168
+ * Used in hybrid strategy to search within already filtered results
1169
+ */
1170
+ applyManualSearchFiltering(messages, criteria) {
1171
+ return messages.filter(message => {
1172
+ // General query search across multiple fields
1173
+ if (criteria.query) {
1174
+ const searchText = criteria.query.toLowerCase().trim();
1175
+ const searchableContent = [
1176
+ message.subject,
1177
+ message.bodyPreview,
1178
+ message.from.name,
1179
+ message.from.address
1180
+ ].join(' ').toLowerCase();
1181
+ const searchTerms = searchText.split(/\s+/).filter(term => term.length > 0);
1182
+ if (!searchTerms.every(term => searchableContent.includes(term))) {
1183
+ return false;
1184
+ }
1185
+ }
1186
+ // Subject search
1187
+ if (criteria.subject) {
1188
+ const subjectMatch = message.subject.toLowerCase().includes(criteria.subject.toLowerCase());
1189
+ if (!subjectMatch)
1190
+ return false;
1191
+ }
1192
+ // From name search (not email address)
1193
+ if (criteria.from && !criteria.from.includes('@')) {
1194
+ const fromNameMatch = message.from.name.toLowerCase().includes(criteria.from.toLowerCase());
1195
+ if (!fromNameMatch)
1196
+ return false;
1197
+ }
1198
+ return true;
1199
+ });
1200
+ }
1097
1201
  /**
1098
1202
  * Resolve UNKNOWN message IDs by finding messages using alternative search criteria
1099
1203
  */
@@ -1355,23 +1459,10 @@ ${originalBodyContent}
1355
1459
  logger.log(`🔍 Applying OData filter: ${filterString}`);
1356
1460
  apiCall = apiCall.filter(filterString);
1357
1461
  }
1358
- // Apply folder filter using specific folder API
1462
+ // Apply folder filter using optimized folder search
1359
1463
  if (criteria.folder) {
1360
- const folders = await this.findFolderByName(criteria.folder);
1361
- if (folders.length > 0) {
1362
- logger.log(`🔍 Switching to folder-specific search: ${folders[0].displayName} (${folders[0].id})`);
1363
- apiCall = graphClient
1364
- .api(`/me/mailFolders/${folders[0].id}/messages`)
1365
- .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
1366
- .orderby('receivedDateTime desc')
1367
- .top(Math.min(maxResults, 999));
1368
- // Re-apply filters for folder-specific search
1369
- if (filters.length > 0) {
1370
- const filterString = filters.join(' and ');
1371
- logger.log(`🔍 Applying OData filter to folder search: ${filterString}`);
1372
- apiCall = apiCall.filter(filterString);
1373
- }
1374
- }
1464
+ logger.log(`🔍 Using optimized folder search for: ${criteria.folder}`);
1465
+ return await this.performOptimizedFolderSearch(criteria, maxResults);
1375
1466
  }
1376
1467
  const result = await apiCall.get();
1377
1468
  const messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
@@ -2578,23 +2669,16 @@ ${originalBodyContent}
2578
2669
  logger.log(`🔍 Applying OData filter: ${filterString}`);
2579
2670
  apiCall = apiCall.filter(filterString);
2580
2671
  }
2581
- // Apply folder filter using specific folder API
2672
+ // For folder searches, use optimized approach
2582
2673
  if (criteria.folder) {
2583
- const folders = await this.findFolderByName(criteria.folder);
2584
- if (folders.length > 0) {
2585
- logger.log(`🔍 Switching to folder-specific search: ${folders[0].displayName} (${folders[0].id})`);
2586
- apiCall = graphClient
2587
- .api(`/me/mailFolders/${folders[0].id}/messages`)
2588
- .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
2589
- .orderby('receivedDateTime desc')
2590
- .top(999);
2591
- // Re-apply filters for folder-specific search
2592
- if (filters.length > 0) {
2593
- const filterString = filters.join(' and ');
2594
- logger.log(`🔍 Applying OData filter to folder search: ${filterString}`);
2595
- apiCall = apiCall.filter(filterString);
2596
- }
2597
- }
2674
+ logger.log(`🔍 Using optimized folder search (ALL mode) for: ${criteria.folder}`);
2675
+ // Use optimized search but allow unlimited results
2676
+ const originalMaxResults = criteria.maxResults;
2677
+ criteria.maxResults = 9999; // Set high limit for "ALL" mode
2678
+ const result = await this.performOptimizedFolderSearch(criteria, 9999);
2679
+ criteria.maxResults = originalMaxResults; // Restore original
2680
+ allMessages.push(...result);
2681
+ break; // Exit the pagination loop since we got the results
2598
2682
  }
2599
2683
  // Use nextLink if available for pagination
2600
2684
  if (nextLink) {
@@ -2957,21 +3041,10 @@ ${originalBodyContent}
2957
3041
  logger.log(`🔍 Fast OData filter: ${filterString}`);
2958
3042
  apiCall = apiCall.filter(filterString);
2959
3043
  }
2960
- // Apply folder filter if specified
3044
+ // Apply folder filter using optimized search
2961
3045
  if (criteria.folder) {
2962
- const folders = await this.findFolderByName(criteria.folder);
2963
- if (folders.length > 0) {
2964
- logger.log(`🔍 Fast folder search: ${folders[0].displayName}`);
2965
- apiCall = graphClient
2966
- .api(`/me/mailFolders/${folders[0].id}/messages`)
2967
- .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
2968
- .orderby('receivedDateTime desc')
2969
- .top(criteria.maxResults || 50);
2970
- if (filters.length > 0) {
2971
- const filterString = filters.join(' and ');
2972
- apiCall = apiCall.filter(filterString);
2973
- }
2974
- }
3046
+ logger.log(`🔍 Using optimized folder search (FAST mode) for: ${criteria.folder}`);
3047
+ return await this.performOptimizedFolderSearch(criteria, criteria.maxResults || 50);
2975
3048
  }
2976
3049
  const result = await apiCall.get();
2977
3050
  const messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
@@ -3031,6 +3104,10 @@ ${originalBodyContent}
3031
3104
  /**
3032
3105
  * Search emails in batches of 50, looping up to 5 times to get more results efficiently
3033
3106
  */
3107
+ /**
3108
+ * GRAPH API COMPLIANT BATCH SEARCH - Respects Microsoft's strict limitations
3109
+ * Uses the same compliant strategy as regular search but in batches
3110
+ */
3034
3111
  async searchEmailsBatched(criteria = {}, batchSize = 50, maxBatches = 5) {
3035
3112
  return await this.executeWithAuth(async () => {
3036
3113
  const graphClient = await this.getGraphClient();
@@ -3111,5 +3188,404 @@ ${originalBodyContent}
3111
3188
  }
3112
3189
  }, 'searchEmailsBatched');
3113
3190
  }
3191
+ /**
3192
+ * NATIVE MICROSOFT GRAPH JSON BATCHING
3193
+ * Uses the official $batch endpoint to combine up to 20 requests into a single HTTP call
3194
+ * Based on: https://learn.microsoft.com/en-us/graph/json-batching
3195
+ */
3196
+ async batchRequests(requests) {
3197
+ return await this.executeWithAuth(async () => {
3198
+ if (requests.length === 0) {
3199
+ return new Map();
3200
+ }
3201
+ if (requests.length > 20) {
3202
+ throw new Error('Microsoft Graph batch limit is 20 requests. Use multiple batches for more requests.');
3203
+ }
3204
+ const graphClient = await this.getGraphClient();
3205
+ logger.log(`🔄 NATIVE BATCH: Processing ${requests.length} requests in single HTTP call`);
3206
+ const batchRequest = {
3207
+ requests: requests.map(req => ({
3208
+ id: req.id,
3209
+ method: req.method.toUpperCase(),
3210
+ url: req.url.startsWith('/') ? req.url : `/${req.url}`,
3211
+ headers: req.headers || {},
3212
+ body: req.body,
3213
+ dependsOn: req.dependsOn || []
3214
+ }))
3215
+ };
3216
+ const startTime = Date.now();
3217
+ const batchResponse = await graphClient
3218
+ .api('/$batch')
3219
+ .post(batchRequest);
3220
+ const processingTime = Date.now() - startTime;
3221
+ logger.log(`📊 NATIVE BATCH: Completed ${requests.length} requests in ${processingTime}ms (avg: ${(processingTime / requests.length).toFixed(2)}ms per request)`);
3222
+ // Process responses into a Map for easy access
3223
+ const results = new Map();
3224
+ if (batchResponse.responses) {
3225
+ for (const response of batchResponse.responses) {
3226
+ if (response.status >= 200 && response.status < 300) {
3227
+ results.set(response.id, {
3228
+ success: true,
3229
+ data: response.body,
3230
+ status: response.status,
3231
+ headers: response.headers
3232
+ });
3233
+ }
3234
+ else {
3235
+ results.set(response.id, {
3236
+ success: false,
3237
+ error: response.body,
3238
+ status: response.status,
3239
+ headers: response.headers
3240
+ });
3241
+ }
3242
+ }
3243
+ }
3244
+ return results;
3245
+ }, 'batchRequests');
3246
+ }
3247
+ /**
3248
+ * Get multiple emails efficiently using native JSON batching
3249
+ * Replaces the current manual batching approach
3250
+ */
3251
+ async getEmailsBatch(messageIds, includeAttachments = false) {
3252
+ if (messageIds.length === 0) {
3253
+ return [];
3254
+ }
3255
+ const emails = [];
3256
+ const selectFields = 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink';
3257
+ // Process in batches of 20 (Graph API limit)
3258
+ for (let i = 0; i < messageIds.length; i += 20) {
3259
+ const batchIds = messageIds.slice(i, i + 20);
3260
+ // Create batch requests
3261
+ const requests = batchIds.map((id, index) => ({
3262
+ id: `email_${i + index}`,
3263
+ method: 'GET',
3264
+ url: `/me/messages/${id}?$select=${selectFields}`
3265
+ }));
3266
+ // Add attachment requests if needed
3267
+ if (includeAttachments) {
3268
+ const attachmentRequests = batchIds.map((id, index) => ({
3269
+ id: `attachments_${i + index}`,
3270
+ method: 'GET',
3271
+ url: `/me/messages/${id}/attachments`,
3272
+ dependsOn: [`email_${i + index}`] // Only run if email request succeeds
3273
+ }));
3274
+ requests.push(...attachmentRequests);
3275
+ }
3276
+ // Execute batch
3277
+ const batchResults = await this.batchRequests(requests);
3278
+ // Process email results
3279
+ for (let j = 0; j < batchIds.length; j++) {
3280
+ const emailResult = batchResults.get(`email_${i + j}`);
3281
+ if (emailResult?.success) {
3282
+ const emailInfo = this.mapEmailResult(emailResult.data);
3283
+ // Add attachments if requested
3284
+ if (includeAttachments) {
3285
+ const attachmentResult = batchResults.get(`attachments_${i + j}`);
3286
+ if (attachmentResult?.success) {
3287
+ emailInfo.attachments = attachmentResult.data.value?.map((att) => ({
3288
+ id: att.id,
3289
+ name: att.name,
3290
+ size: att.size,
3291
+ contentType: att.contentType,
3292
+ isInline: att.isInline || false
3293
+ })) || [];
3294
+ }
3295
+ }
3296
+ emails.push(emailInfo);
3297
+ }
3298
+ else {
3299
+ logger.log(`❌ Failed to get email ${batchIds[j]}: ${emailResult?.error?.message || 'Unknown error'}`);
3300
+ }
3301
+ }
3302
+ }
3303
+ return emails;
3304
+ }
3305
+ /**
3306
+ * Perform multiple email operations in a single batch
3307
+ * Examples: mark multiple emails as read, move multiple emails, etc.
3308
+ */
3309
+ async batchEmailOperations(operations) {
3310
+ const requests = operations.map(op => {
3311
+ switch (op.operation) {
3312
+ case 'mark':
3313
+ return {
3314
+ id: op.id,
3315
+ method: 'PATCH',
3316
+ url: `/me/messages/${op.messageId}`,
3317
+ headers: { 'Content-Type': 'application/json' },
3318
+ body: { isRead: op.params?.isRead ?? true }
3319
+ };
3320
+ case 'move':
3321
+ return {
3322
+ id: op.id,
3323
+ method: 'POST',
3324
+ url: `/me/messages/${op.messageId}/move`,
3325
+ headers: { 'Content-Type': 'application/json' },
3326
+ body: { destinationId: op.params?.destinationFolderId }
3327
+ };
3328
+ case 'delete':
3329
+ return {
3330
+ id: op.id,
3331
+ method: 'DELETE',
3332
+ url: `/me/messages/${op.messageId}`
3333
+ };
3334
+ default:
3335
+ throw new Error(`Unknown operation: ${op.operation}`);
3336
+ }
3337
+ });
3338
+ const batchResults = await this.batchRequests(requests);
3339
+ // Convert to operation results
3340
+ const results = new Map();
3341
+ for (const [id, result] of batchResults) {
3342
+ results.set(id, {
3343
+ success: result.success,
3344
+ error: result.success ? undefined : result.error?.message || 'Unknown error'
3345
+ });
3346
+ }
3347
+ return results;
3348
+ }
3349
+ /**
3350
+ * Enhanced folder and email info fetching using batching
3351
+ * Gets folder info and recent emails in parallel
3352
+ */
3353
+ async getFolderWithRecentEmails(folderId, emailCount = 10) {
3354
+ const requests = [
3355
+ {
3356
+ id: 'folder_info',
3357
+ method: 'GET',
3358
+ url: `/me/mailFolders/${folderId}?$select=id,displayName,totalItemCount,unreadItemCount,parentFolderId`
3359
+ },
3360
+ {
3361
+ id: 'recent_emails',
3362
+ method: 'GET',
3363
+ url: `/me/mailFolders/${folderId}/messages?$select=id,subject,from,receivedDateTime,isRead,hasAttachments&$orderby=receivedDateTime desc&$top=${emailCount}`
3364
+ }
3365
+ ];
3366
+ const batchResults = await this.batchRequests(requests);
3367
+ const folderResult = batchResults.get('folder_info');
3368
+ const emailsResult = batchResults.get('recent_emails');
3369
+ return {
3370
+ folder: folderResult?.success ? folderResult.data : null,
3371
+ emails: emailsResult?.success ?
3372
+ (emailsResult.data.value?.map((email) => this.mapEmailResult(email)) || []) :
3373
+ []
3374
+ };
3375
+ }
3376
+ /**
3377
+ * Perform optimized folder search for large folders
3378
+ */
3379
+ async performOptimizedFolderSearch(criteria, maxResults) {
3380
+ try {
3381
+ const graphClient = await this.getGraphClient();
3382
+ // First, find the folder
3383
+ const folders = await this.findFolderByName(criteria.folder);
3384
+ if (folders.length === 0) {
3385
+ logger.log(`❌ Folder not found: ${criteria.folder}`);
3386
+ throw new Error(`Folder "${criteria.folder}" not found. Available folders can be checked with list_folders tool.`);
3387
+ }
3388
+ const targetFolder = folders[0];
3389
+ logger.log(`🔍 Searching in folder: ${targetFolder.displayName} (${targetFolder.totalItemCount} total emails)`);
3390
+ // Check if it's a large folder and adjust strategy
3391
+ const isLargeFolder = targetFolder.totalItemCount > 10000;
3392
+ if (isLargeFolder) {
3393
+ logger.log(`⚡ Large folder detected (${targetFolder.totalItemCount} emails). Using optimized search strategy.`);
3394
+ // For large folders, use recent-first strategy with filters
3395
+ return await this.performLargeFolderOptimizedSearch(targetFolder, criteria, maxResults);
3396
+ }
3397
+ else {
3398
+ // For smaller folders, use standard folder search
3399
+ return await this.performStandardFolderSearch(targetFolder, criteria, maxResults);
3400
+ }
3401
+ }
3402
+ catch (error) {
3403
+ logger.error(`Error in optimized folder search:`, error);
3404
+ throw error;
3405
+ }
3406
+ }
3407
+ /**
3408
+ * Optimized search for large folders (>10k emails)
3409
+ */
3410
+ async performLargeFolderOptimizedSearch(folder, criteria, maxResults) {
3411
+ const graphClient = await this.getGraphClient();
3412
+ // Strategy 1: Search recent emails first (last 90 days)
3413
+ const recentDate = new Date();
3414
+ recentDate.setDate(recentDate.getDate() - 90);
3415
+ const recentDateStr = recentDate.toISOString().split('T')[0];
3416
+ logger.log(`🔍 Phase 1: Searching recent emails (since ${recentDateStr}) in large folder`);
3417
+ let apiCall = graphClient
3418
+ .api(`/me/mailFolders/${folder.id}/messages`)
3419
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
3420
+ .orderby('receivedDateTime desc')
3421
+ .top(Math.min(maxResults * 2, 500)); // Get more for filtering
3422
+ // Build filters for better performance
3423
+ const filters = [`receivedDateTime ge '${recentDateStr}T00:00:00Z'`];
3424
+ if (criteria.from) {
3425
+ const escapedFrom = this.escapeODataValue(criteria.from);
3426
+ if (criteria.from.includes('@')) {
3427
+ filters.push(`from/emailAddress/address eq '${escapedFrom}'`);
3428
+ }
3429
+ else {
3430
+ filters.push(`contains(from/emailAddress/name,'${escapedFrom}')`);
3431
+ }
3432
+ }
3433
+ if (criteria.subject) {
3434
+ const escapedSubject = this.escapeODataValue(criteria.subject);
3435
+ filters.push(`contains(subject,'${escapedSubject}')`);
3436
+ }
3437
+ if (criteria.isUnread !== undefined) {
3438
+ filters.push(`isRead eq ${!criteria.isUnread}`);
3439
+ }
3440
+ if (criteria.hasAttachment !== undefined) {
3441
+ filters.push(`hasAttachments eq ${criteria.hasAttachment}`);
3442
+ }
3443
+ const filterString = filters.join(' and ');
3444
+ logger.log(`🔍 Large folder filter: ${filterString}`);
3445
+ apiCall = apiCall.filter(filterString);
3446
+ const result = await apiCall.get();
3447
+ let messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
3448
+ // Apply additional text filtering
3449
+ if (criteria.query) {
3450
+ messages = messages.filter(message => {
3451
+ const searchText = criteria.query.toLowerCase();
3452
+ const messageText = `${message.subject} ${message.bodyPreview} ${message.from.name} ${message.from.address}`.toLowerCase();
3453
+ return messageText.includes(searchText);
3454
+ });
3455
+ }
3456
+ logger.log(`📧 Large folder search Phase 1 result: ${messages.length} emails found`);
3457
+ // If we have enough results or found good matches, return
3458
+ if (messages.length >= maxResults || messages.length > 5) {
3459
+ return messages.slice(0, maxResults);
3460
+ }
3461
+ // Strategy 2: If not enough results, search older emails (last 365 days) but with stricter filters
3462
+ logger.log(`🔍 Phase 2: Expanding search to older emails with stricter criteria`);
3463
+ const olderDate = new Date();
3464
+ olderDate.setDate(olderDate.getDate() - 365);
3465
+ const olderDateStr = olderDate.toISOString().split('T')[0];
3466
+ let olderApiCall = graphClient
3467
+ .api(`/me/mailFolders/${folder.id}/messages`)
3468
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
3469
+ .orderby('receivedDateTime desc')
3470
+ .top(300);
3471
+ // More restrictive filters for older search
3472
+ const olderFilters = [
3473
+ `receivedDateTime ge '${olderDateStr}T00:00:00Z'`,
3474
+ `receivedDateTime lt '${recentDateStr}T00:00:00Z'`
3475
+ ];
3476
+ // Must have at least one specific criteria for older search
3477
+ if (criteria.from) {
3478
+ const escapedFrom = this.escapeODataValue(criteria.from);
3479
+ if (criteria.from.includes('@')) {
3480
+ olderFilters.push(`from/emailAddress/address eq '${escapedFrom}'`);
3481
+ }
3482
+ else {
3483
+ olderFilters.push(`contains(from/emailAddress/name,'${escapedFrom}')`);
3484
+ }
3485
+ }
3486
+ else if (criteria.subject) {
3487
+ const escapedSubject = this.escapeODataValue(criteria.subject);
3488
+ olderFilters.push(`contains(subject,'${escapedSubject}')`);
3489
+ }
3490
+ else {
3491
+ // If no specific sender/subject, skip older search to avoid timeout
3492
+ logger.log(`⚠️ No specific sender/subject for older search. Returning recent results only.`);
3493
+ return messages.slice(0, maxResults);
3494
+ }
3495
+ if (criteria.importance) {
3496
+ olderFilters.push(`importance eq '${criteria.importance}'`);
3497
+ }
3498
+ const olderFilterString = olderFilters.join(' and ');
3499
+ logger.log(`🔍 Large folder older filter: ${olderFilterString}`);
3500
+ olderApiCall = olderApiCall.filter(olderFilterString);
3501
+ const olderResult = await olderApiCall.get();
3502
+ const olderMessages = olderResult.value?.map((email) => this.mapEmailResult(email)) || [];
3503
+ // Apply text filtering to older results
3504
+ let filteredOlderMessages = olderMessages;
3505
+ if (criteria.query) {
3506
+ filteredOlderMessages = olderMessages.filter(message => {
3507
+ const searchText = criteria.query.toLowerCase();
3508
+ const messageText = `${message.subject} ${message.bodyPreview} ${message.from.name} ${message.from.address}`.toLowerCase();
3509
+ return messageText.includes(searchText);
3510
+ });
3511
+ }
3512
+ logger.log(`📧 Large folder search Phase 2 result: ${filteredOlderMessages.length} additional emails found`);
3513
+ // Combine results and avoid duplicates
3514
+ const allMessages = [...messages];
3515
+ for (const olderMessage of filteredOlderMessages) {
3516
+ if (!allMessages.some(existing => existing.id === olderMessage.id)) {
3517
+ allMessages.push(olderMessage);
3518
+ }
3519
+ }
3520
+ return allMessages.slice(0, maxResults);
3521
+ }
3522
+ /**
3523
+ * Standard search for smaller folders (<10k emails)
3524
+ */
3525
+ async performStandardFolderSearch(folder, criteria, maxResults) {
3526
+ const graphClient = await this.getGraphClient();
3527
+ let apiCall = graphClient
3528
+ .api(`/me/mailFolders/${folder.id}/messages`)
3529
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
3530
+ .orderby('receivedDateTime desc')
3531
+ .top(Math.min(maxResults * 2, 999));
3532
+ // Build filters
3533
+ const filters = [];
3534
+ if (criteria.from) {
3535
+ const escapedFrom = this.escapeODataValue(criteria.from);
3536
+ if (criteria.from.includes('@')) {
3537
+ filters.push(`from/emailAddress/address eq '${escapedFrom}'`);
3538
+ }
3539
+ else {
3540
+ filters.push(`contains(from/emailAddress/name,'${escapedFrom}')`);
3541
+ }
3542
+ }
3543
+ if (criteria.to && criteria.to.includes('@')) {
3544
+ const escapedTo = this.escapeODataValue(criteria.to);
3545
+ filters.push(`toRecipients/any(r: r/emailAddress/address eq '${escapedTo}')`);
3546
+ }
3547
+ if (criteria.cc && criteria.cc.includes('@')) {
3548
+ const escapedCc = this.escapeODataValue(criteria.cc);
3549
+ filters.push(`ccRecipients/any(r: r/emailAddress/address eq '${escapedCc}')`);
3550
+ }
3551
+ if (criteria.subject) {
3552
+ const escapedSubject = this.escapeODataValue(criteria.subject);
3553
+ filters.push(`contains(subject,'${escapedSubject}')`);
3554
+ }
3555
+ if (criteria.after) {
3556
+ const afterDate = this.formatDateForOData(criteria.after);
3557
+ filters.push(`receivedDateTime ge '${afterDate}'`);
3558
+ }
3559
+ if (criteria.before) {
3560
+ const beforeDate = this.formatDateForOData(criteria.before);
3561
+ filters.push(`receivedDateTime le '${beforeDate}'`);
3562
+ }
3563
+ if (criteria.isUnread !== undefined) {
3564
+ filters.push(`isRead eq ${!criteria.isUnread}`);
3565
+ }
3566
+ if (criteria.hasAttachment !== undefined) {
3567
+ filters.push(`hasAttachments eq ${criteria.hasAttachment}`);
3568
+ }
3569
+ if (criteria.importance) {
3570
+ filters.push(`importance eq '${criteria.importance}'`);
3571
+ }
3572
+ if (filters.length > 0) {
3573
+ const filterString = filters.join(' and ');
3574
+ logger.log(`🔍 Standard folder filter: ${filterString}`);
3575
+ apiCall = apiCall.filter(filterString);
3576
+ }
3577
+ const result = await apiCall.get();
3578
+ let messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
3579
+ // Apply additional text filtering
3580
+ if (criteria.query) {
3581
+ messages = messages.filter(message => {
3582
+ const searchText = criteria.query.toLowerCase();
3583
+ const messageText = `${message.subject} ${message.bodyPreview} ${message.from.name} ${message.from.address}`.toLowerCase();
3584
+ return messageText.includes(searchText);
3585
+ });
3586
+ }
3587
+ logger.log(`📧 Standard folder search result: ${messages.length} emails found`);
3588
+ return messages.slice(0, maxResults);
3589
+ }
3114
3590
  }
3115
3591
  export const ms365Operations = new MS365Operations();