ms365-mcp-server 1.1.15 → 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}`);
188
+ }
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
+ }
218
205
  }
219
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,140 +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 = 6; // Try multiple strategies
1011
- // Add timeout mechanism (5 minutes max)
1012
- const searchTimeout = 5 * 60 * 1000; // 5 minutes
1013
- const startTime = Date.now();
1014
- logger.log(`🔍 Starting 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 reliable OData filter search first (has proper IDs) - Get ALL results
1019
- if (searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1020
- searchAttempts++;
1021
- logger.log(`🔍 Attempt ${searchAttempts}: Using reliable OData filter search to get ALL results`);
1022
- const filterResults = await this.performFilteredSearchAll(criteria);
1023
- allMessages.push(...filterResults);
1024
- if (allMessages.length > 0) {
1025
- logger.log(`✅ Found ${allMessages.length} results with OData filter search`);
1026
- }
1027
- }
1028
- // Strategy 2: Use Microsoft Graph Search API for text-based queries (backup) - Get ALL results
1029
- if (allMessages.length === 0 && criteria.query && searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1030
- searchAttempts++;
1031
- logger.log(`🔍 Attempt ${searchAttempts}: Using Graph Search API for query: "${criteria.query}" to get ALL results`);
1032
- const searchResults = await this.performGraphSearchAll(criteria.query);
1033
- allMessages.push(...searchResults);
1034
- if (allMessages.length > 0) {
1035
- logger.log(`✅ Found ${allMessages.length} results with Graph Search API`);
1036
- }
1037
- }
1038
- // Strategy 3: Use KQL (Keyword Query Language) for advanced searches - Get ALL results
1039
- if (allMessages.length === 0 && searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1040
- const kqlQuery = this.buildKQLQuery(criteria);
1041
- if (kqlQuery) {
1042
- searchAttempts++;
1043
- logger.log(`🔍 Attempt ${searchAttempts}: Using KQL search: "${kqlQuery}" to get ALL results`);
1044
- const kqlResults = await this.performKQLSearchAll(kqlQuery);
1045
- allMessages.push(...kqlResults);
1046
- if (allMessages.length > 0) {
1047
- logger.log(`✅ Found ${allMessages.length} results with KQL search`);
1048
- }
1049
- }
1050
- }
1051
- // Strategy 4: Try relaxed KQL search (remove some constraints) - Get ALL results
1052
- if (allMessages.length === 0 && searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1053
- const relaxedKQL = this.buildRelaxedKQLQuery(criteria);
1054
- if (relaxedKQL && relaxedKQL !== this.buildKQLQuery(criteria)) {
1055
- searchAttempts++;
1056
- logger.log(`🔍 Attempt ${searchAttempts}: Using relaxed KQL search: "${relaxedKQL}" to get ALL results`);
1057
- const relaxedResults = await this.performKQLSearchAll(relaxedKQL);
1058
- allMessages.push(...relaxedResults);
1059
- if (allMessages.length > 0) {
1060
- logger.log(`✅ Found ${allMessages.length} results with relaxed KQL search`);
1061
- }
1062
- }
1063
- }
1064
- // Strategy 5: If we found results but they have UNKNOWN IDs, try to resolve them
1065
- if (allMessages.length > 0 && allMessages.some(msg => msg.id === 'UNKNOWN') && (Date.now() - startTime) < searchTimeout) {
1066
- logger.log(`🔍 Attempting to resolve UNKNOWN message IDs using direct message queries`);
1067
- allMessages = await this.resolveUnknownMessageIds(allMessages, criteria);
1068
- }
1069
- // Strategy 6: Partial text search across ALL recent emails (expanded scope)
1070
- if (allMessages.length === 0 && searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1071
- searchAttempts++;
1072
- logger.log(`🔍 Attempt ${searchAttempts}: Using expanded partial text search across ALL emails`);
1073
- const partialResults = await this.performPartialTextSearchAll(criteria);
1074
- allMessages.push(...partialResults);
1075
- if (allMessages.length > 0) {
1076
- logger.log(`✅ Found ${allMessages.length} results with partial text search`);
1077
- }
1078
- }
1079
- // Strategy 7: Fallback to basic search with ALL results
1080
- if (allMessages.length === 0 && searchAttempts < maxAttempts && (Date.now() - startTime) < searchTimeout) {
1081
- searchAttempts++;
1082
- logger.log(`🔍 Attempt ${searchAttempts}: Using fallback basic search to get ALL results`);
1083
- const basicResult = await this.performBasicSearchAll(criteria);
1084
- allMessages.push(...basicResult.messages);
1085
- if (allMessages.length > 0) {
1086
- logger.log(`✅ Found ${allMessages.length} results with basic search fallback`);
1087
- }
1088
- }
1089
- // Check if we hit the timeout
1090
- if ((Date.now() - startTime) >= searchTimeout) {
1091
- 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;
1092
1030
  }
