outlook-mcp/email/search.js

331 lines
12 KiB
JavaScript

/**
* Improved search emails functionality
*/
const config = require('../config');
const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth');
const { resolveFolderPath } = require('./folder-utils');
const { formatDateTime } = require('../utils/time-formatter');
const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers');
/**
* Search emails handler
* @param {object} args - Tool arguments
* @returns {object} - MCP response
*/
async function handleSearchEmails(args) {
const folder = args.folder || "inbox";
// Coerce count — MCP hosts may send numbers as strings
const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT);
const query = args.query || '';
const from = args.from || '';
const to = args.to || '';
const subject = args.subject || '';
// Coerce booleans — MCP hosts may send numbers/booleans as strings
const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined;
const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined;
const strict = args.strict === true || args.strict === 'true';
// Date filtering uses the same timezone-aware helpers as list-emails.
const dateFrom = args.dateFrom || '';
const dateTo = args.dateTo || '';
const dateRange = args.dateRange || '';
try {
// Get access token
const accessToken = await ensureAuthenticated();
// Resolve the folder path
const endpoint = await resolveFolderPath(accessToken, folder);
console.error(`Using endpoint: ${endpoint} for folder: ${folder}`);
// Execute progressive search
const response = await progressiveSearch(
endpoint,
accessToken,
{ query, from, to, subject },
{ hasAttachments, unreadOnly },
count,
strict,
{ dateFrom, dateTo, dateRange }
);
return formatSearchResults(response, { dateFrom, dateTo, dateRange });
} catch (error) {
// Handle authentication errors
if (error.message === 'Authentication required') {
return {
content: [{
type: "text",
text: "Authentication required. Please use the 'authenticate' tool first."
}]
};
}
// General error response
return {
content: [{
type: "text",
text: `Error searching emails: ${error.message}`
}]
};
}
}
/**
* Execute a search with progressively simpler fallback strategies.
*
* Microsoft Graph API constraints on /me/messages:
* - $search and $orderby CANNOT be used together (causes 400)
* - $search and $filter CANNOT be used together (causes 400)
* - $filter and $orderby CAN be used together
*
* Strategy:
* 1. Text terms present → $search with proper KQL (no $orderby, no $filter),
* then apply boolean filters client-side
* 2. Text terms present → retry with each term individually (same approach)
* 3. Only boolean filters → $filter + $orderby (fully supported)
* 4. Fallback → recent emails (only when not in strict mode; marked clearly)
*/
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}) {
const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject);
const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
const hasDateFilters = !!(dateOpts.dateFrom || dateOpts.dateTo || dateOpts.dateRange);
// Build timezone-aware date filter once. It will be applied server-side
// when we can, or client-side after $search results come back.
const dateConditions = hasDateFilters
? buildDateFilter(dateOpts.dateFrom, dateOpts.dateTo, dateOpts.dateRange)
: [];
const dateFilterString = buildODataFilter(dateConditions);
// Parse date bounds for client-side filtering.
let dateFromMs = null;
let dateToMs = null;
if (hasDateFilters && dateConditions.length > 0) {
for (const cond of dateConditions) {
const geMatch = cond.match(/receivedDateTime ge ([^)]+)/);
const leMatch = cond.match(/receivedDateTime le ([^)]+)/);
if (geMatch) dateFromMs = new Date(geMatch[1]).getTime();
if (leMatch) dateToMs = new Date(leMatch[1]).getTime();
}
}
const applyDateFilter = (emails) => {
if (!hasDateFilters || dateConditions.length === 0) return emails;
return emails.filter(email => {
const receivedMs = email.receivedDateTime ? new Date(email.receivedDateTime).getTime() : null;
if (receivedMs == null) return false;
if (dateFromMs != null && receivedMs < dateFromMs) return false;
if (dateToMs != null && receivedMs > dateToMs) return false;
return true;
});
};
// 1. Try combined KQL search (text terms only — boolean filters applied client-side)
if (hasTextTerms) {
try {
const kqlQuery = buildKqlQuery(searchTerms);
const params = {
$top: count,
$select: config.EMAIL_SELECT_FIELDS,
$search: kqlQuery
// NOTE: NO $orderby — not allowed with $search
// NOTE: NO $filter — not allowed with $search
};
console.error(`Attempting combined KQL search: ${kqlQuery}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
if (response.value && response.value.length > 0) {
let filtered = applyClientSideFilters(response.value, filterTerms);
filtered = applyDateFilter(filtered);
console.error(`Combined search found ${response.value.length} results, ${filtered.length} after filtering`);
if (filtered.length > 0) {
return { value: filtered };
}
}
} catch (error) {
console.error(`Combined KQL search failed: ${error.message}`);
}
// 2. Try each search term individually (priority: subject → from → to → query)
const termPriority = ['subject', 'from', 'to', 'query'];
for (const term of termPriority) {
if (!searchTerms[term]) continue;
try {
const kqlQuery = buildSingleTermKql(term, searchTerms[term]);
const params = {
$top: count,
$select: config.EMAIL_SELECT_FIELDS,
$search: kqlQuery
// NOTE: NO $orderby, NO $filter
};
console.error(`Attempting single-term search (${term}): ${kqlQuery}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
if (response.value && response.value.length > 0) {
let filtered = applyClientSideFilters(response.value, filterTerms);
filtered = applyDateFilter(filtered);
console.error(`Search on ${term} found ${response.value.length} results, ${filtered.length} after filtering`);
if (filtered.length > 0) {
return { value: filtered };
}
}
} catch (error) {
console.error(`Single-term search (${term}) failed: ${error.message}`);
}
}
}
// 3. Boolean filters (and/or date filters) — $filter + $orderby is supported
if (hasBooleanFilters || hasDateFilters) {
try {
const filterConditions = [];
if (filterTerms.hasAttachments === true) filterConditions.push('hasAttachments eq true');
if (filterTerms.unreadOnly === true) filterConditions.push('isRead eq false');
if (dateFilterString) filterConditions.push(dateFilterString);
const params = {
$top: count,
$select: config.EMAIL_SELECT_FIELDS,
$orderby: 'receivedDateTime desc',
$filter: filterConditions.join(' and ')
};
console.error(`Attempting filter-only search: ${params.$filter}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
console.error(`Filter-only search found ${response.value?.length || 0} results`);
return response;
} catch (error) {
console.error(`Filter-only search failed: ${error.message}`);
}
}
// 4. Final fallback: recent emails (disabled in strict mode)
console.error("All search strategies exhausted, falling back to recent emails");
if (strict) {
console.error('Strict mode enabled: returning empty results instead of fallback');
return { value: [], _searchFallback: false, _strict: true, _originalTerms: searchTerms };
}
const basicParams = {
$top: count,
$select: config.EMAIL_SELECT_FIELDS,
$orderby: 'receivedDateTime desc'
};
if (dateFilterString) {
basicParams.$filter = dateFilterString;
}
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams);
console.error(`Fallback to recent emails found ${response.value?.length || 0} results`);
if (dateFilterString) {
response.value = applyDateFilter(response.value || []);
}
response._searchFallback = true;
response._originalTerms = searchTerms;
return response;
}
/**
* Build a KQL query string for all provided search terms.
* The entire expression must be wrapped in outer double quotes for Graph API.
* Example: "subject:invoice from:john@example.com"
*/
function buildKqlQuery(searchTerms) {
const parts = [];
if (searchTerms.subject) parts.push(`subject:${searchTerms.subject}`);
if (searchTerms.from) parts.push(`from:${searchTerms.from}`);
if (searchTerms.to) parts.push(`to:${searchTerms.to}`);
if (searchTerms.query) parts.push(searchTerms.query);
return `"${parts.join(' ')}"`;
}
/**
* Build a KQL query for a single field term.
* Example: "from:john@example.com"
*/
function buildSingleTermKql(term, value) {
if (term === 'query') {
return `"${value}"`;
}
return `"${term}:${value}"`;
}
/**
* Apply boolean filter conditions to an in-memory array of emails.
* Used after $search results are returned (since $search + $filter is not supported).
*/
function applyClientSideFilters(emails, filterTerms) {
return emails.filter(email => {
if (filterTerms.hasAttachments === true && !email.hasAttachments) return false;
if (filterTerms.unreadOnly === true && email.isRead !== false) return false;
return true;
});
}
/**
* Format search results into a readable text format
* @param {object} response - The API response object
* @param {object} [dateOpts] - Optional date filter metadata for the result message
* @returns {object} - MCP response object
*/
function formatSearchResults(response, dateOpts = {}) {
if (!response.value || response.value.length === 0) {
return {
content: [{
type: "text",
text: `No emails found matching your search criteria.`
}]
};
}
// Format results
let emailList = response.value.map((email, index) => {
const sender = email.from?.emailAddress || { name: 'Unknown', address: 'unknown' };
const date = formatDateTime(email.receivedDateTime);
const readStatus = email.isRead ? '' : '[UNREAD] ';
const threadNote = email.conversationId ? `\nConversationID: ${email.conversationId}` : '';
return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}${threadNote}\n`;
}).join("\n");
// Strict mode: no results path
if (response._strict) {
return {
content: [{
type: "text",
text: `No emails found matching your search criteria. Strict mode is enabled; no fallback to recent emails was performed.`
}]
};
}
// Add fallback warning if search had to give up
let additionalInfo = '';
if (response._searchFallback) {
additionalInfo = `\n\n⚠️ FALLBACK: No messages matched the exact search terms. The ${response.value.length} result(s) below are the most recent emails from the folder, not search hits.`;
// Tag each listing so an automated parser can tell these are fallback results
emailList = emailList.replace(/^(\d+\.)\s*/gm, '$1 [FALLBACK] ');
}
const dateParts = [];
if (dateOpts.dateRange) dateParts.push(`dateRange: ${dateOpts.dateRange}`);
if (dateOpts.dateFrom) dateParts.push(`from: ${dateOpts.dateFrom}`);
if (dateOpts.dateTo) dateParts.push(`to: ${dateOpts.dateTo}`);
const dateInfo = dateParts.length > 0 ? ` (${dateParts.join(', ')})` : '';
return {
content: [{
type: "text",
text: `Found ${response.value.length} emails${dateInfo}:${additionalInfo}\n\n${emailList}`
}]
};
}
module.exports = handleSearchEmails;