msexchange-mcp
Version:
MCP server for Microsoft Exchange/Outlook email operations via Microsoft Graph API
535 lines • 24.4 kB
JavaScript
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