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.
182 lines
6.6 KiB
JavaScript
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 };
|