1093
- // Remove duplicates and apply advanced filtering
1094
- const uniqueMessages = this.removeDuplicateMessages(allMessages);
1095
- logger.log(`📧 After deduplication: ${uniqueMessages.length} unique messages`);
1096
- const filteredMessages = this.applyAdvancedFiltering(uniqueMessages, criteria);
1097
- 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);
1098
1033
  // Sort by relevance and date
1099
1034
  const sortedMessages = this.sortSearchResults(filteredMessages, criteria);
1100
- // If still no results, try one more time with very relaxed criteria
1101
- if (sortedMessages.length === 0 && (criteria.query || criteria.from || criteria.subject) && (Date.now() - startTime) < searchTimeout) {
1102
- logger.log(`🔍 Final attempt: Ultra-relaxed search for any partial matches across ALL emails`);
1103
- const ultraRelaxedResults = await this.performUltraRelaxedSearchAll(criteria);
1104
- const finalFiltered = this.applyUltraRelaxedFiltering(ultraRelaxedResults, criteria);
1105
- if (finalFiltered.length > 0) {
1106
- logger.log(`✅ Ultra-relaxed search found ${finalFiltered.length} results`);
1107
- // Apply maxResults limit if specified
1108
- const finalMessages = maxResults > 0 ? finalFiltered.slice(0, maxResults) : finalFiltered.slice(0, safetyLimit);
1109
- const searchResult = {
1110
- messages: finalMessages,
1111
- hasMore: maxResults > 0 ? finalFiltered.length > maxResults : finalFiltered.length > safetyLimit
1112
- };
1113
- this.setCachedResults(cacheKey, searchResult);
1114
- return searchResult;
1115
- }
1116
- }
1117
- // Apply maxResults limit if specified, otherwise return all results (with safety limit)
1118
- const finalMessages = maxResults > 0 ? sortedMessages.slice(0, maxResults) : sortedMessages.slice(0, safetyLimit);
1035
+ // Apply maxResults limit
1036
+ const finalMessages = sortedMessages.slice(0, maxResults);
1119
1037
  const searchResult = {
1120
1038
  messages: finalMessages,
1121
- hasMore: maxResults > 0 ? sortedMessages.length > maxResults : sortedMessages.length > safetyLimit
1039
+ hasMore: sortedMessages.length > maxResults
1122
1040
  };
1123
1041
  this.setCachedResults(cacheKey, searchResult);
1124
- logger.log(`🔍 Search completed after ${searchAttempts} attempts: ${finalMessages.length} final results (${maxResults > 0 ? 'limited' : 'safety-limited'}) in ${(Date.now() - startTime) / 1000} seconds`);
1125
- if (finalMessages.length === 0) {
1126
- logger.log(`❌ No results found despite ${searchAttempts} search strategies. Consider broadening search criteria.`);
1127
- }
1042
+ logger.log(`🔍 COMPLIANT SEARCH completed: ${finalMessages.length} results`);
1128
1043
  return searchResult;
1129
1044
  }
1130
1045
  catch (error) {
1131
- logger.error('Error in persistent email search:', error);
1132
- // Final fallback - get some recent emails and filter them
1133
- logger.log('🔄 Final fallback: getting recent emails to filter manually');
1134
- return await this.performBasicSearchAll(criteria);
1046
+ logger.error('Error in compliant email search:', error);
1047
+ // Final fallback
1048
+ return await this.performBasicSearch(criteria);
1135
1049
  }
1136
1050
  }, 'searchEmails');
1137
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
+ }
1138
1201
  /**
1139
1202
  * Resolve UNKNOWN message IDs by finding messages using alternative search criteria
1140
1203
  */
@@ -1396,23 +1459,10 @@ ${originalBodyContent}
1396
1459
  logger.log(`🔍 Applying OData filter: ${filterString}`);
1397
1460
  apiCall = apiCall.filter(filterString);
1398
1461
  }
