ms365-mcp-server 1.1.10 → 1.1.12

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.
@@ -423,6 +423,54 @@ export class MS365Operations {
423
423
  throw error;
424
424
  }
425
425
  }
426
+ /**
427
+ * Verify draft threading by checking if draft appears in conversation
428
+ */
429
+ async verifyDraftThreading(draftId, originalConversationId) {
430
+ try {
431
+ const graphClient = await this.getGraphClient();
432
+ // Get the draft details
433
+ const draft = await graphClient
434
+ .api(`/me/messages/${draftId}`)
435
+ .select('id,subject,conversationId,parentFolderId,internetMessageHeaders,isDraft')
436
+ .get();
437
+ // Check if conversation IDs match
438
+ const conversationMatch = draft.conversationId === originalConversationId;
439
+ // Get all messages in the conversation to see if draft appears
440
+ let conversationMessages = [];
441
+ try {
442
+ const convResult = await graphClient
443
+ .api('/me/messages')
444
+ .filter(`conversationId eq '${originalConversationId}'`)
445
+ .select('id,subject,isDraft,parentFolderId')
446
+ .get();
447
+ conversationMessages = convResult.value || [];
448
+ }
449
+ catch (convError) {
450
+ logger.log(`Could not fetch conversation messages: ${convError}`);
451
+ }
452
+ const draftInConversation = conversationMessages.some((msg) => msg.id === draftId);
453
+ return {
454
+ isThreaded: conversationMatch && draftInConversation,
455
+ details: {
456
+ draftConversationId: draft.conversationId,
457
+ originalConversationId,
458
+ conversationMatch,
459
+ draftInConversation,
460
+ draftFolder: draft.parentFolderId,
461
+ conversationMessagesCount: conversationMessages.length,
462
+ internetMessageHeaders: draft.internetMessageHeaders
463
+ }
464
+ };
465
+ }
466
+ catch (error) {
467
+ logger.error('Error verifying draft threading:', error);
468
+ return {
469
+ isThreaded: false,
470
+ details: { error: String(error) }
471
+ };
472
+ }
473
+ }
426
474
  /**
427
475
  * List draft emails
428
476
  */
