outlook-mcp/email/search.js
Seton Carmichael 68a64461eb fix(outlook-mcp): P0 audit fixes
- Handle empty 2xx Graph API responses without JSON parse errors
- Fix list-rules handler import (handleListRules named export)
- Add timezone-aware display formatting using MS_TIMEZONE / DEFAULT_TIMEZONE
- Return created event ID in create-event success message
- Change default timezone from Windows name to IANA (America/New_York)
2026-06-21 20:28:11 -04:00

258 lines
8.9 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');
/**
* 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 "true"/"false" as strings
const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined;
const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined;
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
);
return formatSearchResults(response);
} 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 (labeled in response)
*/
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count) {
const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject);
const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === 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) {
const filtered = applyClientSideFilters(response.value, filterTerms);
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) {
const filtered = applyClientSideFilters(response.value, filterTerms);
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 only (no text search) — $filter + $orderby is supported
if (hasBooleanFilters) {
try {
const filterConditions = [];
if (filterTerms.hasAttachments === true) filterConditions.push('hasAttachments eq true');
if (filterTerms.unreadOnly === true) filterConditions.push('isRead eq false');
const params = {
$top: count,
$select: config.EMAIL_SELECT_FIELDS,
$orderby: 'receivedDateTime desc',
$filter: filterConditions.join(' and ')
};
console.error(`Attempting boolean-filter-only search: ${params.$filter}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
console.error(`Boolean filter search found ${response.value?.length || 0} results`);
return response;
} catch (error) {
console.error(`Boolean filter search failed: ${error.message}`);
}
}
// 4. Final fallback: recent emails
console.error("All search strategies exhausted, falling back to recent emails");
const basicParams = {
$top: count,
$select: config.EMAIL_SELECT_FIELDS,
$orderby: 'receivedDateTime desc'
};
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams);
console.error(`Fallback to recent emails found ${response.value?.length || 0} results`);
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
* @returns {object} - MCP response object
*/
function formatSearchResults(response) {
if (!response.value || response.value.length === 0) {
return {
content: [{
type: "text",
text: `No emails found matching your search criteria.`
}]
};
}
// Format results
const 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");
// Add fallback warning if search had to give up
let additionalInfo = '';
if (response._searchFallback) {
additionalInfo = `\n⚠️ Search could not find matches for the specified criteria — showing recent emails instead.`;
}
return {
content: [{
type: "text",
text: `Found ${response.value.length} emails:${additionalInfo}\n\n${emailList}`
}]
};
}
module.exports = handleSearchEmails;