UNPKG

msexchange-mcp

Version:

MCP server for Microsoft Exchange/Outlook email operations via Microsoft Graph API

535 lines 24.4 kB
import { z } from 'zod'; import { logger } from '../utils/logger.js'; import { AliasManager } from './aliasManager.js'; // Semantic concept mappings const CONCEPT_MAPPINGS = { 'insurance': ['policy', 'coverage', 'premium', 'claim'], 'incorporation': ['company', 'formation', 'registration', 'setup', 'establish'], 'tax': ['fiscal', 'revenue', 'hmrc', 'vat', 'obligations'], 'invoice': ['bill', 'payment', 'receipt', 'billing'], }; export class UnifiedSearchService { mailService; aliasManager; constructor(mailService) { this.mailService = mailService; this.aliasManager = AliasManager.getInstance(); this.aliasManager.initialize().catch(err => logger.error('Failed to initialize alias manager', { err })); } /** * Parse natural language query into structured search parameters */ parseQuery(query) { const parsed = { terms: [], filters: { from: [], to: [], subject: [], }, originalQuery: query, }; // Handle OR operators by splitting the query if (query.includes(' OR ')) { logger.debug('Detected OR operator in query - will execute multiple searches'); parsed.hasOrOperator = true; parsed.orParts = query.split(' OR ').map(part => part.trim()); return parsed; } // Extract quoted phrases first const quotedPhrases = []; const queryWithoutQuotes = query.replace(/"([^"]+)"/g, (match, phrase) => { quotedPhrases.push(phrase); return ''; }); // Parse filters like from:, to:, subject:, before:, after: const filterRegex = /(\w+):(\S+)/g; let match; const remainingQuery = queryWithoutQuotes.replace(filterRegex, (fullMatch, key, value) => { switch (key.toLowerCase()) { case 'from': parsed.filters.from.push(value); break; case 'to': parsed.filters.to.push(value); break; case 'subject': parsed.filters.subject.push(value); break; case 'has': if (value.toLowerCase() === 'attachment' || value.toLowerCase() === 'attachments') { parsed.filters.hasAttachments = true; } break; case 'before': // Parse date and set as end date if (!parsed.filters.dateRange) { parsed.filters.dateRange = {}; } try { const date = new Date(value); if (!isNaN(date.getTime())) { parsed.filters.dateRange.end = date.toISOString(); } else { logger.warn('Invalid date format for before: filter', { value }); } } catch (err) { logger.warn('Error parsing before: date', { value, err }); } break; case 'after': // Parse date and set as start date if (!parsed.filters.dateRange) { parsed.filters.dateRange = {}; } try { const date = new Date(value); if (!isNaN(date.getTime())) { parsed.filters.dateRange.start = date.toISOString(); } else { logger.warn('Invalid date format for after: filter', { value }); } } catch (err) { logger.warn('Error parsing after: date', { value, err }); } break; } return ''; }); // Extract date ranges (e.g., "last week", "since Monday", "March 2025") const datePatterns = [ /last\s+(week|month|year)/i, /since\s+(\w+)/i, /in\s+(\w+\s+\d{4})/i, /before\s+(\w+\s+\d{4})/i, /(\w+)\s+(202[0-9])/i, // Match "march 2025" format ]; // Parse dates and convert to filter conditions let remainingText = remainingQuery; for (const pattern of datePatterns) { const match = remainingText.match(pattern); if (match) { // Remove the date portion from remaining text remainingText = remainingText.replace(pattern, '').trim(); // Parse common date formats if (match[0].includes('march') || match[0].includes('March')) { const year = match[2] || new Date().getFullYear(); parsed.filters.dateRange = { start: `${year}-03-01T00:00:00Z`, end: `${year}-03-31T23:59:59Z` }; } // Add more date parsing as needed } } // Add remaining terms const words = remainingText.trim().split(/\s+/).filter(Boolean); parsed.terms = [...quotedPhrases, ...words]; return parsed; } /** * Expand names to known email addresses using AliasManager */ expandAliases(names) { const expanded = new Set(); for (const name of names) { // Add original expanded.add(name); // Use AliasManager to resolve aliases const resolved = this.aliasManager.resolveAlias(name); resolved.forEach(email => expanded.add(email)); // Also add name variations const variations = this.aliasManager.getNameVariations(name); variations.forEach(variation => expanded.add(variation)); } return Array.from(expanded); } /** * Expand search terms with semantic concepts */ expandConcepts(terms, enableSemanticExpansion = true) { const expanded = new Set(); for (const term of terms) { const lower = term.toLowerCase(); expanded.add(term); // Only expand concepts if semantic expansion is enabled if (enableSemanticExpansion) { // Add concept mappings if (CONCEPT_MAPPINGS[lower]) { CONCEPT_MAPPINGS[lower].forEach(concept => expanded.add(concept)); } // Check if term is part of any concept for (const [concept, mappings] of Object.entries(CONCEPT_MAPPINGS)) { if (mappings.includes(lower)) { expanded.add(concept); mappings.forEach(m => expanded.add(m)); } } } } return Array.from(expanded); } /** * Build Microsoft Graph filter string */ buildGraphFilter(query, options) { const filters = []; // Escape special characters in values to prevent OData syntax errors const escapeODataValue = (value) => { // First escape single quotes (most common issue) let escaped = value.replace(/'/g, "''"); // Escape other OData special characters // Note: We don't escape all URL chars as OData handles most of them // We focus on chars that break OData filter syntax escaped = escaped.replace(/[\r\n\t]/g, ' '); // Replace control chars with space return escaped; }; // From filter with alias expansion if (query.filters.from.length > 0) { const expandedFrom = this.expandAliases(query.filters.from); const fromConditions = expandedFrom.map(email => { const escapedEmail = escapeODataValue(email); if (email.includes('@')) { return `from/emailAddress/address eq '${escapedEmail}'`; } else { // For name searches, use displayName field which is more reliable return `contains(from/emailAddress/name, '${escapedEmail}')`; } }); if (fromConditions.length === 1) { filters.push(fromConditions[0]); } else { filters.push(`(${fromConditions.join(' or ')})`); } } // To filter if (query.filters.to.length > 0) { const expandedTo = this.expandAliases(query.filters.to); const toConditions = expandedTo.map(email => { const escapedEmail = escapeODataValue(email); if (email.includes('@')) { return `toRecipients/any(r: r/emailAddress/address eq '${escapedEmail}')`; } else { return `toRecipients/any(r: contains(r/emailAddress/name, '${escapedEmail}'))`; } }); if (toConditions.length === 1) { filters.push(toConditions[0]); } else { filters.push(`(${toConditions.join(' or ')})`); } } // Subject filter if (query.filters.subject.length > 0) { const subjectConditions = query.filters.subject.map(subject => { const escapedSubject = escapeODataValue(subject); return `contains(subject, '${escapedSubject}')`; }); if (subjectConditions.length === 1) { filters.push(subjectConditions[0]); } else { filters.push(`(${subjectConditions.join(' or ')})`); } } // Has attachments if (query.filters.hasAttachments !== undefined) { filters.push(`hasAttachments eq ${query.filters.hasAttachments}`); } // Date range filters if (query.filters.dateRange) { if (query.filters.dateRange.start) { const startDate = typeof query.filters.dateRange.start === 'string' ? query.filters.dateRange.start : query.filters.dateRange.start.toISOString(); filters.push(`receivedDateTime ge ${startDate}`); } if (query.filters.dateRange.end) { const endDate = typeof query.filters.dateRange.end === 'string' ? query.filters.dateRange.end : query.filters.dateRange.end.toISOString(); filters.push(`receivedDateTime le ${endDate}`); } } return filters.join(' and '); } /** * Build Microsoft Graph search query */ buildSearchQuery(terms, requireAll = true, enableSemanticExpansion = true) { if (terms.length === 0) return ''; // Expand concepts if semantic search is enabled const expandedTerms = this.expandConcepts(terms, enableSemanticExpansion); // Filter and sanitize terms for Graph API search const validTerms = expandedTerms.filter(term => { // Remove pure numbers which cause Graph API syntax errors if (/^\d+$/.test(term)) { logger.debug(`Filtering out numeric term: ${term} (use date filters instead)`); return false; } // Remove very short terms that aren't meaningful for search if (term.length < 2) { return false; } // Remove terms with special characters that break Graph search if (/[<>{}[\]\\]/.test(term)) { logger.debug(`Filtering out term with special characters: ${term}`); return false; } return true; }).map(term => { // Escape any remaining problematic characters return term.replace(/["]/g, "'"); }); if (validTerms.length === 0) { logger.debug('No valid search terms after filtering'); return ''; } if (requireAll) { // AND logic: all terms must be present return validTerms.map(term => `"${term}"`).join(' AND '); } else { // OR logic: any term can match return validTerms.map(term => `"${term}"`).join(' OR '); } } /** * Perform unified email search with intelligent fallbacks */ async search(query, options = {}) { // Convert string to SearchQuery if needed const searchQuery = typeof query === 'string' ? { text: query, requireAllTerms: true, includeAliases: true } : query; // Parse the query const parsed = this.parseQuery(searchQuery.text); // Handle OR operations by executing multiple searches if (parsed.hasOrOperator && parsed.orParts) { return this.executeOrSearch(parsed.orParts, searchQuery, options); } // Override parsed values with explicit SearchQuery properties if (searchQuery.from) parsed.filters.from = searchQuery.from; if (searchQuery.to) parsed.filters.to = searchQuery.to; if (searchQuery.subject) parsed.filters.subject = [searchQuery.subject]; if (searchQuery.hasAttachments !== undefined) parsed.filters.hasAttachments = searchQuery.hasAttachments; // Use chunked search to prevent token limit issues return this.executeChunkedSearch(parsed, searchQuery, options); } /** * Execute OR search by combining results from multiple searches */ async executeOrSearch(orParts, searchQuery, options) { const allEmails = new Map(); // Use Map to deduplicate by ID let totalSearches = 0; let successfulSearches = 0; logger.info('Executing OR search', { parts: orParts.length }); for (const part of orParts) { try { totalSearches++; const partQuery = { ...searchQuery, text: part }; const partResult = await this.executeChunkedSearch(this.parseQuery(part), partQuery, { ...options, limit: Math.ceil((options.limit || 20) / orParts.length) }); // Add unique emails to the result partResult.emails.forEach(email => { allEmails.set(email.id, email); }); successfulSearches++; } catch (error) { logger.warn('OR part search failed', { part, error: error instanceof Error ? error.message : error }); } } const emails = Array.from(allEmails.values()); return { emails: emails.slice(0, options.limit || 20), totalCount: emails.length, hasMore: emails.length > (options.limit || 20), searchStrategy: 'or_combined', suggestions: successfulSearches < totalSearches ? [`Combined results from ${successfulSearches}/${totalSearches} OR parts. Some searches failed.`] : undefined, }; } /** * Execute search with automatic chunking to prevent token limit issues */ async executeChunkedSearch(parsed, searchQuery, options) { const accountId = options.accountId || 'default'; const totalLimit = options.limit || searchQuery.limit || 20; const includeBody = options.includeBody || false; // Calculate chunk size based on whether we're including body content const chunkSize = includeBody ? 5 : 15; // Smaller chunks when including body to prevent token limits let allEmails = []; let skip = options.offset || 0; let hasMore = false; let strategy = 'chunked_search'; let suggestions = []; try { // Strategy 1: Try with full search query (only if we have searchable terms) const searchTerms = [...parsed.terms, ...parsed.filters.subject]; // Disable semantic expansion if exactMatch is true const enableSemanticExpansion = !options.exactMatch && (options.enableSemanticExpansion !== false); const graphSearchQuery = searchTerms.length > 0 ? this.buildSearchQuery(searchTerms, searchQuery.requireAllTerms ?? true, enableSemanticExpansion) : undefined; const filter = this.buildGraphFilter(parsed, options); logger.info('Chunked search attempt', { searchQuery: graphSearchQuery, filter, chunkSize, totalLimit }); while (allEmails.length < totalLimit) { const currentChunkSize = Math.min(chunkSize, totalLimit - allEmails.length); const graphOptions = { search: graphSearchQuery || undefined, filter: filter || undefined, top: currentChunkSize, skip: skip + allEmails.length, // Microsoft Graph API doesn't support $orderBy with $search queries orderby: graphSearchQuery ? undefined // No orderBy with search queries : (options.orderBy ? `${options.orderBy} ${options.orderDirection || 'desc'}` : 'receivedDateTime desc'), select: includeBody ? undefined : 'id,subject,from,toRecipients,receivedDateTime,hasAttachments,importance,isRead,categories', }; const chunk = await this.mailService.queryMessages(accountId, graphOptions); if (chunk.length === 0) { break; // No more results } allEmails.push(...chunk); hasMore = chunk.length === currentChunkSize; if (chunk.length < currentChunkSize) { hasMore = false; break; // We got all available results } } // If no results and we were using AND logic, try OR logic if (allEmails.length === 0 && searchQuery.requireAllTerms) { logger.info('No results with AND logic, trying OR logic'); strategy = 'or_fallback'; const orGraphSearchQuery = searchTerms.length > 0 ? this.buildSearchQuery(searchTerms, false) : undefined; const orMessages = await this.mailService.queryMessages(accountId, { search: orGraphSearchQuery || undefined, filter: filter || undefined, top: totalLimit, skip: skip, // Microsoft Graph API doesn't support $orderBy with $search queries orderby: orGraphSearchQuery ? undefined : 'receivedDateTime desc', }); allEmails = orMessages; hasMore = orMessages.length === totalLimit; suggestions = [`Found emails containing ANY of: ${searchTerms.join(', ')}`]; } } catch (error) { logger.error('Chunked search error, trying fallback', { error }); // Strategy 2: Fallback to filter-only search try { const filter = this.buildGraphFilter(parsed, options); if (filter) { const messages = await this.mailService.queryMessages(accountId, { filter, top: totalLimit, skip: skip, orderby: 'receivedDateTime desc', }); allEmails = messages; hasMore = messages.length === totalLimit; strategy = 'filter_only_fallback'; suggestions = ['Search terms were too complex. Showing results based on filters only.']; } } catch (fallbackError) { logger.error('Fallback search also failed', { fallbackError }); throw new Error(`Search failed: ${fallbackError instanceof Error ? fallbackError.message : fallbackError}`); } } // Add helpful suggestions if no results if (allEmails.length === 0) { suggestions = this.generateSearchSuggestions(parsed); } return { emails: allEmails, totalCount: allEmails.length, hasMore, searchStrategy: strategy, suggestions: suggestions.length > 0 ? suggestions : undefined, }; } /** * Generate helpful search suggestions */ generateSearchSuggestions(parsed) { const suggestions = []; // Check if user is searching for a name that might be an alias for (const term of parsed.terms) { const resolved = this.aliasManager.resolveAlias(term); if (resolved.length > 0) { suggestions.push(`Try searching for emails from: ${resolved.join(' or ')}`); } // Also check for name variations const variations = this.aliasManager.getNameVariations(term); if (variations.length > 1) { suggestions.push(`Also known as: ${variations.filter(v => v !== term).join(', ')}`); } } // Suggest using filters if (parsed.filters.from.length === 0 && parsed.terms.some(t => t.toLowerCase().includes('from'))) { suggestions.push('Use "from:email@example.com" to filter by sender'); } // Suggest date ranges if (parsed.terms.some(t => /\d{4}/.test(t) || ['yesterday', 'today', 'week', 'month'].includes(t.toLowerCase()))) { suggestions.push('Date filtering coming soon. For now, try searching by subject or sender.'); } // General suggestions suggestions.push('Try fewer or different search terms', 'Use quotes for exact phrases: "project update"', 'Search by sender: from:john@example.com'); return suggestions; } } // Export schema for tool validation export const searchQuerySchema = z.object({ text: z.string().describe('Natural language search query'), from: z.array(z.string()).optional().describe('Filter by sender names or emails'), to: z.array(z.string()).optional().describe('Filter by recipient names or emails'), subject: z.string().optional().describe('Filter by subject line'), hasAttachments: z.boolean().optional().describe('Filter by attachment presence'), dateRange: z.object({ start: z.string().optional().transform(s => s ? new Date(s) : undefined), end: z.string().optional().transform(s => s ? new Date(s) : undefined), }).optional(), folders: z.array(z.string()).optional().describe('Filter by folder names'), categories: z.array(z.string()).optional().describe('Filter by categories/labels'), isRead: z.boolean().optional().describe('Filter by read status'), importance: z.enum(['low', 'normal', 'high']).optional(), limit: z.number().optional().default(20), requireAllTerms: z.boolean().optional().default(true).describe('Use AND logic (true) or OR logic (false)'), includeAliases: z.boolean().optional().default(true).describe('Expand known name aliases'), semanticSearch: z.boolean().optional().default(true).describe('Expand search with related concepts'), }); export const searchOptionsSchema = z.object({ accountId: z.string().optional().default('default'), limit: z.number().optional().default(20), offset: z.number().optional().default(0), orderBy: z.enum(['receivedDateTime', 'subject', 'from', 'importance', 'relevance']).optional().default('receivedDateTime'), orderDirection: z.enum(['asc', 'desc']).optional().default('desc'), includeBody: z.boolean().optional().default(false), includeSentItems: z.boolean().optional().default(true), }); //# sourceMappingURL=unifiedSearch.js.map