/** * 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'); const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox'); /** * 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'; const mb = normalizeMailbox(args.mailbox); const graphOpts = { headers: withMailboxHeaders(mb) }; // 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, mb); console.error(`Using endpoint: ${endpoint} for folder: ${folder} mailbox=${mb.graphRoot}`); // Execute progressive search const response = await progressiveSearch( endpoint, accessToken, { query, from, to, subject }, { hasAttachments, unreadOnly }, count, strict, { dateFrom, dateTo, dateRange }, graphOpts ); return formatSearchResults(response, { dateFrom, dateTo, dateRange, mailbox: mb.kind === 'user' ? mb.smtpOrUpn : null }); } 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 = {}, graphOpts = {}) { 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, graphOpts); 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, graphOpts); 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, graphOpts); 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, graphOpts); 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}`); if (dateOpts.mailbox) dateParts.push(`mailbox: ${dateOpts.mailbox}`); 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;