1399
- // Apply folder filter using specific folder API
1462
+ // Apply folder filter using optimized folder search
1400
1463
  if (criteria.folder) {
1401
- const folders = await this.findFolderByName(criteria.folder);
1402
- if (folders.length > 0) {
1403
- logger.log(`🔍 Switching to folder-specific search: ${folders[0].displayName} (${folders[0].id})`);
1404
- apiCall = graphClient
1405
- .api(`/me/mailFolders/${folders[0].id}/messages`)
1406
- .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
1407
- .orderby('receivedDateTime desc')
1408
- .top(Math.min(maxResults, 999));
1409
- // Re-apply filters for folder-specific search
1410
- if (filters.length > 0) {
1411
- const filterString = filters.join(' and ');
1412
- logger.log(`🔍 Applying OData filter to folder search: ${filterString}`);
1413
- apiCall = apiCall.filter(filterString);
1414
- }
1415
- }
1464
+ logger.log(`🔍 Using optimized folder search for: ${criteria.folder}`);
1465
+ return await this.performOptimizedFolderSearch(criteria, maxResults);
1416
1466
  }
1417
1467
  const result = await apiCall.get();
1418
1468
  const messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
@@ -2619,23 +2669,16 @@ ${originalBodyContent}
2619
2669
  logger.log(`🔍 Applying OData filter: ${filterString}`);
2620
2670
  apiCall = apiCall.filter(filterString);
2621
2671
  }
2622
- // Apply folder filter using specific folder API
2672
+ // For folder searches, use optimized approach
2623
2673
  if (criteria.folder) {
2624
- const folders = await this.findFolderByName(criteria.folder);
2625
- if (folders.length > 0) {
2626
- logger.log(`🔍 Switching to folder-specific search: ${folders[0].displayName} (${folders[0].id})`);
2627
- apiCall = graphClient
2628
- .api(`/me/mailFolders/${folders[0].id}/messages`)
2629
- .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
2630
- .orderby('receivedDateTime desc')
2631
- .top(999);
2632
- // Re-apply filters for folder-specific search
2633
- if (filters.length > 0) {
2634
- const filterString = filters.join(' and ');
2635
- logger.log(`🔍 Applying OData filter to folder search: ${filterString}`);
2636
- apiCall = apiCall.filter(filterString);
2637
- }
2638
- }
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
2639
2682
  }
2640
2683
  // Use nextLink if available for pagination
2641
2684
  if (nextLink) {
@@ -2945,31 +2988,169 @@ ${originalBodyContent}
2945
2988
  } while (nextLink);
2946
2989
  return allMessages;
2947
2990
  }
