outlook-mcp/tools/get-email-thread.js
Seton Carmichael e70840552d feat(outlook-mcp): shared mailbox targeting, discovery, and scopes (v1.1.0)
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.
2026-08-24 08:41:05 -04:00

182 lines
6.6 KiB
JavaScript

'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 { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
const MAX_MESSAGES = 20;
/**
* Fetch all messages in a conversation across ALL folders (inbox + sent + etc.)
* using the conversationId filter on the mailbox messages endpoint.
*/
async function fetchByConversationId(accessToken, conversationId, mb) {
const allMessages = [];
const opts = { headers: withMailboxHeaders(mb) };
let url = buildPath(mb, '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.
};
// Single page is enough for most threads; avoid following absolute nextLink URLs
// through callGraphAPI (path encoder is relative-path only).
const page = await callGraphAPI(accessToken, 'GET', url, null, params, opts);
if (page.value) allMessages.push(...page.value);
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
* @param {string} [args.mailbox] - Optional shared mailbox UPN/SMTP
*/
async function handleGetEmailThread(args) {
const { ids, conversationId, subject } = args || {};
const mb = normalizeMailbox(args && args.mailbox);
const opts = { headers: withMailboxHeaders(mb) };
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) {
try {
messages = await fetchByConversationId(accessToken, conversationId.trim(), mb);
} catch (err) {
console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`);
return {
content: [{ type: 'text', text: `Failed to fetch conversation: ${err.message}` }]
};
}
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',
buildPath(mb, `messages/${id}`),
null,
{ $select: config.EMAIL_DETAIL_FIELDS },
opts
);
} catch (err) {
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
failCount++;
return null;
}
})
);
messages.push(...extras.filter(Boolean));
}
} else {
const results = await Promise.all(ids.map(async (id) => {
try {
const message = await callGraphAPI(
accessToken,
'GET',
buildPath(mb, `messages/${id}`),
null,
{ $select: config.EMAIL_DETAIL_FIELDS },
opts
);
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, mailbox, and authentication.' }]
};
}
const thread = buildThread(messages, subject);
const mbNote = mb.kind === 'user' ? `\n\n(Mailbox: ${mb.smtpOrUpn})` : '';
const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : '';
return {
content: [{ type: 'text', text: thread + mbNote + 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. Pass mailbox when the conversation is in a shared mailbox.',
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'
},
mailbox: {
type: 'string',
description: "Optional shared/delegated mailbox UPN or SMTP. Omit for primary mailbox. Must match the mailbox the conversationId/IDs came from."
}
}
},
handler: handleGetEmailThread
};
module.exports = { threadTool, handleGetEmailThread };