/** * 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 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 };