/** * 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;