UNPKG

msexchange-mcp

Version:

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

274 lines 12 kB
import { MailService } from './mailService.js'; import { SearchQuerySanitizer } from './searchQuerySanitizer.js'; export class ImprovedQueryBuilder { userEmail; mailService; DEFAULT_MAX_TOKENS = 20000; // Leave buffer below 25k limit constructor(userEmail) { this.userEmail = userEmail; this.mailService = new MailService(userEmail); } /** * Builds an improved filter that handles partial matches and complex scenarios */ buildImprovedFilter(options) { const conditions = []; // Handle sender filtering with partial match support if (options.from) { const fromConditions = this.buildEmailConditions('from', options.from, true); if (fromConditions) conditions.push(fromConditions); } // Handle recipient filtering - use simpler syntax to avoid errors if (options.to) { const toConditions = this.buildRecipientConditions(options.to); if (toConditions) conditions.push(toConditions); } // Subject with improved matching if (options.subject) { // Handle aliases in subject const subjectTerms = this.expandWithAliases(options.subject, options.aliases); if (subjectTerms.length === 1) { conditions.push(`contains(subject, '${this.escapeFilterValue(subjectTerms[0])}')`); } else { const subjectConditions = subjectTerms.map(term => `contains(subject, '${this.escapeFilterValue(term)}')`).join(' or '); conditions.push(`(${subjectConditions})`); } } // Body content search if (options.body) { conditions.push(`contains(body/content, '${this.escapeFilterValue(options.body)}')`); } // Boolean and enum filters if (options.hasAttachments !== undefined) { conditions.push(`hasAttachments eq ${options.hasAttachments}`); } if (options.isRead !== undefined) { conditions.push(`isRead eq ${options.isRead}`); } if (options.importance) { conditions.push(`importance eq '${options.importance}'`); } // Categories if (options.categories && options.categories.length > 0) { const categoryConditions = options.categories.map(cat => `categories/any(c: c eq '${this.escapeFilterValue(cat)}')`); conditions.push(categoryConditions.length === 1 ? categoryConditions[0] : `(${categoryConditions.join(' or ')})`); } // Date range if (options.dateRange?.start) { conditions.push(`receivedDateTime ge ${options.dateRange.start}T00:00:00Z`); } if (options.dateRange?.end) { conditions.push(`receivedDateTime le ${options.dateRange.end}T23:59:59Z`); } // Conversation ID if (options.conversationId) { conditions.push(`conversationId eq '${options.conversationId}'`); } return conditions.join(' and '); } /** * Build email conditions with support for partial matches */ buildEmailConditions(field, value, allowPartial = true) { const values = Array.isArray(value) ? value : [value]; const conditions = []; for (const email of values) { if (email.includes('@')) { // Full email address - use exact match conditions.push(`${field}/emailAddress/address eq '${this.escapeFilterValue(email)}'`); } else if (allowPartial) { // Partial name - use contains for flexibility conditions.push(`contains(${field}/emailAddress/address, '${this.escapeFilterValue(email)}')`); // Also search in display name conditions.push(`contains(${field}/emailAddress/name, '${this.escapeFilterValue(email)}')`); } } if (conditions.length === 0) return ''; if (conditions.length === 1) return conditions[0]; return `(${conditions.join(' or ')})`; } /** * Build recipient conditions with simplified syntax to avoid errors */ buildRecipientConditions(value) { const values = Array.isArray(value) ? value : [value]; const conditions = []; for (const recipient of values) { if (recipient.includes('@')) { // For full email addresses, we still need the lambda syntax // But we'll try to make it as simple as possible conditions.push(`toRecipients/any(r: r/emailAddress/address eq '${this.escapeFilterValue(recipient)}')`); } else { // For partial matches, we'll need a different approach // Graph API doesn't support contains within lambda expressions well // So we'll use subject/body search as a fallback console.warn(`Partial recipient search for "${recipient}" will use fallback method`); } } return conditions.join(' or '); } /** * Expand search terms with aliases */ expandWithAliases(term, aliases) { if (!aliases) return [term]; const expanded = new Set([term]); // Check if term matches any alias key for (const [key, values] of Object.entries(aliases)) { if (term.toLowerCase().includes(key.toLowerCase())) { values.forEach(v => expanded.add(v)); expanded.add(key); } // Also check if term matches any alias value for (const value of values) { if (term.toLowerCase().includes(value.toLowerCase())) { expanded.add(key); values.forEach(v => expanded.add(v)); } } } return Array.from(expanded); } /** * Escape special characters in filter values */ escapeFilterValue(value) { return value.replace(/'/g, "''").replace(/\\/g, "\\\\"); } /** * Execute query with automatic token management */ async executeWithTokenManagement(options) { const maxTokens = options.maxTokens || this.DEFAULT_MAX_TOKENS; let emails = []; let hadTokenIssue = false; let attempts = 0; const maxAttempts = 3; // Start with requested options let currentOptions = { ...options }; while (attempts < maxAttempts) { attempts++; try { // Build query based on current options const queryOptions = { top: currentOptions.limit || 20, skip: currentOptions.skip || 0, }; // Decide between search and filter if (currentOptions.search && !SearchQuerySanitizer.hasProblematicCharacters(currentOptions.search)) { queryOptions.search = currentOptions.search; } else if (currentOptions.filter || this.hasStructuredFilters(currentOptions)) { queryOptions.filter = currentOptions.filter || this.buildImprovedFilter(currentOptions); } // Only add orderBy if not using search if (!queryOptions.search && currentOptions.orderBy) { queryOptions.orderBy = currentOptions.orderBy; } // Select fields based on includeBody and attempt number queryOptions.select = this.getSelectFields(!!currentOptions.includeBody && attempts === 1); emails = await this.mailService.queryEmails(queryOptions); // Estimate response size (rough calculation) const estimatedTokens = JSON.stringify(emails).length / 4; if (estimatedTokens > maxTokens && currentOptions.includeBody) { hadTokenIssue = true; console.warn(`Response size (~${Math.round(estimatedTokens)} tokens) exceeds limit. Retrying without body content.`); currentOptions.includeBody = false; continue; } break; // Success } catch (error) { const errorMessage = error.message || ''; // Handle specific errors if (errorMessage.includes('exceeds maximum allowed tokens')) { hadTokenIssue = true; if (currentOptions.includeBody) { console.warn('Token limit exceeded. Retrying without body content.'); currentOptions.includeBody = false; } else if (currentOptions.limit && currentOptions.limit > 5) { console.warn('Token limit still exceeded. Reducing limit.'); currentOptions.limit = Math.floor(currentOptions.limit / 2); } else { throw new Error('Unable to retrieve emails within token limit. Try more specific search criteria.'); } } else if (errorMessage.includes('invalid nodes') && currentOptions.to) { console.warn('Recipient filter error. Trying alternative approach.'); // Move recipient search to subject/body search if (!currentOptions.search) { currentOptions.search = Array.isArray(currentOptions.to) ? currentOptions.to.join(' ') : currentOptions.to; } delete currentOptions.to; } else { throw error; // Re-throw unknown errors } } } const suggestions = []; if (hadTokenIssue) { suggestions.push('Results were limited due to size. For full content, fetch individual emails.'); suggestions.push(`Use get_email_by_id for specific emails.`); } return { emails, strategy: this.describeStrategy(currentOptions), hadTokenIssue, suggestions: suggestions.length > 0 ? suggestions : undefined }; } hasStructuredFilters(options) { return !!(options.from || options.to || options.subject || options.body || options.hasAttachments !== undefined || options.isRead !== undefined || options.dateRange || options.categories || options.importance); } getSelectFields(includeBody) { const baseFields = [ 'id', 'subject', 'from', 'toRecipients', 'ccRecipients', 'receivedDateTime', 'sentDateTime', 'hasAttachments', 'importance', 'isRead', 'isDraft', 'bodyPreview', 'categories', 'conversationId' ]; if (includeBody) { baseFields.push('body'); } return baseFields; } describeStrategy(options) { const parts = []; if (options.search) { parts.push(`search: "${options.search}"`); } if (options.filter) { parts.push('custom filter'); } if (options.from) { parts.push(`from: ${Array.isArray(options.from) ? options.from.join(', ') : options.from}`); } if (options.to) { parts.push(`to: ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`); } if (options.aliases) { parts.push('with alias expansion'); } if (options.includeBody === false) { parts.push('(body excluded for performance)'); } return parts.join(', ') || 'all emails'; } } //# sourceMappingURL=improvedQueryBuilder.js.map