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
JavaScript
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,
];