UNPKG

ms365-mcp-server

Version:

Microsoft 365 MCP Server for managing Microsoft 365 email through natural language interactions with full OAuth2 authentication support

701 lines (700 loc) β€’ 27.2 kB
import { logger } from './api.js'; export class ThreadReconstruction { constructor(ms365Operations) { this.ms365Operations = ms365Operations; } /** * Reconstruct thread from a single email */ async reconstructThread(email, allEmails, options = {}) { const opts = { ...ThreadReconstruction.DEFAULT_OPTIONS, ...options }; logger.log(`πŸ”— Reconstructing thread for email: ${email.subject}`); // Find all related emails const relatedEmails = this.findRelatedEmails(email, allEmails, opts); logger.log(`πŸ”— Found ${relatedEmails.length} related emails`); // Build thread tree const threadTree = this.buildThreadTree(relatedEmails, opts); // Analyze thread const threadAnalysis = this.analyzeThread(threadTree, opts); // Create reconstructed thread const reconstructedThread = { id: this.generateThreadId(email), rootEmail: threadAnalysis.rootEmail, allEmails: relatedEmails, threadTree, totalMessages: relatedEmails.length, threadSpan: this.calculateThreadSpan(relatedEmails), participants: this.analyzeParticipants(relatedEmails), summary: this.generateThreadSummary(threadTree, threadAnalysis, opts) }; logger.log(`πŸ”— Thread reconstruction completed: ${reconstructedThread.totalMessages} messages`); return reconstructedThread; } /** * Find all emails related to the target email */ findRelatedEmails(targetEmail, allEmails, options) { const relatedEmails = [targetEmail]; const processedIds = new Set([targetEmail.id]); // 1. Find by conversation ID const conversationEmails = allEmails.filter(email => email.conversationId === targetEmail.conversationId && !processedIds.has(email.id)); relatedEmails.push(...conversationEmails); conversationEmails.forEach(email => processedIds.add(email.id)); // 2. Find by subject similarity and patterns const subjectRelatedEmails = this.findSubjectRelatedEmails(targetEmail, allEmails.filter(email => !processedIds.has(email.id)), options); relatedEmails.push(...subjectRelatedEmails); subjectRelatedEmails.forEach(email => processedIds.add(email.id)); // 3. Find by content patterns (forwarded content detection) if (options.enableAdvancedPatternMatching) { const contentRelatedEmails = this.findContentRelatedEmails(targetEmail, allEmails.filter(email => !processedIds.has(email.id)), options); relatedEmails.push(...contentRelatedEmails); contentRelatedEmails.forEach(email => processedIds.add(email.id)); } // Filter by time window const timeFilteredEmails = this.filterByTimeWindow(relatedEmails, targetEmail, options.timeWindowDays); return timeFilteredEmails; } /** * Find emails related by subject patterns */ findSubjectRelatedEmails(targetEmail, emails, options) { const relatedEmails = []; const cleanTargetSubject = this.cleanSubject(targetEmail.subject); for (const email of emails) { const cleanEmailSubject = this.cleanSubject(email.subject); // Check for subject similarity if (this.subjectsMatch(cleanTargetSubject, cleanEmailSubject)) { relatedEmails.push(email); } // Check for forward/reply patterns if (this.hasForwardOrReplyPattern(email.subject, targetEmail.subject)) { relatedEmails.push(email); } } return relatedEmails; } /** * Find emails related by content patterns */ findContentRelatedEmails(targetEmail, emails, options) { const relatedEmails = []; const targetContent = this.extractContentSignatures(targetEmail); for (const email of emails) { const emailContent = this.extractContentSignatures(email); // Check for forwarded content patterns if (this.hasForwardedContent(email, targetEmail)) { relatedEmails.push(email); } // Check for content similarity if (this.calculateContentSimilarity(targetContent, emailContent) > 0.7) { relatedEmails.push(email); } } return relatedEmails; } /** * Build thread tree from related emails */ buildThreadTree(emails, options) { // Sort emails by date const sortedEmails = emails.sort((a, b) => new Date(a.receivedDateTime).getTime() - new Date(b.receivedDateTime).getTime()); // Find root email (likely the original) const rootEmail = this.findRootEmail(sortedEmails); // Create root node const rootNode = { email: rootEmail, children: [], depth: 0, threadType: 'original', confidence: 1.0, metadata: { isRoot: true, hasChildren: false, chainPosition: 0, estimatedOriginalDate: new Date(rootEmail.receivedDateTime) } }; // Build tree recursively this.buildThreadNodes(rootNode, sortedEmails, options); return rootNode; } /** * Build thread nodes recursively */ buildThreadNodes(parentNode, remainingEmails, options) { if (parentNode.depth >= options.maxDepth) return; const childEmails = remainingEmails.filter(email => email.id !== parentNode.email.id && this.isChildOf(email, parentNode.email)); for (const childEmail of childEmails) { const childNode = { email: childEmail, children: [], parent: parentNode, depth: parentNode.depth + 1, threadType: this.determineThreadType(childEmail, parentNode.email), confidence: this.calculateRelationshipConfidence(childEmail, parentNode.email), metadata: { isRoot: false, hasChildren: false, chainPosition: parentNode.depth + 1, estimatedOriginalDate: this.estimateOriginalDate(childEmail) } }; // Set parent and forwarded references if (childNode.threadType === 'forward') { childNode.metadata.forwardedFrom = parentNode.email; } else if (childNode.threadType === 'reply') { childNode.metadata.replyTo = parentNode.email; } parentNode.children.push(childNode); parentNode.metadata.hasChildren = true; // Recursively build children this.buildThreadNodes(childNode, remainingEmails, options); } } /** * Find the root email (original email in the thread) */ findRootEmail(emails) { // Look for email without forward/reply patterns const originalEmails = emails.filter(email => !this.hasAnyForwardOrReplyPattern(email.subject)); if (originalEmails.length > 0) { // Return the earliest original email return originalEmails.reduce((earliest, current) => new Date(current.receivedDateTime) < new Date(earliest.receivedDateTime) ? current : earliest); } // If no clear original, return the earliest email return emails[0]; } /** * Determine if an email is a child of another email */ isChildOf(childEmail, parentEmail) { // Check if child is newer than parent if (new Date(childEmail.receivedDateTime) <= new Date(parentEmail.receivedDateTime)) { return false; } // Check subject patterns if (this.hasForwardOrReplyPattern(childEmail.subject, parentEmail.subject)) { return true; } // Check for forwarded content if (this.hasForwardedContent(childEmail, parentEmail)) { return true; } // Check for reply patterns if (this.hasReplyPattern(childEmail.subject) && this.subjectsMatch(this.cleanSubject(childEmail.subject), this.cleanSubject(parentEmail.subject))) { return true; } return false; } /** * Determine thread type for an email */ determineThreadType(email, parentEmail) { const subject = email.subject.toLowerCase(); // Check for nested forwards if (ThreadReconstruction.NESTED_FORWARD_PATTERNS.some(pattern => pattern.test(subject))) { return 'nested_forward'; } // Check for forwards if (ThreadReconstruction.FORWARD_PATTERNS.some(pattern => pattern.test(subject))) { return 'forward'; } // Check for replies if (ThreadReconstruction.REPLY_PATTERNS.some(pattern => pattern.test(subject))) { return 'reply'; } // Check content for forwarded patterns if (this.hasForwardedContent(email, parentEmail)) { return 'forward'; } return 'reply'; // Default assumption } /** * Calculate relationship confidence between two emails */ calculateRelationshipConfidence(email, parentEmail) { let confidence = 0; // Subject similarity const subjectSim = this.calculateSubjectSimilarity(email.subject, parentEmail.subject); confidence += subjectSim * 0.3; // Time proximity (closer in time = higher confidence) const timeDiff = Math.abs(new Date(email.receivedDateTime).getTime() - new Date(parentEmail.receivedDateTime).getTime()); const timeProximity = Math.max(0, 1 - (timeDiff / (30 * 24 * 60 * 60 * 1000))); // 30 days confidence += timeProximity * 0.2; // Content similarity const contentSim = this.calculateContentSimilarity(this.extractContentSignatures(email), this.extractContentSignatures(parentEmail)); confidence += contentSim * 0.3; // Pattern matching if (this.hasForwardOrReplyPattern(email.subject, parentEmail.subject)) { confidence += 0.2; } return Math.min(1, confidence); } /** * Clean subject by removing prefixes */ cleanSubject(subject) { let cleaned = subject.toLowerCase().trim(); // Remove forward prefixes for (const pattern of ThreadReconstruction.FORWARD_PATTERNS) { cleaned = cleaned.replace(pattern, ''); } // Remove reply prefixes for (const pattern of ThreadReconstruction.REPLY_PATTERNS) { cleaned = cleaned.replace(pattern, ''); } return cleaned.trim(); } /** * Check if subjects match after cleaning */ subjectsMatch(subject1, subject2) { return subject1 === subject2 || this.calculateStringSimilarity(subject1, subject2) > 0.8; } /** * Check if email has forward or reply pattern */ hasForwardOrReplyPattern(subject, parentSubject) { const cleanSubject = this.cleanSubject(subject); const cleanParentSubject = this.cleanSubject(parentSubject); return this.hasAnyForwardOrReplyPattern(subject) && this.subjectsMatch(cleanSubject, cleanParentSubject); } /** * Check if subject has any forward or reply pattern */ hasAnyForwardOrReplyPattern(subject) { const lowerSubject = subject.toLowerCase(); return ThreadReconstruction.FORWARD_PATTERNS.some(pattern => pattern.test(lowerSubject)) || ThreadReconstruction.REPLY_PATTERNS.some(pattern => pattern.test(lowerSubject)); } /** * Check if subject has reply pattern */ hasReplyPattern(subject) { return ThreadReconstruction.REPLY_PATTERNS.some(pattern => pattern.test(subject.toLowerCase())); } /** * Check if email contains forwarded content */ hasForwardedContent(email, parentEmail) { const emailContent = email.bodyPreview.toLowerCase(); const parentContent = parentEmail.bodyPreview.toLowerCase(); // Look for forwarded message patterns const forwardedPatterns = [ /from:.*sent:/i, /forwarded message/i, /original message/i, /---------- forwarded message/i, /-----original message-----/i, /begin forwarded message/i, ]; const hasForwardPattern = forwardedPatterns.some(pattern => pattern.test(emailContent)); // Check if parent content appears in current email const hasParentContent = parentContent.length > 50 && emailContent.includes(parentContent.substring(0, 100)); return hasForwardPattern || hasParentContent; } /** * Extract content signatures for comparison */ extractContentSignatures(email) { const content = `${email.subject} ${email.bodyPreview}`.toLowerCase(); return { keywords: this.extractKeywords(content), phrases: this.extractPhrases(content), entities: this.extractEntities(content) }; } /** * Extract keywords from content */ extractKeywords(content) { const stopWords = new Set(['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by']); const words = content.match(/\b\w{3,}\b/g) || []; return words.filter(word => !stopWords.has(word)); } /** * Extract phrases from content */ extractPhrases(content) { // Extract phrases of 2-4 words const phrases = []; const words = content.split(/\s+/); for (let i = 0; i < words.length - 1; i++) { // 2-word phrases phrases.push(`${words[i]} ${words[i + 1]}`); // 3-word phrases if (i < words.length - 2) { phrases.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`); } } return phrases; } /** * Extract entities from content */ extractEntities(content) { const entities = []; // Email addresses const emailMatches = content.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g); if (emailMatches) entities.push(...emailMatches); // Dates const dateMatches = content.match(/\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/g); if (dateMatches) entities.push(...dateMatches); // Numbers const numberMatches = content.match(/\b\d{4,}\b/g); if (numberMatches) entities.push(...numberMatches); return entities; } /** * Calculate content similarity */ calculateContentSimilarity(content1, content2) { const keywordSim = this.calculateArraySimilarity(content1.keywords, content2.keywords); const phraseSim = this.calculateArraySimilarity(content1.phrases, content2.phrases); const entitySim = this.calculateArraySimilarity(content1.entities, content2.entities); return (keywordSim * 0.4 + phraseSim * 0.4 + entitySim * 0.2); } /** * Calculate array similarity */ calculateArraySimilarity(arr1, arr2) { if (arr1.length === 0 && arr2.length === 0) return 1; if (arr1.length === 0 || arr2.length === 0) return 0; const set1 = new Set(arr1); const set2 = new Set(arr2); const intersection = new Set([...set1].filter(x => set2.has(x))); const union = new Set([...set1, ...set2]); return intersection.size / union.size; } /** * Calculate string similarity */ calculateStringSimilarity(str1, str2) { if (str1 === str2) return 1; const longer = str1.length > str2.length ? str1 : str2; const shorter = str1.length > str2.length ? str2 : str1; if (longer.length === 0) return 1; const editDistance = this.levenshteinDistance(longer, shorter); return (longer.length - editDistance) / longer.length; } /** * Calculate Levenshtein distance */ levenshteinDistance(str1, str2) { const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); for (let i = 0; i <= str1.length; i++) matrix[0][i] = i; for (let j = 0; j <= str2.length; j++) matrix[j][0] = j; for (let j = 1; j <= str2.length; j++) { for (let i = 1; i <= str1.length; i++) { const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; matrix[j][i] = Math.min(matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + indicator); } } return matrix[str2.length][str1.length]; } /** * Calculate subject similarity */ calculateSubjectSimilarity(subject1, subject2) { const clean1 = this.cleanSubject(subject1); const clean2 = this.cleanSubject(subject2); return this.calculateStringSimilarity(clean1, clean2); } /** * Estimate original date for forwarded emails */ estimateOriginalDate(email) { // Look for date patterns in forwarded content const datePatterns = [ /sent:\s*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})/i, /date:\s*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})/i, /on\s*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})/i, ]; for (const pattern of datePatterns) { const match = email.bodyPreview.match(pattern); if (match) { const estimatedDate = new Date(match[1]); if (!isNaN(estimatedDate.getTime())) { return estimatedDate; } } } return new Date(email.receivedDateTime); } /** * Filter emails by time window */ filterByTimeWindow(emails, targetEmail, days) { const targetDate = new Date(targetEmail.receivedDateTime); const windowStart = new Date(targetDate.getTime() - (days * 24 * 60 * 60 * 1000)); const windowEnd = new Date(targetDate.getTime() + (days * 24 * 60 * 60 * 1000)); return emails.filter(email => { const emailDate = new Date(email.receivedDateTime); return emailDate >= windowStart && emailDate <= windowEnd; }); } /** * Analyze thread structure */ analyzeThread(threadTree, options) { const analysis = { rootEmail: threadTree.email, maxDepth: 0, totalNodes: 0, forwardCount: 0, replyCount: 0, complexity: 'simple' }; this.analyzeNode(threadTree, analysis); // Determine complexity if (analysis.totalNodes <= 3) { analysis.complexity = 'simple'; } else if (analysis.totalNodes <= 10) { analysis.complexity = 'moderate'; } else { analysis.complexity = 'complex'; } return analysis; } /** * Analyze individual node */ analyzeNode(node, analysis) { analysis.totalNodes++; analysis.maxDepth = Math.max(analysis.maxDepth, node.depth); if (node.threadType === 'forward' || node.threadType === 'nested_forward') { analysis.forwardCount++; } else if (node.threadType === 'reply') { analysis.replyCount++; } for (const child of node.children) { this.analyzeNode(child, analysis); } } /** * Calculate thread span */ calculateThreadSpan(emails) { const dates = emails.map(email => new Date(email.receivedDateTime)); const startDate = new Date(Math.min(...dates.map(d => d.getTime()))); const endDate = new Date(Math.max(...dates.map(d => d.getTime()))); const durationDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); return { startDate, endDate, durationDays }; } /** * Analyze thread participants */ analyzeParticipants(emails) { const participantMap = new Map(); for (const email of emails) { // Add sender const senderKey = email.from.address.toLowerCase(); if (!participantMap.has(senderKey)) { participantMap.set(senderKey, { name: email.from.name, messageCount: 0, roles: new Set() }); } participantMap.get(senderKey).messageCount++; participantMap.get(senderKey).roles.add('sender'); // Add recipients for (const recipient of email.toRecipients) { const recipientKey = recipient.address.toLowerCase(); if (!participantMap.has(recipientKey)) { participantMap.set(recipientKey, { name: recipient.name, messageCount: 0, roles: new Set() }); } participantMap.get(recipientKey).roles.add('recipient'); } // Add CC recipients for (const ccRecipient of email.ccRecipients) { const ccKey = ccRecipient.address.toLowerCase(); if (!participantMap.has(ccKey)) { participantMap.set(ccKey, { name: ccRecipient.name, messageCount: 0, roles: new Set() }); } participantMap.get(ccKey).roles.add('cc'); } } const participants = Array.from(participantMap.entries()).map(([address, data]) => ({ name: data.name, address, messageCount: data.messageCount, role: data.roles.size > 1 ? 'mixed' : data.roles.has('sender') ? 'sender' : data.roles.has('recipient') ? 'recipient' : 'cc' })); return participants.sort((a, b) => b.messageCount - a.messageCount); } /** * Generate thread summary */ generateThreadSummary(threadTree, analysis, options) { const cleanSubject = this.cleanSubject(threadTree.email.subject); const mainTopic = this.extractMainTopic(threadTree); const keyParticipants = this.extractKeyParticipants(threadTree); return { subject: cleanSubject, mainTopic, keyParticipants, isForwardChain: analysis.forwardCount > 0, isReplyChain: analysis.replyCount > 0, complexity: analysis.complexity }; } /** * Extract main topic from thread */ extractMainTopic(threadTree) { const subjects = this.collectAllSubjects(threadTree); const keywords = subjects.flatMap(subject => this.extractKeywords(subject)); const keywordCounts = new Map(); for (const keyword of keywords) { keywordCounts.set(keyword, (keywordCounts.get(keyword) || 0) + 1); } // Find most common keywords const sortedKeywords = Array.from(keywordCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([keyword]) => keyword); return sortedKeywords.join(', ') || 'General Discussion'; } /** * Extract key participants */ extractKeyParticipants(threadTree) { const participants = new Set(); this.collectAllParticipants(threadTree, participants); return Array.from(participants).slice(0, 5); } /** * Collect all subjects from thread tree */ collectAllSubjects(node) { const subjects = [node.email.subject]; for (const child of node.children) { subjects.push(...this.collectAllSubjects(child)); } return subjects; } /** * Collect all participants from thread tree */ collectAllParticipants(node, participants) { participants.add(node.email.from.name); for (const recipient of node.email.toRecipients) { participants.add(recipient.name); } for (const child of node.children) { this.collectAllParticipants(child, participants); } } /** * Generate thread ID */ generateThreadId(email) { return `thread_${email.conversationId}_${Date.now()}`; } /** * Batch process multiple emails for thread reconstruction */ async batchReconstructThreads(emails, options = {}) { const threads = new Map(); const processedEmails = new Set(); logger.log(`πŸ”— Batch processing ${emails.length} emails for thread reconstruction`); for (const email of emails) { if (processedEmails.has(email.id)) continue; try { const thread = await this.reconstructThread(email, emails, options); threads.set(thread.id, thread); // Mark all emails in this thread as processed for (const threadEmail of thread.allEmails) { processedEmails.add(threadEmail.id); } } catch (error) { logger.error(`Error reconstructing thread for email ${email.id}:`, error); } } logger.log(`πŸ”— Batch thread reconstruction completed: ${threads.size} threads`); return threads; } } ThreadReconstruction.DEFAULT_OPTIONS = { maxDepth: 10, timeWindowDays: 365, includeForwardChains: true, includeReplyChains: true, enableAdvancedPatternMatching: true, minimumConfidence: 0.6, analysisDepth: 'detailed' }; // Common forwarded email patterns ThreadReconstruction.FORWARD_PATTERNS = [ // Standard forward prefixes /^(fw|fwd|forward):\s*/i, /^re:\s*(fw|fwd|forward):\s*/i, // Forwarded message headers /---------- forwarded message ----------/i, /-----original message-----/i, /forwarded message/i, /original message/i, // Email client specific patterns /begin forwarded message/i, /forwarded by/i, /forwarding.*message/i, // International patterns /tr:\s*/i, // Turkish (Tr:) /fw:\s*/i, // Forward abbreviation /weitergeleitet:\s*/i, // German /reenvio:\s*/i, // Spanish /transfΓ©rΓ©:\s*/i, // French ]; // Reply patterns ThreadReconstruction.REPLY_PATTERNS = [ /^re:\s*/i, /^re\[\d+\]:\s*/i, /^reply:\s*/i, /^response:\s*/i, /^answer:\s*/i, /^antw:\s*/i, // German /^resp:\s*/i, // Spanish /^rΓ©p:\s*/i, // French ]; // Nested forward patterns (forwards within forwards) ThreadReconstruction.NESTED_FORWARD_PATTERNS = [ /^(fw|fwd):\s*(fw|fwd):\s*/i, /^re:\s*(fw|fwd):\s*(fw|fwd):\s*/i, /^(fw|fwd):\s*re:\s*(fw|fwd):\s*/i, ];