Add optional mailbox (UPN/SMTP) routing on email/folder/thread tools via
users/{upn}/... and X-AnchorMailbox. New list-mailboxes probes primary,
OUTLOOK_SHARED_MAILBOXES seeds, cache, and candidates. Send supports
mailbox-rooted sendMail and onBehalfOf. MSAL requests Mail.*.Shared;
check-auth-status reports token scp gaps. Docs, env example, tests.
336 lines
12 KiB
JavaScript
336 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');
|
|
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;
|