@@ -477,27 +525,143 @@ export class MS365Operations {
477
525
  async createReplyDraft(originalMessageId, body, replyToAll = false) {
478
526
  try {
479
527
  const graphClient = await this.getGraphClient();
480
- // Use Microsoft Graph's createReply endpoint for proper threading
481
- const endpoint = replyToAll
482
- ? `/me/messages/${originalMessageId}/createReplyAll`
483
- : `/me/messages/${originalMessageId}/createReply`;
484
- const requestBody = {};
485
- // If body is provided, include it as a comment
486
- if (body) {
487
- requestBody.comment = body;
488
- }
489
- const replyDraft = await graphClient
490
- .api(endpoint)
491
- .post(requestBody);
492
- return {
493
- id: replyDraft.id,
494
- subject: replyDraft.subject,
495
- conversationId: replyDraft.conversationId,
496
- toRecipients: replyDraft.toRecipients?.map((r) => r.emailAddress.address),
497
- ccRecipients: replyDraft.ccRecipients?.map((r) => r.emailAddress.address),
498
- bodyPreview: replyDraft.bodyPreview,
499
- isDraft: replyDraft.isDraft
500
- };
528
+ logger.log(`Creating reply draft for message: ${originalMessageId}`);
529
+ logger.log(`Reply to all: ${replyToAll}`);
530
+ // First, get the original message to include its content in the reply
531
+ const originalMessage = await graphClient
532
+ .api(`/me/messages/${originalMessageId}`)
533
+ .select('id,subject,from,toRecipients,ccRecipients,conversationId,parentFolderId,internetMessageId,internetMessageHeaders,conversationIndex,body,sentDateTime')
534
+ .get();
535
+ // Build the complete reply body with original content
536
+ const originalBodyContent = originalMessage.body?.content || '';
537
+ const fromDisplay = originalMessage.from?.emailAddress?.name || originalMessage.from?.emailAddress?.address || '';
538
+ const sentDate = new Date(originalMessage.sentDateTime).toLocaleString();
539
+ const completeReplyBody = `${body || ''}
540
+
541
+ <br><br>
542
+ <div style="border-left: 2px solid #ccc; padding-left: 10px; margin-top: 10px;">
543
+ <p><strong>From:</strong> ${fromDisplay}<br>
544
+ <strong>Sent:</strong> ${sentDate}<br>
545
+ <strong>Subject:</strong> ${originalMessage.subject}</p>
546
+ <hr style="border: none; border-top: 1px solid #ccc; margin: 10px 0;">
547
+ ${originalBodyContent}
548
+ </div>`;
549
+ // First, try using the official Microsoft Graph createReply endpoint for proper threading
550
+ try {
551
+ const endpoint = replyToAll
552
+ ? `/me/messages/${originalMessageId}/createReplyAll`
553
+ : `/me/messages/${originalMessageId}/createReply`;
554
+ logger.log(`Using official Graph API endpoint: ${endpoint}`);
555
+ const replyDraft = await graphClient
556
+ .api(endpoint)
557
+ .post({
558
+ message: {
559
+ body: {
560
+ contentType: 'html',
561
+ content: completeReplyBody
562
+ }
563
+ }
564
+ });
565
+ logger.log(`Reply draft created successfully with ID: ${replyDraft.id}`);
566
+ logger.log(`Draft conversation ID: ${replyDraft.conversationId}`);
567
+ logger.log(`Draft appears as threaded reply in conversation with original content`);
568
+ return {
569
+ id: replyDraft.id,
570
+ subject: replyDraft.subject,
571
+ conversationId: replyDraft.conversationId,
572
+ toRecipients: replyDraft.toRecipients?.map((r) => r.emailAddress.address),
573
+ ccRecipients: replyDraft.ccRecipients?.map((r) => r.emailAddress.address),
574
+ bodyPreview: replyDraft.bodyPreview,
575
+ isDraft: replyDraft.isDraft,
576
+ parentFolderId: replyDraft.parentFolderId
577
+ };
578
+ }
579
+ catch (officialApiError) {
580
+ logger.log(`Official API failed: ${officialApiError}, trying fallback approach`);
581
+ logger.log(`Fallback: Creating manual reply draft with enhanced threading`);
582
+ logger.log(`Original message conversation ID: ${originalMessage.conversationId}`);
583
+ logger.log(`Original message folder: ${originalMessage.parentFolderId}`);
584
+ // Build proper References header from existing chain
585
+ let referencesHeader = originalMessage.internetMessageId || originalMessage.id;
586
+ if (originalMessage.internetMessageHeaders) {
587
+ const existingReferences = originalMessage.internetMessageHeaders.find((h) => h.name.toLowerCase() === 'references');
588
+ if (existingReferences && existingReferences.value) {
589
+ referencesHeader = `${existingReferences.value} ${referencesHeader}`;
590
+ }
591
+ }
592
+ const currentUserEmail = await this.getCurrentUserEmail();
593
+ const draftBody = {
594
+ subject: originalMessage.subject?.startsWith('Re:') ? originalMessage.subject : `Re: ${originalMessage.subject}`,
595
+ body: {
596
+ contentType: 'html',
597
+ content: completeReplyBody
598
+ },
599
+ conversationId: originalMessage.conversationId,
600
+ internetMessageHeaders: [
601
+ {
602
+ name: 'X-In-Reply-To',
603
+ value: originalMessage.internetMessageId || originalMessage.id
604
+ },
605
+ {
606
+ name: 'X-References',
607
+ value: referencesHeader
608
+ },
609
+ {
610
+ name: 'X-Thread-Topic',
611
+ value: originalMessage.subject?.replace(/^Re:\s*/i, '') || ''
612
+ }
613
+ ]
614
+ };
615
+ // Include conversation index if available for proper Outlook threading
616
+ if (originalMessage.conversationIndex) {
617
+ draftBody.internetMessageHeaders.push({
618
+ name: 'X-Thread-Index',
619
+ value: originalMessage.conversationIndex
620
+ });
621
+ }
622
+ // Set recipients based on reply type
623
+ if (replyToAll) {
624
+ draftBody.toRecipients = [
625
+ ...(originalMessage.from ? [{ emailAddress: originalMessage.from.emailAddress }] : []),
626
+ ...(originalMessage.toRecipients || []).filter((r) => r.emailAddress.address !== currentUserEmail)
627
+ ];
628
+ draftBody.ccRecipients = originalMessage.ccRecipients || [];
629
+ }
630
+ else {
631
+ draftBody.toRecipients = originalMessage.from ? [{ emailAddress: originalMessage.from.emailAddress }] : [];
632
+ }
633
+ // Create the fallback draft
634
+ const replyDraft = await graphClient
635
+ .api('/me/messages')
636
+ .post(draftBody);
637
+ logger.log(`Fallback reply draft created with ID: ${replyDraft.id}`);
638
+ logger.log(`Draft conversation ID: ${replyDraft.conversationId}`);
639
+ // Try to move the draft to the same folder as the original message for better threading
640
+ if (originalMessage.parentFolderId && originalMessage.parentFolderId !== 'drafts') {
641
+ try {
642
+ await graphClient
643
+ .api(`/me/messages/${replyDraft.id}/move`)
644
+ .post({
645
+ destinationId: originalMessage.parentFolderId
646
+ });
647
+ logger.log(`Draft moved to original message folder: ${originalMessage.parentFolderId}`);
648
+ }
649
+ catch (moveError) {
650
+ logger.log(`Could not move draft to original folder: ${moveError}`);
651
+ // This is not critical, draft will remain in drafts folder
652
+ }
653
+ }
654
+ return {
655
+ id: replyDraft.id,
656
+ subject: replyDraft.subject,
657
+ conversationId: replyDraft.conversationId,
658
+ toRecipients: replyDraft.toRecipients?.map((r) => r.emailAddress.address),
659
+ ccRecipients: replyDraft.ccRecipients?.map((r) => r.emailAddress.address),
660
+ bodyPreview: replyDraft.bodyPreview,
661
+ isDraft: replyDraft.isDraft,
662
+ parentFolderId: originalMessage.parentFolderId
663
+ };
664
+ }
501
665
  }
502
666
  catch (error) {
503
667
  throw new Error(`Error creating reply draft: ${error}`);
@@ -509,23 +673,106 @@ export class MS365Operations {
509
673
  async createForwardDraft(originalMessageId, comment) {
510
674
  try {
511
675
  const graphClient = await this.getGraphClient();
512
- // Use Microsoft Graph's createForward endpoint for proper threading
513
- const endpoint = `/me/messages/${originalMessageId}/createForward`;
514
- const requestBody = {};
515
- // If comment is provided, include it
516
- if (comment) {
517
- requestBody.comment = comment;
518
- }
519
- const forwardDraft = await graphClient
520
- .api(endpoint)
521
- .post(requestBody);
522
- return {
523
- id: forwardDraft.id,
524
- subject: forwardDraft.subject,
525
- conversationId: forwardDraft.conversationId,
526
- bodyPreview: forwardDraft.bodyPreview,
527
- isDraft: forwardDraft.isDraft
528
- };
676
+ logger.log(`Creating forward draft for message: ${originalMessageId}`);
677
+ // First, try using the official Microsoft Graph createForward endpoint for proper threading
678
+ try {
679
+ logger.log(`Using official Graph API endpoint: /me/messages/${originalMessageId}/createForward`);
680
+ const forwardDraft = await graphClient
681
+ .api(`/me/messages/${originalMessageId}/createForward`)
682
+ .post({
683
+ message: {
684
+ body: {
685
+ contentType: 'html',
686
+ content: comment || ''
687
+ }
688
+ }
689
+ });
690
+ logger.log(`Forward draft created successfully with ID: ${forwardDraft.id}`);
691
+ logger.log(`Draft conversation ID: ${forwardDraft.conversationId}`);
692
+ logger.log(`Draft appears as threaded forward in conversation`);
693
+ return {
694
+ id: forwardDraft.id,
695
+ subject: forwardDraft.subject,
696
+ conversationId: forwardDraft.conversationId,
697
+ bodyPreview: forwardDraft.bodyPreview,
698
+ isDraft: forwardDraft.isDraft,
699
+ parentFolderId: forwardDraft.parentFolderId
700
+ };
701
+ }
702
+ catch (officialApiError) {
703
+ logger.log(`Official API failed: ${officialApiError}, trying fallback approach`);
704
+ // Fallback to manual creation if the official endpoint fails
705
+ const originalMessage = await graphClient
706
+ .api(`/me/messages/${originalMessageId}`)
707
+ .select('id,subject,from,toRecipients,ccRecipients,conversationId,parentFolderId,internetMessageId,internetMessageHeaders,conversationIndex,body,sentDateTime')
708
+ .get();
709
+ logger.log(`Fallback: Creating manual forward draft with enhanced threading`);
710
+ logger.log(`Original message conversation ID: ${originalMessage.conversationId}`);
711
+ logger.log(`Original message folder: ${originalMessage.parentFolderId}`);
712
+ // Build proper References header from existing chain
713
+ let referencesHeader = originalMessage.internetMessageId || originalMessage.id;
714
+ if (originalMessage.internetMessageHeaders) {
715
+ const existingReferences = originalMessage.internetMessageHeaders.find((h) => h.name.toLowerCase() === 'references');
716
+ if (existingReferences && existingReferences.value) {
717
+ referencesHeader = `${existingReferences.value} ${referencesHeader}`;
718
+ }
719
+ }
720
+ const forwardedBody = `${comment ? comment + '\n\n' : ''}---------- Forwarded message ----------\nFrom: ${originalMessage.from?.emailAddress?.name || originalMessage.from?.emailAddress?.address}\nDate: ${originalMessage.sentDateTime}\nSubject: ${originalMessage.subject}\nTo: ${originalMessage.toRecipients?.map((r) => r.emailAddress.address).join(', ')}\n\n${originalMessage.body?.content || ''}`;
721
+ const draftBody = {
722
+ subject: originalMessage.subject?.startsWith('Fwd:') ? originalMessage.subject : `Fwd: ${originalMessage.subject}`,
723
+ body: {
724
+ contentType: originalMessage.body?.contentType || 'html',
725
+ content: forwardedBody
726
+ },
727
+ conversationId: originalMessage.conversationId,
728
+ internetMessageHeaders: [
729
+ {
730
+ name: 'X-References',
731
+ value: referencesHeader
732
+ },
733
+ {
734
+ name: 'X-Thread-Topic',
735
+ value: originalMessage.subject?.replace(/^(Re:|Fwd?):\s*/i, '') || ''
736
+ }
737
+ ]
738
+ };
739
+ // Include conversation index if available for proper Outlook threading
740
+ if (originalMessage.conversationIndex) {
741
+ draftBody.internetMessageHeaders.push({
742
+ name: 'X-Thread-Index',
743
+ value: originalMessage.conversationIndex
744
+ });
745
+ }
746
+ // Create the fallback draft
747
+ const forwardDraft = await graphClient
748
+ .api('/me/messages')
749
+ .post(draftBody);
750
+ logger.log(`Fallback forward draft created with ID: ${forwardDraft.id}`);
751
+ logger.log(`Draft conversation ID: ${forwardDraft.conversationId}`);
752
+ // Try to move the draft to the same folder as the original message for better threading
753
+ if (originalMessage.parentFolderId && originalMessage.parentFolderId !== 'drafts') {
754
+ try {
755
+ await graphClient
756
+ .api(`/me/messages/${forwardDraft.id}/move`)
757
+ .post({
758
+ destinationId: originalMessage.parentFolderId
759
+ });
760
+ logger.log(`Draft moved to original message folder: ${originalMessage.parentFolderId}`);
761
+ }
762
+ catch (moveError) {
763
+ logger.log(`Could not move draft to original folder: ${moveError}`);
764
+ // This is not critical, draft will remain in drafts folder
765
+ }
766
+ }
767
+ return {
768
+ id: forwardDraft.id,
769
+ subject: forwardDraft.subject,
770
+ conversationId: forwardDraft.conversationId,
771
+ bodyPreview: forwardDraft.bodyPreview,
772
+ isDraft: forwardDraft.isDraft,
773
+ parentFolderId: originalMessage.parentFolderId
774
+ };
775
+ }
529
776
  }
530
777
  catch (error) {
531
778
  throw new Error(`Error creating forward draft: ${error}`);
@@ -643,163 +890,591 @@ export class MS365Operations {
643
890
  }
644
891
  }
645
892
  /**
646
- * Search emails with criteria
893
+ * Advanced email search using Microsoft Graph Search API with KQL - Persistent search until results found
647
894
  */
648
895
  async searchEmails(criteria = {}) {
649
896
  return await this.executeWithAuth(async () => {
650
897
  const graphClient = await this.getGraphClient();
898
+ logger.log(`🔍 Starting email search with criteria:`, JSON.stringify(criteria, null, 2));
651
899
  // Create cache key from criteria
652
900
  const cacheKey = JSON.stringify(criteria);
653
901
  const cachedResults = this.getCachedResults(cacheKey);
654
902
  if (cachedResults) {
903
+ logger.log('📦 Returning cached results');
655
904
  return cachedResults;
656
905
  }
657
- // For complex searches, use a simpler approach
906
+ const maxResults = criteria.maxResults || 50;
907
+ let allMessages = [];
908
+ let searchAttempts = 0;
909
+ const maxAttempts = 6; // Try multiple strategies
910
+ logger.log(`🔍 Starting persistent search with criteria: ${JSON.stringify(criteria)}`);
658
911
  try {
659
- // Start with a basic query to get recent emails
660
- const apiCall = graphClient.api('/me/messages')
661
- .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
662
- .orderby('receivedDateTime desc')
663
- .top(criteria.maxResults || 100);
664
- const result = await apiCall.get();
665
- let messages = result.value?.map((email) => ({
666
- id: email.id,
667
- subject: email.subject || '',
668
- from: {
669
- name: email.from?.emailAddress?.name || '',
670
- address: email.from?.emailAddress?.address || ''
671
- },
672
- toRecipients: email.toRecipients?.map((recipient) => ({
673
- name: recipient.emailAddress?.name || '',
674
- address: recipient.emailAddress?.address || ''
675
- })) || [],
676
- ccRecipients: email.ccRecipients?.map((recipient) => ({
677
- name: recipient.emailAddress?.name || '',
678
- address: recipient.emailAddress?.address || ''
679
- })) || [],
680
- receivedDateTime: email.receivedDateTime,
681
- sentDateTime: email.sentDateTime,
682
- bodyPreview: email.bodyPreview || '',
683
- isRead: email.isRead || false,
684
- hasAttachments: email.hasAttachments || false,
685
- importance: email.importance || 'normal',
686
- conversationId: email.conversationId || '',
687
- parentFolderId: email.parentFolderId || '',
688
- webLink: email.webLink || ''
689
- })) || [];
690
- // Apply manual filtering for all criteria
691
- messages = this.applyManualFiltering(messages, criteria);
692
- // For emails with attachments, get attachment counts
693
- for (const message of messages) {
694
- if (message.hasAttachments) {
695
- try {
696
- const attachments = await graphClient
697
- .api(`/me/messages/${message.id}/attachments`)
698
- .select('id')
699
- .get();
700
- message.attachments = new Array(attachments.value?.length || 0);
912
+ // Strategy 1: Use reliable OData filter search first (has proper IDs)
913
+ if (searchAttempts < maxAttempts) {
914
+ searchAttempts++;
915
+ logger.log(`🔍 Attempt ${searchAttempts}: Using reliable OData filter search first`);
916
+ const filterResults = await this.performFilteredSearch(criteria, maxResults * 2);
917
+ allMessages.push(...filterResults);
918
+ if (allMessages.length > 0) {
919
+ logger.log(`✅ Found ${allMessages.length} results with OData filter search`);
920
+ }
921
+ }
922
+ // Strategy 2: Use Microsoft Graph Search API for text-based queries (backup)
923
+ if (allMessages.length === 0 && criteria.query && searchAttempts < maxAttempts) {
924
+ searchAttempts++;
925
+ logger.log(`🔍 Attempt ${searchAttempts}: Using Graph Search API for query: "${criteria.query}"`);
926
+ const searchResults = await this.performGraphSearch(criteria.query, maxResults * 2); // Search for more to increase chances
927
+ allMessages.push(...searchResults);
928
+ if (allMessages.length > 0) {
929
+ logger.log(`✅ Found ${allMessages.length} results with Graph Search API`);
930
+ }
931
+ }
932
+ // Strategy 3: Use KQL (Keyword Query Language) for advanced searches
933
+ if (allMessages.length === 0 && searchAttempts < maxAttempts) {
934
+ const kqlQuery = this.buildKQLQuery(criteria);
935
+ if (kqlQuery) {
936
+ searchAttempts++;
937
+ logger.log(`🔍 Attempt ${searchAttempts}: Using KQL search: "${kqlQuery}"`);
938
+ const kqlResults = await this.performKQLSearch(kqlQuery, maxResults * 2);
939
+ allMessages.push(...kqlResults);
940
+ if (allMessages.length > 0) {
941
+ logger.log(`✅ Found ${allMessages.length} results with KQL search`);
701
942
  }
702
- catch (error) {
703
- logger.error(`Error getting attachment count for message ${message.id}:`, error);
704
- message.attachments = [];
943
+ }
944
+ }
945
+ // Strategy 4: Try relaxed KQL search (remove some constraints)
946
+ if (allMessages.length === 0 && searchAttempts < maxAttempts) {
947
+ const relaxedKQL = this.buildRelaxedKQLQuery(criteria);
948
+ if (relaxedKQL && relaxedKQL !== this.buildKQLQuery(criteria)) {
949
+ searchAttempts++;
950
+ logger.log(`🔍 Attempt ${searchAttempts}: Using relaxed KQL search: "${relaxedKQL}"`);
951
+ const relaxedResults = await this.performKQLSearch(relaxedKQL, maxResults * 2);
952
+ allMessages.push(...relaxedResults);
953
+ if (allMessages.length > 0) {
954
+ logger.log(`✅ Found ${allMessages.length} results with relaxed KQL search`);
705
955
  }
706
956
  }
707
957
  }
958
+ // Strategy 5: If we found results but they have UNKNOWN IDs, try to resolve them
959
+ if (allMessages.length > 0 && allMessages.some(msg => msg.id === 'UNKNOWN')) {
960
+ logger.log(`🔍 Attempting to resolve UNKNOWN message IDs using direct message queries`);
961
+ allMessages = await this.resolveUnknownMessageIds(allMessages, criteria);
962
+ }
963
+ // Strategy 6: Partial text search across recent emails (expanded scope)
964
+ if (allMessages.length === 0 && searchAttempts < maxAttempts) {
965
+ searchAttempts++;
966
+ logger.log(`🔍 Attempt ${searchAttempts}: Using expanded partial text search`);
967
+ const partialResults = await this.performPartialTextSearch(criteria, maxResults * 4); // Very broad search
968
+ allMessages.push(...partialResults);
969
+ if (allMessages.length > 0) {
970
+ logger.log(`✅ Found ${allMessages.length} results with partial text search`);
971
+ }
972
+ }
973
+ // Strategy 7: Fallback to basic search with maximum scope
974
+ if (allMessages.length === 0 && searchAttempts < maxAttempts) {
975
+ searchAttempts++;
976
+ logger.log(`🔍 Attempt ${searchAttempts}: Using fallback basic search with maximum scope`);
977
+ const basicResult = await this.performBasicSearch({
978
+ ...criteria,
979
+ maxResults: Math.max(maxResults * 5, 500) // Very large scope
980
+ });
981
+ allMessages.push(...basicResult.messages);
982
+ if (allMessages.length > 0) {
983
+ logger.log(`✅ Found ${allMessages.length} results with basic search fallback`);
984
+ }
985
+ }
986
+ // Remove duplicates and apply advanced filtering
987
+ const uniqueMessages = this.removeDuplicateMessages(allMessages);
988
+ logger.log(`📧 After deduplication: ${uniqueMessages.length} unique messages`);
989
+ const filteredMessages = this.applyAdvancedFiltering(uniqueMessages, criteria);
990
+ logger.log(`📧 After advanced filtering: ${filteredMessages.length} filtered messages`);
991
+ // Sort by relevance and date
992
+ const sortedMessages = this.sortSearchResults(filteredMessages, criteria);
993
+ // If still no results, try one more time with very relaxed criteria
994
+ if (sortedMessages.length === 0 && (criteria.query || criteria.from || criteria.subject)) {
995
+ logger.log(`🔍 Final attempt: Ultra-relaxed search for any partial matches`);
996
+ const ultraRelaxedResults = await this.performUltraRelaxedSearch(criteria, maxResults * 3);
997
+ const finalFiltered = this.applyUltraRelaxedFiltering(ultraRelaxedResults, criteria);
998
+ if (finalFiltered.length > 0) {
999
+ logger.log(`✅ Ultra-relaxed search found ${finalFiltered.length} results`);
1000
+ const limitedMessages = finalFiltered.slice(0, maxResults);
1001
+ const searchResult = {
1002
+ messages: limitedMessages,
1003
+ hasMore: finalFiltered.length > maxResults
1004
+ };
1005
+ this.setCachedResults(cacheKey, searchResult);
1006
+ return searchResult;
1007
+ }
1008
+ }
1009
+ // Limit results
1010
+ const limitedMessages = sortedMessages.slice(0, maxResults);
708
1011
  const searchResult = {
709
- messages,
710
- hasMore: !!result['@odata.nextLink']
1012
+ messages: limitedMessages,
1013
+ hasMore: sortedMessages.length > maxResults
711
1014
  };
712
1015
  this.setCachedResults(cacheKey, searchResult);
1016
+ logger.log(`🔍 Search completed after ${searchAttempts} attempts: ${limitedMessages.length} final results`);
1017
+ if (limitedMessages.length === 0) {
1018
+ logger.log(`❌ No results found despite ${searchAttempts} search strategies. Consider broadening search criteria.`);
1019
+ }
713
1020
  return searchResult;
714
1021
  }
715
1022
  catch (error) {
716
- logger.error('Error in email search:', error);
717
- throw error;
1023
+ logger.error('Error in persistent email search:', error);
1024
+ // Final fallback - get some recent emails and filter them
1025
+ logger.log('🔄 Final fallback: getting recent emails to filter manually');
1026
+ return await this.performBasicSearch(criteria);
718
1027
  }
719
1028
  }, 'searchEmails');
720
1029
  }
721
1030
  /**
722
- * Apply manual filtering to search results (used when $filter can't be used with $search)
1031
+ * Resolve UNKNOWN message IDs by finding messages using alternative search criteria
723
1032
  */
724
- applyManualFiltering(messages, criteria) {
1033
+ async resolveUnknownMessageIds(messages, criteria) {
1034
+ try {
1035
+ const graphClient = await this.getGraphClient();
1036
+ const resolvedMessages = [];
1037
+ for (const message of messages) {
1038
+ if (message.id === 'UNKNOWN') {
1039
+ // Try to find this message using subject and sender
1040
+ try {
1041
+ const searchFilter = [];
1042
+ if (message.subject) {
1043
+ searchFilter.push(`subject eq '${message.subject.replace(/'/g, "''")}'`);
1044
+ }
1045
+ if (message.from?.address) {
1046
+ searchFilter.push(`from/emailAddress/address eq '${message.from.address}'`);
1047
+ }
1048
+ if (searchFilter.length > 0) {
1049
+ const result = await graphClient
1050
+ .api('/me/messages')
1051
+ .filter(searchFilter.join(' and '))
1052
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
1053
+ .top(1)
1054
+ .get();
1055
+ if (result.value && result.value.length > 0) {
1056
+ const resolvedMessage = this.mapEmailResult(result.value[0]);
1057
+ logger.log(`✅ Resolved UNKNOWN ID to: ${resolvedMessage.id}`);
1058
+ resolvedMessages.push(resolvedMessage);
1059
+ continue;
1060
+ }
1061
+ }
1062
+ }
1063
+ catch (resolveError) {
1064
+ logger.log(`⚠️ Could not resolve message ID: ${resolveError}`);
1065
+ }
1066
+ }
1067
+ // If we couldn't resolve it, keep the original message
1068
+ resolvedMessages.push(message);
1069
+ }
1070
+ return resolvedMessages;
1071
+ }
1072
+ catch (error) {
1073
+ logger.log(`⚠️ Error resolving unknown message IDs: ${error}`);
1074
+ return messages; // Return original messages if resolution fails
1075
+ }
1076
+ }
1077
+ /**
1078
+ * Use Microsoft Graph Search API for full-text search
1079
+ */
1080
+ async performGraphSearch(query, maxResults) {
1081
+ try {
1082
+ const graphClient = await this.getGraphClient();
1083
+ const searchRequest = {
1084
+ requests: [
1085
+ {
1086
+ entityTypes: ['message'],
1087
+ query: {
1088
+ queryString: query
1089
+ },
1090
+ from: 0,
1091
+ size: Math.min(maxResults, 1000), // Graph Search max is 1000
1092
+ fields: ['id', 'subject', 'from', 'toRecipients', 'ccRecipients', 'receivedDateTime', 'sentDateTime', 'bodyPreview', 'isRead', 'hasAttachments', 'importance', 'conversationId', 'parentFolderId', 'webLink']
1093
+ }
1094
+ ]
1095
+ };
1096
+ logger.log(`🔍 Graph Search request:`, JSON.stringify(searchRequest, null, 2));
1097
+ const result = await graphClient
1098
+ .api('/search/query')
1099
+ .post(searchRequest);
1100
+ logger.log(`🔍 Graph Search raw response:`, JSON.stringify(result, null, 2));
1101
+ const messages = [];
1102
+ if (result.value?.[0]?.hitsContainers?.[0]?.hits) {
1103
+ logger.log(`🔍 Processing ${result.value[0].hitsContainers[0].hits.length} hits`);
1104
+ for (const hit of result.value[0].hitsContainers[0].hits) {
1105
+ const email = hit.resource;
1106
+ logger.log(`🔍 Raw email from Graph Search:`, JSON.stringify(email, null, 2));
1107
+ const mappedEmail = this.mapEmailResult(email);
1108
+ logger.log(`🔍 Mapped email:`, JSON.stringify(mappedEmail, null, 2));
1109
+ messages.push(mappedEmail);
1110
+ }
1111
+ }
1112
+ else {
1113
+ logger.log(`🔍 No hits found in Graph Search response structure`);
1114
+ }
1115
+ logger.log(`📧 Graph Search returned ${messages.length} results`);
1116
+ return messages;
1117
+ }
1118
+ catch (error) {
1119
+ logger.log(`❌ Graph Search failed: ${error}. Falling back to alternative search.`);
1120
+ logger.error(`❌ Graph Search error details:`, JSON.stringify(error, null, 2));
1121
+ return [];
1122
+ }
1123
+ }
1124
+ /**
1125
+ * Build KQL (Keyword Query Language) for advanced searches
1126
+ */
1127
+ buildKQLQuery(criteria) {
1128
+ const kqlParts = [];
1129
+ if (criteria.from) {
1130
+ // Smart sender search - handles partial names, emails, display names
1131
+ const fromTerm = criteria.from.trim();
1132
+ if (fromTerm.includes('@')) {
1133
+ kqlParts.push(`from:${fromTerm}`);
1134
+ }
1135
+ else {
1136
+ // For names, search in both from field and sender
1137
+ kqlParts.push(`(from:"${fromTerm}" OR sender:"${fromTerm}" OR from:*${fromTerm}*)`);
1138
+ }
1139
+ }
1140
+ if (criteria.to) {
1141
+ kqlParts.push(`to:${criteria.to}`);
1142
+ }
1143
+ if (criteria.cc) {
1144
+ kqlParts.push(`cc:${criteria.cc}`);
1145
+ }
1146
+ if (criteria.subject) {
1147
+ kqlParts.push(`subject:"${criteria.subject}"`);
1148
+ }
1149
+ if (criteria.hasAttachment === true) {
1150
+ kqlParts.push('hasattachment:true');
1151
+ }
1152
+ else if (criteria.hasAttachment === false) {
1153
+ kqlParts.push('hasattachment:false');
1154
+ }
1155
+ if (criteria.isUnread === true) {
1156
+ kqlParts.push('isread:false');
1157
+ }
1158
+ else if (criteria.isUnread === false) {
1159
+ kqlParts.push('isread:true');
1160
+ }
1161
+ if (criteria.importance) {
1162
+ kqlParts.push(`importance:${criteria.importance}`);
1163
+ }
1164
+ if (criteria.after) {
1165
+ const afterDate = new Date(criteria.after).toISOString().split('T')[0];
1166
+ kqlParts.push(`received>=${afterDate}`);
1167
+ }
1168
+ if (criteria.before) {
1169
+ const beforeDate = new Date(criteria.before).toISOString().split('T')[0];
1170
+ kqlParts.push(`received<=${beforeDate}`);
1171
+ }
1172
+ if (criteria.folder) {
1173
+ kqlParts.push(`foldernames:"${criteria.folder}"`);
1174
+ }
1175
+ return kqlParts.join(' AND ');
1176
+ }
1177
+ /**
1178
+ * Perform KQL-based search using Graph Search API
1179
+ */
1180
+ async performKQLSearch(kqlQuery, maxResults) {
1181
+ try {
1182
+ const graphClient = await this.getGraphClient();
1183
+ const searchRequest = {
1184
+ requests: [
1185
+ {
1186
+ entityTypes: ['message'],
1187
+ query: {
1188
+ queryString: kqlQuery
1189
+ },
1190
+ from: 0,
1191
+ size: Math.min(maxResults, 1000),
1192
+ fields: ['id', 'subject', 'from', 'toRecipients', 'ccRecipients', 'receivedDateTime', 'sentDateTime', 'bodyPreview', 'isRead', 'hasAttachments', 'importance', 'conversationId', 'parentFolderId', 'webLink']
1193
+ }
1194
+ ]
1195
+ };
1196
+ const result = await graphClient
1197
+ .api('/search/query')
1198
+ .post(searchRequest);
1199
+ const messages = [];
1200
+ if (result.value?.[0]?.hitsContainers?.[0]?.hits) {
1201
+ for (const hit of result.value[0].hitsContainers[0].hits) {
1202
+ const email = hit.resource;
1203
+ messages.push(this.mapEmailResult(email));
1204
+ }
1205
+ }
1206
+ logger.log(`📧 KQL Search returned ${messages.length} results`);
1207
+ return messages;
1208
+ }
1209
+ catch (error) {
1210
+ logger.log(`❌ KQL Search failed: ${error}. Falling back to filter search.`);
1211
+ return [];
1212
+ }
1213
+ }
1214
+ /**
1215
+ * Fallback to OData filter-based search
1216
+ */
1217
+ async performFilteredSearch(criteria, maxResults) {
1218
+ try {
1219
+ const graphClient = await this.getGraphClient();
1220
+ let apiCall = graphClient
1221
+ .api('/me/messages')
1222
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
1223
+ .orderby('receivedDateTime desc')
1224
+ .top(Math.min(maxResults, 999));
1225
+ // Apply OData filters where possible
1226
+ const filters = [];
1227
+ if (criteria.from && criteria.from.includes('@')) {
1228
+ filters.push(`from/emailAddress/address eq '${criteria.from}'`);
1229
+ }
1230
+ if (criteria.to && criteria.to.includes('@')) {
1231
+ filters.push(`toRecipients/any(r: r/emailAddress/address eq '${criteria.to}')`);
1232
+ }
1233
+ if (criteria.isUnread !== undefined) {
1234
+ filters.push(`isRead eq ${!criteria.isUnread}`);
1235
+ }
1236
+ if (criteria.hasAttachment !== undefined) {
1237
+ filters.push(`hasAttachments eq ${criteria.hasAttachment}`);
1238
+ }
1239
+ if (criteria.importance) {
1240
+ filters.push(`importance eq '${criteria.importance}'`);
1241
+ }
1242
+ if (filters.length > 0) {
1243
+ apiCall = apiCall.filter(filters.join(' and '));
1244
+ }
1245
+ // Apply folder filter using specific folder API
1246
+ if (criteria.folder) {
1247
+ const folders = await this.findFolderByName(criteria.folder);
1248
+ if (folders.length > 0) {
1249
+ apiCall = graphClient
1250
+ .api(`/me/mailFolders/${folders[0].id}/messages`)
1251
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
1252
+ .orderby('receivedDateTime desc')
1253
+ .top(Math.min(maxResults, 999));
1254
+ }
1255
+ }
1256
+ const result = await apiCall.get();
1257
+ const messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
1258
+ logger.log(`📧 Filtered Search returned ${messages.length} results`);
1259
+ return messages;
1260
+ }
1261
+ catch (error) {
1262
+ logger.log(`❌ Filtered Search failed: ${error}`);
1263
+ return [];
1264
+ }
1265
+ }
1266
+ /**
1267
+ * Fallback to basic search (original implementation)
1268
+ */
1269
+ async performBasicSearch(criteria) {
1270
+ logger.log('🔄 Using fallback basic search');
1271
+ const graphClient = await this.getGraphClient();
1272
+ const result = await graphClient
1273
+ .api('/me/messages')
1274
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
1275
+ .orderby('receivedDateTime desc')
1276
+ .top(criteria.maxResults || 50)
1277
+ .get();
1278
+ let messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
1279
+ messages = this.applyManualFiltering(messages, criteria);
1280
+ return {
1281
+ messages,
1282
+ hasMore: !!result['@odata.nextLink']
1283
+ };
1284
+ }
1285
+ /**
1286
+ * Map email result to EmailInfo format
1287
+ */
1288
+ mapEmailResult(email) {
1289
+ // Extract ID from various possible locations in the response
1290
+ let emailId = email.id;
1291
+ if (!emailId) {
1292
+ // Try alternative ID sources
1293
+ emailId = email['@odata.id'] ||
1294
+ email.internetMessageId ||
1295
+ email._id ||
1296
+ email.messageId ||
1297
+ email.resource?.id;
1298
+ // If still no ID, try to extract from webLink or other fields
1299
+ if (!emailId && email.webLink) {
1300
+ const linkMatch = email.webLink.match(/itemid=([^&]+)/i);
1301
+ if (linkMatch) {
1302
+ emailId = decodeURIComponent(linkMatch[1]);
1303
+ }
1304
+ }
1305
+ // Log the raw email object for debugging when ID is missing
1306
+ if (!emailId) {
1307
+ logger.log('DEBUG: Email object missing ID after all attempts:', JSON.stringify(email, null, 2));
1308
+ emailId = 'UNKNOWN';
1309
+ }
1310
+ else {
1311
+ logger.log(`DEBUG: Found email ID from alternative source: ${emailId}`);
1312
+ }
1313
+ }
1314
+ return {
1315
+ id: emailId,
1316
+ subject: email.subject || '',
1317
+ from: {
1318
+ name: email.from?.emailAddress?.name || '',
1319
+ address: email.from?.emailAddress?.address || ''
1320
+ },
1321
+ toRecipients: email.toRecipients?.map((recipient) => ({
1322
+ name: recipient.emailAddress?.name || '',
1323
+ address: recipient.emailAddress?.address || ''
1324
+ })) || [],
1325
+ ccRecipients: email.ccRecipients?.map((recipient) => ({
1326
+ name: recipient.emailAddress?.name || '',
1327
+ address: recipient.emailAddress?.address || ''
1328
+ })) || [],
1329
+ receivedDateTime: email.receivedDateTime,
1330
+ sentDateTime: email.sentDateTime,
1331
+ bodyPreview: email.bodyPreview || '',
1332
+ isRead: email.isRead || false,
1333
+ hasAttachments: email.hasAttachments || false,
1334
+ importance: email.importance || 'normal',
1335
+ conversationId: email.conversationId || '',
1336
+ parentFolderId: email.parentFolderId || '',
1337
+ webLink: email.webLink || '',
1338
+ attachments: []
1339
+ };
1340
+ }
1341
+ /**
1342
+ * Remove duplicate messages based on ID
1343
+ */
1344
+ removeDuplicateMessages(messages) {
1345
+ const seen = new Set();
725
1346
  return messages.filter(message => {
726
- // Apply text search filters manually
1347
+ if (seen.has(message.id)) {
1348
+ return false;
1349
+ }
1350
+ seen.add(message.id);
1351
+ return true;
1352
+ });
1353
+ }
1354
+ /**
1355
+ * Apply advanced filtering with better logic
1356
+ */
1357
+ applyAdvancedFiltering(messages, criteria) {
1358
+ return messages.filter(message => {
1359
+ // Enhanced text search across multiple fields
727
1360
  if (criteria.query) {
728
- const searchText = criteria.query.toLowerCase();
729
- const messageText = `${message.subject} ${message.bodyPreview} ${message.from.name} ${message.from.address}`.toLowerCase();
730
- if (!messageText.includes(searchText))
1361
+ const searchText = criteria.query.toLowerCase().trim();
1362
+ const searchableContent = [
1363
+ message.subject,
1364
+ message.bodyPreview,
1365
+ message.from.name,
1366
+ message.from.address,
1367
+ ...message.toRecipients.map(r => `${r.name} ${r.address}`),
1368
+ ...message.ccRecipients.map(r => `${r.name} ${r.address}`)
1369
+ ].join(' ').toLowerCase();
1370
+ // Support multiple search terms (AND logic)
1371
+ const searchTerms = searchText.split(/\s+/).filter(term => term.length > 0);
1372
+ if (!searchTerms.every(term => searchableContent.includes(term))) {
731
1373
  return false;
1374
+ }
732
1375
  }
1376
+ // Enhanced sender search
733
1377
  if (criteria.from) {
734
1378
  const searchTerm = criteria.from.toLowerCase().trim();
735
1379
  const fromName = message.from.name.toLowerCase();
736
1380
  const fromAddress = message.from.address.toLowerCase();
737
- // Multiple matching strategies for better partial name support
738
1381
  const matches = [
739
- // Direct name or email match
740
1382
  fromName.includes(searchTerm),
741
1383
  fromAddress.includes(searchTerm),
742
- // Split search term and check if all parts exist in name
1384
+ // Split name search
743
1385
  searchTerm.split(/\s+/).every(part => fromName.includes(part)),
744
- // Check if any word in the name starts with the search term
745
- fromName.split(/\s+/).some(namePart => namePart.startsWith(searchTerm)),
746
- // Check if search term matches any word in the name exactly
747
- fromName.split(/\s+/).some(namePart => namePart === searchTerm),
748
- // Handle "Last, First" format
749
- fromName.replace(/,\s*/g, ' ').includes(searchTerm),
750
- // Handle initials (e.g., "M Kumar" for "Madan Kumar")
751
- searchTerm.split(/\s+/).length === 2 &&
752
- fromName.split(/\s+/).length >= 2 &&
753
- fromName.split(/\s+/)[0].startsWith(searchTerm.split(/\s+/)[0][0]) &&
754
- fromName.includes(searchTerm.split(/\s+/)[1])
1386
+ // Word boundary search
1387
+ new RegExp(`\\b${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i').test(fromName),
1388
+ // Email domain search
1389
+ searchTerm.includes('@') && fromAddress === searchTerm,
1390
+ // Partial email search
1391
+ !searchTerm.includes('@') && fromAddress.includes(searchTerm)
755
1392
  ];
756
1393
  if (!matches.some(match => match))
757
1394
  return false;
758
1395
  }
759
- if (criteria.to) {
760
- const toMatch = message.toRecipients.some(recipient => recipient.address.toLowerCase() === criteria.to.toLowerCase());
761
- if (!toMatch)
762
- return false;
763
- }
764
- if (criteria.cc) {
765
- // Handle case where ccRecipients might be undefined
766
- const ccMatch = message.ccRecipients && message.ccRecipients.length > 0 &&
767
- message.ccRecipients.some(recipient => recipient.address.toLowerCase() === criteria.cc.toLowerCase());
768
- if (!ccMatch)
769
- return false;
770
- }
771
- if (criteria.subject) {
772
- if (!message.subject.toLowerCase().includes(criteria.subject.toLowerCase()))
773
- return false;
774
- }
775
- // Apply date filters
776
- if (criteria.after) {
777
- const afterDate = new Date(criteria.after);
1396
+ // Date range filters
1397
+ if (criteria.after || criteria.before) {
778
1398
  const messageDate = new Date(message.receivedDateTime);
779
- if (messageDate < afterDate)
780
- return false;
781
- }
782
- if (criteria.before) {
783
- const beforeDate = new Date(criteria.before);
784
- const messageDate = new Date(message.receivedDateTime);
785
- if (messageDate > beforeDate)
786
- return false;
1399
+ if (criteria.after) {
1400
+ const afterDate = new Date(criteria.after);
1401
+ if (messageDate < afterDate)
1402
+ return false;
1403
+ }
1404
+ if (criteria.before) {
1405
+ const beforeDate = new Date(criteria.before);
1406
+ if (messageDate > beforeDate)
1407
+ return false;
1408
+ }
787
1409
  }
788
- // Apply attachment filter
789
- if (criteria.hasAttachment !== undefined && message.hasAttachments !== criteria.hasAttachment) {
1410
+ // Other filters remain the same but are more robust
1411
+ if (criteria.to && !message.toRecipients.some(r => r.address.toLowerCase().includes(criteria.to.toLowerCase())))
790
1412
  return false;
791
- }
792
- // Apply read status filter
793
- if (criteria.isUnread !== undefined && message.isRead === criteria.isUnread) {
1413
+ if (criteria.cc && (!message.ccRecipients || !message.ccRecipients.some(r => r.address.toLowerCase().includes(criteria.cc.toLowerCase()))))
794
1414
  return false;
795
- }
796
- // Apply importance filter
797
- if (criteria.importance && message.importance !== criteria.importance) {
1415
+ if (criteria.subject && !message.subject.toLowerCase().includes(criteria.subject.toLowerCase()))
1416
+ return false;
1417
+ if (criteria.hasAttachment !== undefined && message.hasAttachments !== criteria.hasAttachment)
1418
+ return false;
1419
+ if (criteria.isUnread !== undefined && message.isRead === criteria.isUnread)
1420
+ return false;
1421
+ if (criteria.importance && message.importance !== criteria.importance)
798
1422
  return false;
799
- }
800
1423
  return true;
801
1424
  });
802
1425
  }
1426
+ /**
1427
+ * Sort search results by relevance and date
1428
+ */
1429
+ sortSearchResults(messages, criteria) {
1430
+ return messages.sort((a, b) => {
1431
+ // Calculate relevance score
1432
+ const scoreA = this.calculateRelevanceScore(a, criteria);
1433
+ const scoreB = this.calculateRelevanceScore(b, criteria);
1434
+ if (scoreA !== scoreB) {
1435
+ return scoreB - scoreA; // Higher score first
1436
+ }
1437
+ // If relevance is same, sort by date (newer first)
1438
+ return new Date(b.receivedDateTime).getTime() - new Date(a.receivedDateTime).getTime();
1439
+ });
1440
+ }
1441
+ /**
1442
+ * Calculate relevance score for search results
1443
+ */
1444
+ calculateRelevanceScore(message, criteria) {
1445
+ let score = 0;
1446
+ if (criteria.query) {
1447
+ const query = criteria.query.toLowerCase();
1448
+ // Subject matches get higher score
1449
+ if (message.subject.toLowerCase().includes(query))
1450
+ score += 10;
1451
+ // Sender name matches
1452
+ if (message.from.name.toLowerCase().includes(query))
1453
+ score += 5;
1454
+ // Body preview matches
1455
+ if (message.bodyPreview.toLowerCase().includes(query))
1456
+ score += 3;
1457
+ // Exact word matches get bonus
1458
+ const words = query.split(/\s+/);
1459
+ words.forEach(word => {
1460
+ if (message.subject.toLowerCase().includes(word))
1461
+ score += 2;
1462
+ });
1463
+ }
1464
+ // Recent emails get slight boost
1465
+ const daysOld = (Date.now() - new Date(message.receivedDateTime).getTime()) / (1000 * 60 * 60 * 24);
1466
+ if (daysOld < 7)
1467
+ score += 2;
1468
+ else if (daysOld < 30)
1469
+ score += 1;
1470
+ // Unread emails get boost
1471
+ if (!message.isRead)
1472
+ score += 1;
1473
+ // Important emails get boost
1474
+ if (message.importance === 'high')
1475
+ score += 3;
1476
+ return score;
1477
+ }
803
1478
  /**
804
1479
  * List emails in a folder
805
1480
  */
@@ -957,10 +1632,13 @@ export class MS365Operations {
957
1632
  const graphClient = await this.getGraphClient();
958
1633
  const allFolders = [];
959
1634
  // Get top-level folders
1635
+ logger.log('📁 Fetching top-level mail folders...');
960
1636
  const result = await graphClient
961
1637
  .api('/me/mailFolders')
962
1638
  .select('id,displayName,totalItemCount,unreadItemCount,parentFolderId')
1639
+ .top(999) // Request up to 999 folders to avoid pagination
963
1640
  .get();
1641
+ logger.log(`📁 Found ${result.value?.length || 0} top-level folders from API`);
964
1642
  // Process top-level folders
965
1643
  const topLevelFolders = result.value?.map((folder) => ({
966
1644
  id: folder.id,
@@ -971,12 +1649,17 @@ export class MS365Operations {
971
1649
  depth: 0,
972
1650
  fullPath: folder.displayName || ''
973
1651
  })) || [];
1652
+ logger.log(`📁 Processed ${topLevelFolders.length} top-level folders: ${topLevelFolders.map((f) => f.displayName).join(', ')}`);
974
1653
  allFolders.push(...topLevelFolders);
975
1654
  // Recursively get child folders for each top-level folder
1655
+ logger.log('📂 Starting recursive child folder discovery...');
976
1656
  for (const folder of topLevelFolders) {
1657
+ logger.log(`📂 Checking children of: ${folder.displayName} (${folder.id})`);
977
1658
  const childFolders = await this.getChildFolders(folder.id, folder.fullPath || folder.displayName, 1);
1659
+ logger.log(`📂 Found ${childFolders.length} child folders for ${folder.displayName}`);
978
1660
  allFolders.push(...childFolders);
979
1661
  }
1662
+ logger.log(`📁 Total folders discovered: ${allFolders.length}`);
980
1663
  // Sort folders by full path for better organization
981
1664
  allFolders.sort((a, b) => (a.fullPath || '').localeCompare(b.fullPath || ''));
982
1665
  return allFolders;
@@ -993,11 +1676,14 @@ export class MS365Operations {
993
1676
  try {
994
1677
  const graphClient = await this.getGraphClient();
995
1678
  const childFolders = [];
1679
+ logger.log(`📂 [Depth ${depth}] Getting child folders for: ${parentPath} (${parentFolderId})`);
996
1680
  // Get child folders of the specified parent
997
1681
  const result = await graphClient
998
1682
  .api(`/me/mailFolders/${parentFolderId}/childFolders`)
999
1683
  .select('id,displayName,totalItemCount,unreadItemCount,parentFolderId')
1684
+ .top(999) // Request up to 999 child folders to avoid pagination
1000
1685
  .get();
1686
+ logger.log(`📂 [Depth ${depth}] API returned ${result.value?.length || 0} child folders for ${parentPath}`);
1001
1687
  if (result.value && result.value.length > 0) {
1002
1688
  const folders = result.value.map((folder) => ({
1003
1689
  id: folder.id,
@@ -1008,20 +1694,26 @@ export class MS365Operations {
1008
1694
  depth,
1009
1695
  fullPath: `${parentPath}/${folder.displayName || ''}`
1010
1696
  }));
1697
+ logger.log(`📂 [Depth ${depth}] Mapped child folders: ${folders.map((f) => f.displayName).join(', ')}`);
1011
1698
  childFolders.push(...folders);
1012
1699
  // Recursively get child folders (limit depth to prevent infinite recursion)
1013
1700
  if (depth < 10) { // Max depth of 10 levels
1701
+ logger.log(`📂 [Depth ${depth}] Recursing into ${folders.length} child folders...`);
1014
1702
  for (const folder of folders) {
1015
1703
  const subChildFolders = await this.getChildFolders(folder.id, folder.fullPath || folder.displayName, depth + 1);
1016
1704
  childFolders.push(...subChildFolders);
1017
1705
  }
1018
1706
  }
1707
+ else {
1708
+ logger.log(`📂 [Depth ${depth}] Maximum depth reached, stopping recursion`);
1709
+ }
1019
1710
  }
1711
+ logger.log(`📂 [Depth ${depth}] Returning ${childFolders.length} total child folders for ${parentPath}`);
1020
1712
  return childFolders;
1021
1713
  }
1022
1714
  catch (error) {
1023
1715
  // Log the error but don't throw - some folders might not have children or access might be restricted
1024
- logger.log(`Could not access child folders for ${parentFolderId}: ${error instanceof Error ? error.message : String(error)}`);
1716
+ logger.log(`❌ [Depth ${depth}] Could not access child folders for ${parentFolderId} (${parentPath}): ${error instanceof Error ? error.message : String(error)}`);
1025
1717
  return [];
1026
1718
  }
1027
1719
  }
@@ -1034,6 +1726,7 @@ export class MS365Operations {
1034
1726
  const result = await graphClient
1035
1727
  .api(`/me/mailFolders/${parentFolderId}/childFolders`)
1036
1728
  .select('id,displayName,totalItemCount,unreadItemCount,parentFolderId')
1729
+ .top(999) // Request up to 999 child folders to avoid pagination
1037
1730
  .get();
1038
1731
  return result.value?.map((folder) => ({
1039
1732
  id: folder.id,
@@ -1337,5 +2030,286 @@ export class MS365Operations {
1337
2030
  throw error;
1338
2031
  }
1339
2032
  }
2033
+ /**
2034
+ * Debug method to check raw folder API response
2035
+ */
2036
+ async debugFolders() {
2037
+ try {
2038
+ const graphClient = await this.getGraphClient();
2039
+ logger.log('🔍 DEBUG: Testing folder API calls...');
2040
+ // Test basic folder listing
2041
+ const basicResult = await graphClient
2042
+ .api('/me/mailFolders')
2043
+ .get();
2044
+ logger.log(`🔍 DEBUG: Raw /me/mailFolders response: ${JSON.stringify(basicResult, null, 2)}`);
2045
+ // Test with specific selection
2046
+ const selectResult = await graphClient
2047
+ .api('/me/mailFolders')
2048
+ .select('id,displayName,totalItemCount,unreadItemCount,parentFolderId')
2049
+ .get();
2050
+ logger.log(`🔍 DEBUG: Selected fields response: ${JSON.stringify(selectResult, null, 2)}`);
2051
+ // Test well-known folder access
2052
+ try {
2053
+ const inboxResult = await graphClient
2054
+ .api('/me/mailFolders/inbox/childFolders')
2055
+ .select('id,displayName,totalItemCount,unreadItemCount,parentFolderId')
2056
+ .get();
2057
+ logger.log(`🔍 DEBUG: Inbox children: ${JSON.stringify(inboxResult, null, 2)}`);
2058
+ }
2059
+ catch (inboxError) {
2060
+ logger.log(`🔍 DEBUG: Inbox children error: ${inboxError}`);
2061
+ }
2062
+ }
2063
+ catch (error) {
2064
+ logger.error('🔍 DEBUG: Error in debugFolders:', error);
2065
+ }
2066
+ }
2067
+ /**
2068
+ * Apply manual filtering to search results (used when $filter can't be used with $search)
2069
+ */
2070
+ applyManualFiltering(messages, criteria) {
2071
+ return messages.filter(message => {
2072
+ // Apply text search filters manually
2073
+ if (criteria.query) {
2074
+ const searchText = criteria.query.toLowerCase();
2075
+ const messageText = `${message.subject} ${message.bodyPreview} ${message.from.name} ${message.from.address}`.toLowerCase();
2076
+ if (!messageText.includes(searchText))
2077
+ return false;
2078
+ }
2079
+ if (criteria.from) {
2080
+ const searchTerm = criteria.from.toLowerCase().trim();
2081
+ const fromName = message.from.name.toLowerCase();
2082
+ const fromAddress = message.from.address.toLowerCase();
2083
+ const matches = [
2084
+ fromName.includes(searchTerm),
2085
+ fromAddress.includes(searchTerm),
2086
+ searchTerm.split(/\s+/).every(part => fromName.includes(part)),
2087
+ fromName.split(/\s+/).some(namePart => namePart.startsWith(searchTerm))
2088
+ ];
2089
+ if (!matches.some(match => match))
2090
+ return false;
2091
+ }
2092
+ if (criteria.to) {
2093
+ const toMatch = message.toRecipients.some(recipient => recipient.address.toLowerCase() === criteria.to.toLowerCase());
2094
+ if (!toMatch)
2095
+ return false;
2096
+ }
2097
+ if (criteria.cc) {
2098
+ const ccMatch = message.ccRecipients && message.ccRecipients.length > 0 &&
2099
+ message.ccRecipients.some(recipient => recipient.address.toLowerCase() === criteria.cc.toLowerCase());
2100
+ if (!ccMatch)
2101
+ return false;
2102
+ }
2103
+ if (criteria.subject) {
2104
+ if (!message.subject.toLowerCase().includes(criteria.subject.toLowerCase()))
2105
+ return false;
2106
+ }
2107
+ if (criteria.after) {
2108
+ const afterDate = new Date(criteria.after);
2109
+ const messageDate = new Date(message.receivedDateTime);
2110
+ if (messageDate < afterDate)
2111
+ return false;
2112
+ }
2113
+ if (criteria.before) {
2114
+ const beforeDate = new Date(criteria.before);
2115
+ const messageDate = new Date(message.receivedDateTime);
2116
+ if (messageDate > beforeDate)
2117
+ return false;
2118
+ }
2119
+ if (criteria.hasAttachment !== undefined && message.hasAttachments !== criteria.hasAttachment) {
2120
+ return false;
2121
+ }
2122
+ if (criteria.isUnread !== undefined && message.isRead === criteria.isUnread) {
2123
+ return false;
2124
+ }
2125
+ if (criteria.importance && message.importance !== criteria.importance) {
2126
+ return false;
2127
+ }
2128
+ return true;
2129
+ });
2130
+ }
2131
+ /**
2132
+ * Build relaxed KQL query with fewer constraints
2133
+ */
2134
+ buildRelaxedKQLQuery(criteria) {
2135
+ const kqlParts = [];
2136
+ // Only include the most important criteria for relaxed search
2137
+ if (criteria.from) {
2138
+ const fromTerm = criteria.from.trim();
2139
+ if (fromTerm.includes('@')) {
2140
+ // For email addresses, try domain search too
2141
+ const domain = fromTerm.split('@')[1];
2142
+ if (domain) {
2143
+ kqlParts.push(`(from:${fromTerm} OR from:*@${domain})`);
2144
+ }
2145
+ else {
2146
+ kqlParts.push(`from:${fromTerm}`);
2147
+ }
2148
+ }
2149
+ else {
2150
+ // For names, use wildcard search
2151
+ kqlParts.push(`from:*${fromTerm}*`);
2152
+ }
2153
+ }
2154
+ if (criteria.subject) {
2155
+ // Use partial subject matching
2156
+ kqlParts.push(`subject:*${criteria.subject}*`);
2157
+ }
2158
+ // Skip attachment and read status filters in relaxed mode
2159
+ // Keep only critical filters
2160
+ if (criteria.importance === 'high') {
2161
+ kqlParts.push(`importance:high`);
2162
+ }
2163
+ // Expand date range for relaxed search
2164
+ if (criteria.after) {
2165
+ const afterDate = new Date(criteria.after);
2166
+ afterDate.setDate(afterDate.getDate() - 7); // Expand by a week
2167
+ const expandedDate = afterDate.toISOString().split('T')[0];
2168
+ kqlParts.push(`received>=${expandedDate}`);
2169
+ }
2170
+ return kqlParts.join(' AND ');
2171
+ }
2172
+ /**
2173
+ * Perform partial text search across recent emails with broader matching
2174
+ */
2175
+ async performPartialTextSearch(criteria, maxResults) {
2176
+ try {
2177
+ const graphClient = await this.getGraphClient();
2178
+ // Get a large set of recent emails to search through
2179
+ const result = await graphClient
2180
+ .api('/me/messages')
2181
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
2182
+ .orderby('receivedDateTime desc')
2183
+ .top(Math.min(maxResults, 1000))
2184
+ .get();
2185
+ const messages = result.value?.map((email) => this.mapEmailResult(email)) || [];
2186
+ // Apply very broad partial matching
2187
+ const partialMatches = messages.filter(message => {
2188
+ let matches = true;
2189
+ // Very flexible text search
2190
+ if (criteria.query) {
2191
+ const searchTerms = criteria.query.toLowerCase().split(/\s+/);
2192
+ const searchableText = `${message.subject} ${message.bodyPreview} ${message.from.name} ${message.from.address}`.toLowerCase();
2193
+ // At least 50% of search terms should match
2194
+ const matchingTerms = searchTerms.filter(term => searchableText.includes(term));
2195
+ matches = matchingTerms.length >= Math.ceil(searchTerms.length * 0.5);
2196
+ }
2197
+ // Very flexible sender search
2198
+ if (criteria.from && matches) {
2199
+ const fromTerm = criteria.from.toLowerCase();
2200
+ const fromText = `${message.from.name} ${message.from.address}`.toLowerCase();
2201
+ // Partial matching with character-level similarity
2202
+ matches = fromText.includes(fromTerm) ||
2203
+ fromTerm.split('').some(char => fromText.includes(char)) ||
2204
+ this.calculateStringSimilarity(fromTerm, fromText) > 0.3;
2205
+ }
2206
+ return matches;
2207
+ });
2208
+ logger.log(`📧 Partial text search found ${partialMatches.length} potential matches`);
2209
+ return partialMatches;
2210
+ }
2211
+ catch (error) {
2212
+ logger.log(`❌ Partial text search failed: ${error}`);
2213
+ return [];
2214
+ }
2215
+ }
2216
+ /**
2217
+ * Ultra-relaxed search that casts a very wide net
2218
+ */
2219
+ async performUltraRelaxedSearch(criteria, maxResults) {
2220
+ try {
2221
+ const graphClient = await this.getGraphClient();
2222
+ // Search across multiple folders and time ranges
2223
+ const searches = [];
2224
+ // Search recent emails
2225
+ searches.push(graphClient
2226
+ .api('/me/messages')
2227
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
2228
+ .orderby('receivedDateTime desc')
2229
+ .top(500)
2230
+ .get());
2231
+ // Search sent items if looking for specific people
2232
+ if (criteria.from || criteria.to) {
2233
+ searches.push(graphClient
2234
+ .api('/me/mailFolders/sentitems/messages')
2235
+ .select('id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,isRead,hasAttachments,importance,conversationId,parentFolderId,webLink')
2236
+ .orderby('receivedDateTime desc')
2237
+ .top(200)
2238
+ .get());
2239
+ }
2240
+ const results = await Promise.allSettled(searches);
2241
+ const allEmails = [];
2242
+ results.forEach((result, index) => {
2243
+ if (result.status === 'fulfilled') {
2244
+ const emails = result.value.value?.map((email) => this.mapEmailResult(email)) || [];
2245
+ allEmails.push(...emails);
2246
+ logger.log(`📧 Ultra-relaxed search ${index + 1} found ${emails.length} emails`);
2247
+ }
2248
+ });
2249
+ return this.removeDuplicateMessages(allEmails);
2250
+ }
2251
+ catch (error) {
2252
+ logger.log(`❌ Ultra-relaxed search failed: ${error}`);
2253
+ return [];
2254
+ }
2255
+ }
2256
+ /**
2257
+ * Ultra-relaxed filtering with very permissive matching
2258
+ */
2259
+ applyUltraRelaxedFiltering(messages, criteria) {
2260
+ return messages.filter(message => {
2261
+ let score = 0;
2262
+ let hasAnyMatch = false;
2263
+ // Any partial query match
2264
+ if (criteria.query) {
2265
+ const searchText = criteria.query.toLowerCase();
2266
+ const messageText = `${message.subject} ${message.bodyPreview} ${message.from.name}`.toLowerCase();
2267
+ // Check for any word matches
2268
+ const queryWords = searchText.split(/\s+/);
2269
+ const matchingWords = queryWords.filter(word => messageText.includes(word));
2270
+ if (matchingWords.length > 0) {
2271
+ hasAnyMatch = true;
2272
+ score += matchingWords.length;
2273
+ }
2274
+ }
2275
+ // Any sender similarity
2276
+ if (criteria.from) {
2277
+ const fromTerm = criteria.from.toLowerCase();
2278
+ const fromText = `${message.from.name} ${message.from.address}`.toLowerCase();
2279
+ if (fromText.includes(fromTerm) ||
2280
+ this.calculateStringSimilarity(fromTerm, fromText) > 0.2) {
2281
+ hasAnyMatch = true;
2282
+ score += 2;
2283
+ }
2284
+ }
2285
+ // Any subject similarity
2286
+ if (criteria.subject) {
2287
+ const subjectTerm = criteria.subject.toLowerCase();
2288
+ if (message.subject.toLowerCase().includes(subjectTerm)) {
2289
+ hasAnyMatch = true;
2290
+ score += 3;
2291
+ }
2292
+ }
2293
+ // If no specific criteria, return recent emails
2294
+ if (!criteria.query && !criteria.from && !criteria.subject) {
2295
+ hasAnyMatch = true;
2296
+ }
2297
+ return hasAnyMatch;
2298
+ }).sort((a, b) => {
2299
+ // Sort by date for ultra-relaxed results
2300
+ return new Date(b.receivedDateTime).getTime() - new Date(a.receivedDateTime).getTime();
2301
+ });
2302
+ }
2303
+ /**
2304
+ * Calculate string similarity (simple version)
2305
+ */
2306
+ calculateStringSimilarity(str1, str2) {
2307
+ const longer = str1.length > str2.length ? str1 : str2;
2308
+ const shorter = str1.length > str2.length ? str2 : str1;
2309
+ if (longer.length === 0)
2310
+ return 1.0;
2311
+ const matches = shorter.split('').filter(char => longer.includes(char)).length;
2312
+ return matches / longer.length;
2313
+ }
1340
2314
  }
1341
2315
  export const ms365Operations = new MS365Operations();