outlook-mcp/folder/list.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

307 lines
9.4 KiB
JavaScript

/**
* List folders functionality
*/
const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/**
* List folders handler
* @param {object} args - Tool arguments
* @returns {object} - MCP response
*/
async function handleListFolders(args) {
const includeItemCounts = args.includeItemCounts === true || args.includeItemCounts === 'true';
const includeChildren = args.includeChildren === true || args.includeChildren === 'true';
const mb = normalizeMailbox(args.mailbox);
try {
// Get access token
const accessToken = await ensureAuthenticated();
// Get all mail folders
const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts, mb);
const mailboxPrefix = mb.kind === 'user' ? `Mailbox: ${mb.smtpOrUpn}\n\n` : '';
// If including children, format as hierarchy
if (includeChildren) {
return {
content: [{
type: "text",
text: mailboxPrefix + formatFolderHierarchy(folders, includeItemCounts)
}]
};
} else {
// Otherwise, format as flat list
return {
content: [{
type: "text",
text: mailboxPrefix + formatFolderList(folders, includeItemCounts)
}]
};
}
} catch (error) {
if (error.message === 'Authentication required') {
return {
content: [{
type: "text",
text: "Authentication required. Please use the 'authenticate' tool first."
}]
};
}
return {
content: [{
type: "text",
text: `Error listing folders: ${error.message}`
}]
};
}
}
/**
* Get all mail folders with hierarchy information
* @param {string} accessToken - Access token
* @param {boolean} includeItemCounts - Include item counts in response
* @param {object} mb - normalizeMailbox result
* @returns {Promise<Array>} - Array of folder objects with hierarchy
*/
async function getAllFoldersHierarchy(accessToken, includeItemCounts, mb = normalizeMailbox(null)) {
try {
const opts = { headers: withMailboxHeaders(mb) };
// Determine select fields based on whether to include counts
const selectFields = includeItemCounts
? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
: 'id,displayName,parentFolderId,childFolderCount';
// Get all mail folders
const response = await callGraphAPI(
accessToken,
'GET',
buildPath(mb, 'mailFolders'),
null,
{
$top: 100,
$select: selectFields
},
opts
);
if (!response.value) {
return [];
}
// Get child folders for folders with children
const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0);
const childFolderPromises = foldersWithChildren.map(async (folder) => {
try {
const childResponse = await callGraphAPI(
accessToken,
'GET',
buildPath(mb, `mailFolders/${folder.id}/childFolders`),
null,
{ $select: selectFields },
opts
);
// Add parent folder info to each child
const childFolders = childResponse.value || [];
childFolders.forEach(child => {
child.parentFolder = folder.displayName;
});
return childFolders;
} catch (error) {
console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`);
return [];
}
});
const childFolders = await Promise.all(childFolderPromises);
const allChildFolders = childFolders.flat();
// Add top-level flag to parent folders
const topLevelFolders = response.value.map(folder => ({
...folder,
isTopLevel: true
}));
// Combine all folders and deduplicate by ID. Some folders can appear both as
// top-level entries and as children of other folders (e.g. Junk Email under
// RecoverableItems), causing duplicate output.
const combined = [...topLevelFolders, ...allChildFolders];
const deduped = Array.from(
new Map(combined.map(folder => [folder.id, folder])).values()
);
// Mark folders whose displayName is shared by multiple distinct IDs so the
// formatter can include the ID to tell them apart.
const displayNameCounts = new Map();
for (const folder of deduped) {
displayNameCounts.set(folder.displayName, (displayNameCounts.get(folder.displayName) || 0) + 1);
}
for (const folder of deduped) {
if (displayNameCounts.get(folder.displayName) > 1) {
folder.hasDuplicateName = true;
}
}
return deduped;
} catch (error) {
console.error(`Error getting all folders: ${error.message}`);
throw error;
}
}
/**
* Format folders as a flat list
* @param {Array} folders - Array of folder objects
* @param {boolean} includeItemCounts - Whether to include item counts
* @returns {string} - Formatted list
*/
function formatFolderList(folders, includeItemCounts) {
if (!folders || folders.length === 0) {
return "No folders found.";
}
// Sort folders alphabetically, with well-known folders first
const wellKnownFolderNames = ['Inbox', 'Drafts', 'Sent Items', 'Deleted Items', 'Junk Email', 'Archive'];
const sortedFolders = [...folders].sort((a, b) => {
// Well-known folders come first
const aIsWellKnown = wellKnownFolderNames.includes(a.displayName);
const bIsWellKnown = wellKnownFolderNames.includes(b.displayName);
if (aIsWellKnown && !bIsWellKnown) return -1;
if (!aIsWellKnown && bIsWellKnown) return 1;
if (aIsWellKnown && bIsWellKnown) {
// Sort well-known folders by their index in the array
return wellKnownFolderNames.indexOf(a.displayName) - wellKnownFolderNames.indexOf(b.displayName);
}
// Sort other folders alphabetically
return a.displayName.localeCompare(b.displayName);
});
// Format each folder
const folderLines = sortedFolders.map(folder => {
let folderInfo = folder.displayName;
// Include the raw folder id when multiple distinct folders share the
// same displayName (e.g. two Junk Email folders).
if (folder.hasDuplicateName) {
folderInfo += ` [id: ${folder.id}]`;
}
// Add parent folder info if available
if (folder.parentFolder) {
folderInfo += ` (in ${folder.parentFolder})`;
}
// Add item counts if requested
if (includeItemCounts) {
const unreadCount = folder.unreadItemCount || 0;
const totalCount = folder.totalItemCount || 0;
folderInfo += ` - ${totalCount} items`;
if (unreadCount > 0) {
folderInfo += ` (${unreadCount} unread)`;
}
}
return folderInfo;
});
return `Found ${folders.length} folders:\n\n${folderLines.join('\n')}`;
}
/**
* Format folders as a hierarchical tree
* @param {Array} folders - Array of folder objects
* @param {boolean} includeItemCounts - Whether to include item counts
* @returns {string} - Formatted hierarchy
*/
function formatFolderHierarchy(folders, includeItemCounts) {
if (!folders || folders.length === 0) {
return "No folders found.";
}
// Build folder hierarchy
const folderMap = new Map();
const rootFolders = [];
// First pass: create map of all folders
folders.forEach(folder => {
folderMap.set(folder.id, {
...folder,
children: []
});
if (folder.isTopLevel) {
rootFolders.push(folder.id);
}
});
// Second pass: build hierarchy
folders.forEach(folder => {
if (!folder.isTopLevel && folder.parentFolderId) {
const parent = folderMap.get(folder.parentFolderId);
if (parent) {
parent.children.push(folder.id);
} else {
// Fallback for orphaned folders
rootFolders.push(folder.id);
}
}
});
// Format hierarchy recursively
function formatSubtree(folderId, level = 0) {
const folder = folderMap.get(folderId);
if (!folder) return '';
const indent = ' '.repeat(level);
let line = `${indent}${folder.displayName}`;
// Include the raw folder id when multiple distinct folders share the
// same displayName (e.g. two Junk Email folders).
if (folder.hasDuplicateName) {
line += ` [id: ${folder.id}]`;
}
// Add item counts if requested
if (includeItemCounts) {
const unreadCount = folder.unreadItemCount || 0;
const totalCount = folder.totalItemCount || 0;
line += ` - ${totalCount} items`;
if (unreadCount > 0) {
line += ` (${unreadCount} unread)`;
}
}
// Add children
const childLines = folder.children
.map(childId => formatSubtree(childId, level + 1))
.filter(line => line.length > 0)
.join('\n');
return childLines.length > 0 ? `${line}\n${childLines}` : line;
}
// Format all root folders
const formattedHierarchy = rootFolders
.map(folderId => formatSubtree(folderId))
.join('\n');
return `Folder Hierarchy:\n\n${formattedHierarchy}`;
}
module.exports = {
handleListFolders,
formatFolderList,
formatFolderHierarchy
};