2991
+ /**
2992
+ * Fast OData filter search - optimized for speed
2993
+ */
2994
+ async performFilteredSearchFast(criteria) {
2995
+ try {
2996
+ const graphClient = await this.getGraphClient();
2997
+ const filters = [];
2998
+ // Build filters quickly
2999
+ if (criteria.from) {
3000
+ const escapedFrom = this.escapeODataValue(criteria.from);
3001
+ filters.push(`contains(from/emailAddress/address,'${escapedFrom}') or contains(from/emailAddress/name,'${escapedFrom}')`);
3002
+ }
3003
+ if (criteria.to) {
3004
+ const escapedTo = this.escapeODataValue(criteria.to);
3005
+ filters.push(`toRecipients/any(r:contains(r/emailAddress/address,'${escapedTo}'))`);
3006
+ }
3007
+ if (criteria.cc) {
3008
+ const escapedCc = this.escapeODataValue(criteria.cc);
3009
+ filters.push(`ccRecipients/any(r:contains(r/emailAddress/address,'${escapedCc}'))`);
3010
+ }
3011
+ if (criteria.subject) {
3012
+ const escapedSubject = this.escapeODataValue(criteria.subject);
3013
+ filters.push(`contains(subject,'${escapedSubject}')`);
3014
+ }
3015
+ if (criteria.after) {
3016
+ const afterDate = this.formatDateForOData(criteria.after);
3017
+ filters.push(`receivedDateTime ge '${afterDate}'`);
3018
+ }
3019
+ if (criteria.before) {
3020
+ const beforeDate = this.formatDateForOData(criteria.before);
3021
+ filters.push(`receivedDateTime le '${beforeDate}'`);
3022
+ }
3023
+ if (criteria.isUnread !== undefined) {
3024
+ filters.push(`isRead eq ${!criteria.isUnread}`);
3025
+ }
3026
+ if (criteria.importance) {
3027
+ filters.push(`importance eq '${criteria.importance}'`);
3028
+ }
3029
+ if (criteria.hasAttachment !== undefined) {
3030
+ filters.push(`hasAttachments eq ${criteria.hasAttachment}`);
3031
+ }
3032
+ // Build API call
3033
+ let apiCall = graphClient
3034
+ .api('/me/messages')
3035
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
3036
+ .orderby('receivedDateTime desc')
3037
+ .top(criteria.maxResults || 50);
3038
+ // Apply filters
3039
+ if (filters.length > 0) {
3040
+ const filterString = filters.join(' and ');
3041
+ logger.log(`🔍 Fast OData filter: ${filterString}`);
3042
+ apiCall = apiCall.filter(filterString);
3043
+ }
3044
+ // Apply folder filter using optimized search
3045
+ if (criteria.folder) {
3046
+ logger.log(`🔍 Using optimized folder search (FAST mode) for: ${criteria.folder}`);
3047
+ return await this.performOptimizedFolderSearch(criteria, criteria.maxResults || 50);
3048
+ }
3049
+ const result = await apiCall.get();
3050
+ const messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
3051
+ logger.log(`📧 Fast OData search returned ${messages.length} results`);
3052
+ return messages;
3053
+ }
3054
+ catch (error) {
3055
+ logger.log(`❌ Fast OData search failed: ${error}`);
3056
+ return [];
3057
+ }
3058
+ }
3059
+ /**
3060
+ * Fast basic search - optimized for speed
3061
+ */
3062
+ async performBasicSearchFast(criteria) {
3063
+ logger.log('🔄 Using fast basic search');
3064
+ const graphClient = await this.getGraphClient();
3065
+ const result = await graphClient
3066
+ .api('/me/messages')
3067
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
3068
+ .orderby('receivedDateTime desc')
3069
+ .top(criteria.maxResults || 50)
3070
+ .get();
3071
+ let messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
3072
+ // Apply minimal filtering for speed
3073
+ if (criteria.from || criteria.to || criteria.subject) {
3074
+ messages = this.applyBasicFiltering(messages, criteria);
3075
+ }
3076
+ return {
3077
+ messages,
3078
+ hasMore: !!result['@odata.nextLink']
3079
+ };
3080
+ }
3081
+ /**
3082
+ * Basic filtering for fast search
3083
+ */
3084
+ applyBasicFiltering(messages, criteria) {
3085
+ return messages.filter(message => {
3086
+ // Simple text matching
3087
+ if (criteria.from) {
3088
+ const fromText = `${message.from.name} ${message.from.address}`.toLowerCase();
3089
+ if (!fromText.includes(criteria.from.toLowerCase()))
3090
+ return false;
3091
+ }
3092
+ if (criteria.to) {
3093
+ const toText = message.toRecipients.map(r => `${r.name} ${r.address}`).join(' ').toLowerCase();
3094
+ if (!toText.includes(criteria.to.toLowerCase()))
3095
+ return false;
3096
+ }
3097
+ if (criteria.subject) {
3098
+ if (!message.subject.toLowerCase().includes(criteria.subject.toLowerCase()))
3099
+ return false;
3100
+ }
3101
+ return true;
3102
+ });
3103
+ }
2948
3104
  /**
2949
3105
  * Search emails in batches of 50, looping up to 5 times to get more results efficiently
2950
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
+ */
2951
3111
  async searchEmailsBatched(criteria = {}, batchSize = 50, maxBatches = 5) {
2952
3112
  return await this.executeWithAuth(async () => {
2953
3113
  const graphClient = await this.getGraphClient();
2954
- logger.log(`🔍 Starting batched email search: ${batchSize} per batch, max ${maxBatches} batches`);
3114
+ logger.log(`🔍 Starting FAST batched email search: ${batchSize} per batch, max ${maxBatches} batches`);
2955
3115
  logger.log(`🔍 Search criteria:`, JSON.stringify(criteria, null, 2));
2956
3116
  const allMessages = [];
2957
3117
  let currentBatch = 0;
2958
3118
  let hasMoreResults = true;
2959
3119
  const startTime = Date.now();
2960
- const batchTimeout = 2 * 60 * 1000; // 2 minutes per batch
3120
+ const batchTimeout = 30 * 1000; // 30 seconds per batch (much faster)
2961
3121
  try {
2962
3122
  while (currentBatch < maxBatches && hasMoreResults && (Date.now() - startTime) < batchTimeout) {
2963
3123
  currentBatch++;
2964
3124
  logger.log(`🔍 Processing batch ${currentBatch}/${maxBatches}`);
2965
- // Create criteria for this batch
3125
+ // Create criteria for this batch with FAST search strategy
2966
3126
  const batchCriteria = {
2967
3127
  ...criteria,
2968
3128
  maxResults: batchSize
2969
3129
  };
2970
- // Search for this batch
2971
- const batchResult = await this.searchEmails(batchCriteria);
2972
- const batchMessages = batchResult.messages;
3130
+ // Use FAST search - only try the most reliable method first
3131
+ let batchMessages = [];
3132
+ try {
3133
+ // Strategy 1: Fast OData filter search (most reliable and fastest)
3134
+ logger.log(`🔍 Batch ${currentBatch}: Using fast OData filter search`);
3135
+ batchMessages = await this.performFilteredSearchFast(batchCriteria);
3136
+ if (batchMessages.length === 0) {
3137
+ // Strategy 2: Quick basic search fallback
3138
+ logger.log(`🔍 Batch ${currentBatch}: Using quick basic search fallback`);
3139
+ const basicResult = await this.performBasicSearchFast(batchCriteria);
3140
+ batchMessages = basicResult.messages;
3141
+ }
3142
+ }
3143
+ catch (error) {
3144
+ logger.log(`⚠️ Batch ${currentBatch} search failed, trying basic search: ${error}`);
3145
+ try {
3146
+ const basicResult = await this.performBasicSearchFast(batchCriteria);
3147
+ batchMessages = basicResult.messages;
3148
+ }
3149
+ catch (basicError) {
3150
+ logger.log(`❌ Batch ${currentBatch} basic search also failed: ${basicError}`);
3151
+ break;
3152
+ }
3153
+ }
2973
3154
  logger.log(`📧 Batch ${currentBatch}: Found ${batchMessages.length} results`);
2974
3155
  if (batchMessages.length > 0) {
2975
3156
  // Add new messages (avoid duplicates)
@@ -2977,20 +3158,20 @@ ${originalBodyContent}
2977
3158
  allMessages.push(...newMessages);
2978
3159
  logger.log(`📧 Batch ${currentBatch}: Added ${newMessages.length} new unique messages (total: ${allMessages.length})`);
2979
3160
  // Check if we have more results
2980
- hasMoreResults = batchResult.hasMore && batchMessages.length === batchSize;
3161
+ hasMoreResults = batchMessages.length === batchSize;
2981
3162
  }
2982
3163
  else {
2983
3164
  // No more results found
2984
3165
  hasMoreResults = false;
2985
3166
  logger.log(`📧 Batch ${currentBatch}: No more results found`);
2986
3167
  }
2987
- // Small delay between batches to avoid rate limiting
3168
+ // Shorter delay between batches for faster performance
2988
3169
  if (hasMoreResults && currentBatch < maxBatches) {
2989
- await new Promise(resolve => setTimeout(resolve, 1000));
3170
+ await new Promise(resolve => setTimeout(resolve, 500)); // Reduced to 500ms
2990
3171
  }
2991
3172
  }
2992
3173
  const totalTime = (Date.now() - startTime) / 1000;
2993
- logger.log(`🔍 Batched search completed: ${allMessages.length} total results from ${currentBatch} batches in ${totalTime} seconds`);
3174
+ logger.log(`🔍 FAST batched search completed: ${allMessages.length} total results from ${currentBatch} batches in ${totalTime} seconds`);
2994
3175
  return {
2995
3176
  messages: allMessages,
2996
3177
  hasMore: hasMoreResults,
@@ -2998,7 +3179,7 @@ ${originalBodyContent}
2998
3179
  };
2999
3180
  }
3000
3181
  catch (error) {
3001
- logger.error('Error in batched email search:', error);
3182
+ logger.error('Error in fast batched email search:', error);
3002
3183
  return {
3003
3184
  messages: allMessages,
3004
3185
  hasMore: false,
@@ -3007,5 +3188,404 @@ ${originalBodyContent}
3007
3188
  }
3008
3189
  }, 'searchEmailsBatched');
3009
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
+ }
3010
3590
  }
3011
3591
  export const ms365Operations = new MS365Operations();