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