'use strict'; /** * get-email-thread tool * Fetches a set of email message IDs, cleans each body, strips quoted history * from each message, and returns a single clean chronological thread. * * This dramatically reduces token usage vs calling read-emails on a chain — * each message no longer carries the full history of every prior reply. */ const config = require('../config'); const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { buildThread } = require('../utils/threadBuilder'); const MAX_MESSAGES = 20; /** * Fetch all messages in a conversation across ALL folders (inbox + sent + etc.) * using the conversationId filter on the global me/messages endpoint. */ async function fetchByConversationId(accessToken, conversationId) { const allMessages = []; let url = 'me/messages'; let params = { $filter: `conversationId eq '${conversationId}'`, $select: config.EMAIL_DETAIL_FIELDS, $top: MAX_MESSAGES, // NOTE: $orderby intentionally omitted — combining $filter on conversationId // with $orderby causes a Graph API "InefficientFilter" 400 error. // buildThread() handles chronological sorting in memory instead. }; // Page through results (unlikely to exceed one page for most threads, but safe) while (url) { const page = await callGraphAPI(accessToken, 'GET', url, null, params); if (page.value) allMessages.push(...page.value); url = page['@odata.nextLink'] || null; params = null; // params are embedded in nextLink on subsequent pages } return allMessages; } /** * Handler for the get-email-thread tool. * @param {object} args * @param {string[]} [args.ids] - Explicit message IDs to include * @param {string} [args.conversationId] - Fetch entire conversation from all folders * @param {string} [args.subject] - Optional subject label for the thread header */ async function handleGetEmailThread(args) { const { ids, conversationId, subject } = args || {}; const hasIds = Array.isArray(ids) && ids.length > 0; const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0; if (!hasIds && !hasConvId) { return { content: [{ type: 'text', text: 'Provide either an ids array or a conversationId.' }] }; } if (hasIds && ids.length > MAX_MESSAGES) { return { content: [{ type: 'text', text: `Maximum ${MAX_MESSAGES} messages per thread request. ${ids.length} provided.` }] }; } let accessToken; try { accessToken = await ensureAuthenticated(); } catch { return { content: [{ type: 'text', text: "Authentication required. Please use the 'authenticate' tool first." }] }; } let messages = []; let failCount = 0; if (hasConvId) { // Auto-fetch entire conversation from all folders (inbox + sent + etc.) try { messages = await fetchByConversationId(accessToken, conversationId.trim()); } catch (err) { console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`); return { content: [{ type: 'text', text: `Failed to fetch conversation: ${err.message}` }] }; } // If caller also passed explicit IDs, merge in any that weren't in the conversation result if (hasIds) { const fetchedIds = new Set(messages.map(m => m.id)); const extras = await Promise.all( ids.filter(id => !fetchedIds.has(id)).map(async (id) => { try { return await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS }); } catch (err) { console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); failCount++; return null; } }) ); messages.push(...extras.filter(Boolean)); } } else { // IDs-only path — fetch concurrently, same as before const results = await Promise.all(ids.map(async (id) => { try { const message = await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS }); return { message, error: null }; } catch (err) { console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); return { message: null, error: err.message }; } })); messages = results.filter(r => r.message).map(r => r.message); failCount = results.filter(r => r.error).length; } if (messages.length === 0) { return { content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId and authentication.' }] }; } const thread = buildThread(messages, subject); const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : ''; return { content: [{ type: 'text', text: thread + note }] }; } const threadTool = { name: 'get-email-thread', description: 'Fetch a complete email thread and return it as a single clean deduplicated conversation, sorted chronologically. Each message shows only its unique new content — quoted prior replies are stripped, and signatures are deduplicated per sender. Prefer conversationId (from list-emails or search-emails results) to automatically include both inbox AND sent items in the thread. Fall back to ids when you only have specific message IDs without a conversationId.', inputSchema: { type: 'object', properties: { conversationId: { type: 'string', description: 'Conversation ID (from list-emails). Fetches the COMPLETE thread from all folders including Sent Items. Preferred over ids for full thread reconstruction.' }, ids: { type: 'array', items: { type: 'string' }, description: 'Array of specific message IDs to include (max 20). Use when you only have individual IDs and no conversationId.', maxItems: MAX_MESSAGES }, subject: { type: 'string', description: 'Optional: override the thread subject shown in the header' } } }, handler: handleGetEmailThread }; module.exports = { threadTool, handleGetEmailThread };