/** * list-mailboxes — discover primary + candidate shared mailboxes and probe access. * * Graph cannot enumerate "mailboxes I have rights to". Candidates come from: * - primary (always) * - OUTLOOK_SHARED_MAILBOXES env * - local probe cache * - explicit candidates[] argument * * Optional directorySearch is intentionally not implemented in v1 (needs User.ReadBasic.All * and still does not prove mailbox rights). */ const config = require('../config'); const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { normalizeMailbox, buildPath, withMailboxHeaders, parseSharedMailboxEnv, loadMailboxCache, saveMailboxCache } = require('../utils/mailbox'); /** * Run async work over items with a concurrency limit. */ async function mapPool(items, concurrency, fn) { const results = new Array(items.length); let next = 0; async function worker() { while (next < items.length) { const i = next++; results[i] = await fn(items[i], i); } } const n = Math.max(1, Math.min(concurrency || 4, items.length || 1)); await Promise.all(Array.from({ length: n }, () => worker())); return results; } /** * Probe a single mailbox (or primary). */ async function probeOne(accessToken, address, sources) { const now = new Date().toISOString(); if (!address || address === 'me' || address === 'primary') { try { const me = await callGraphAPI( accessToken, 'GET', 'me', null, { $select: 'displayName,mail,userPrincipalName' } ); const smtp = me.mail || me.userPrincipalName || 'me'; // Confirm mail read on primary let read = false; try { await callGraphAPI( accessToken, 'GET', 'me/mailFolders/inbox', null, { $select: 'id,displayName' } ); read = true; } catch { read = false; } return { address: smtp, displayName: me.displayName || smtp, isPrimary: true, capabilities: { read: read, listFolders: read, sendAs: 'self' }, source: ['me'], lastProbedAt: now, error: read ? null : 'Could not read primary inbox' }; } catch (e) { return { address: 'me', displayName: 'primary', isPrimary: true, capabilities: { read: false, listFolders: false, sendAs: 'self' }, source: ['me'], lastProbedAt: now, error: e.message }; } } const mb = normalizeMailbox(address); const opts = { headers: withMailboxHeaders(mb) }; const result = { address: mb.smtpOrUpn, displayName: null, isPrimary: false, capabilities: { read: false, listFolders: false, sendAs: 'unverified' }, source: sources || ['probe'], lastProbedAt: now, error: null }; // Best-effort identity resolve (may 403 without directory scopes — ignore) try { const user = await callGraphAPI( accessToken, 'GET', buildPath(mb, ''), null, { $select: 'displayName,mail,userPrincipalName' }, opts ); // buildPath(mb,'') returns users/upn — GET users/{upn} works result.displayName = user.displayName || null; if (user.mail) result.address = user.mail; else if (user.userPrincipalName) result.address = user.userPrincipalName; } catch (e) { // fall through — mailbox may still be readable without user profile console.error(`[list-mailboxes] profile resolve ${mb.smtpOrUpn}: ${e.message}`); } try { await callGraphAPI( accessToken, 'GET', buildPath(mb, 'mailFolders/inbox'), null, { $select: 'id,displayName,totalItemCount' }, opts ); result.capabilities.read = true; } catch (e) { result.capabilities.read = false; result.error = e.message; } try { await callGraphAPI( accessToken, 'GET', buildPath(mb, 'mailFolders'), null, { $top: 1, $select: 'id,displayName' }, opts ); result.capabilities.listFolders = true; } catch { result.capabilities.listFolders = false; } // sendAs cannot be proven without a send or draft; leave unverified when readable if (!result.capabilities.read) { result.capabilities.sendAs = 'denied_or_unknown'; } return result; } function formatResults(rows, primaryLabel) { if (!rows.length) { return 'No mailboxes to report.'; } const lines = [`Mailboxes for ${primaryLabel}`, '']; rows.forEach((r, i) => { const tags = []; if (r.isPrimary) tags.push('PRIMARY'); if (r.capabilities.read) tags.push('read=yes'); else tags.push('read=no'); if (r.capabilities.listFolders) tags.push('folders=yes'); else tags.push('folders=no'); tags.push(`sendAs=${r.capabilities.sendAs}`); lines.push(`${i + 1}. ${r.address}${r.displayName ? ` (${r.displayName})` : ''}`); lines.push(` ${tags.join(' ')}`); if (r.source && r.source.length) { lines.push(` source: ${r.source.join('+')}`); } if (r.error && !r.capabilities.read) { lines.push(` error: ${r.error}`); } if (!r.isPrimary && r.capabilities.read) { lines.push(' note: Send As not probed; use send-email({ mailbox }) to verify'); } lines.push(''); }); lines.push('Notes:'); lines.push('- Graph cannot list all mailboxes you have rights to. Seed with OUTLOOK_SHARED_MAILBOXES or candidates[].'); lines.push('- Message IDs are mailbox-scoped: pass the same mailbox on list/search/read/thread/send.'); lines.push('- Exchange Full Access / Send As are separate from Graph app scopes (Mail.*.Shared).'); return lines.join('\n'); } async function handleListMailboxes(args = {}) { if (!config.ENABLE_SHARED_MAILBOXES) { return { content: [{ type: 'text', text: 'Shared mailbox support is disabled (OUTLOOK_ENABLE_SHARED_MAILBOXES=false). Only the primary mailbox is available via tools without a mailbox parameter.' }] }; } const includeCached = args.includeCached !== false && args.includeCached !== 'false'; const includeConfigured = args.includeConfigured !== false && args.includeConfigured !== 'false'; const refresh = args.refresh === true || args.refresh === 'true'; let candidates = []; // Always probe primary candidates.push({ address: 'me', sources: ['me'] }); if (includeConfigured) { for (const addr of parseSharedMailboxEnv()) { candidates.push({ address: addr, sources: ['env'] }); } } if (includeCached) { const cache = loadMailboxCache(); for (const m of cache.mailboxes) { if (m.address) candidates.push({ address: m.address, sources: ['cache'] }); } } if (Array.isArray(args.candidates)) { for (const c of args.candidates) { if (c && String(c).trim()) { candidates.push({ address: String(c).trim(), sources: ['candidates'] }); } } } else if (typeof args.candidates === 'string' && args.candidates.trim()) { for (const c of args.candidates.split(/[,;\s]+/).filter(Boolean)) { candidates.push({ address: c, sources: ['candidates'] }); } } // Dedupe by lowercased key; merge sources const map = new Map(); for (const c of candidates) { const key = c.address === 'me' || c.address === 'primary' ? 'me' : String(c.address).trim().toLowerCase(); if (!map.has(key)) { map.set(key, { address: c.address === 'me' ? 'me' : c.address, sources: new Set(c.sources) }); } else { for (const s of c.sources) map.get(key).sources.add(s); } } // If not refreshing, still probe — cache is only for candidate discovery, not skipping probes. // (refresh reserved for future TTL skip; always probe for accurate rights.) void refresh; try { const accessToken = await ensureAuthenticated(); const list = Array.from(map.values()).map(v => ({ address: v.address, sources: Array.from(v.sources) })); const probed = await mapPool( list, config.MAILBOX_PROBE_CONCURRENCY || 4, (item) => probeOne(accessToken, item.address, item.sources) ); // Persist non-primary results const toCache = probed.filter(p => !p.isPrimary); if (toCache.length) { saveMailboxCache(toCache.map(p => ({ address: p.address, displayName: p.displayName, capabilities: p.capabilities, source: p.source, lastProbedAt: p.lastProbedAt, error: p.error }))); } // Sort: primary first, then readable, then alpha probed.sort((a, b) => { if (a.isPrimary && !b.isPrimary) return -1; if (!a.isPrimary && b.isPrimary) return 1; if (a.capabilities.read !== b.capabilities.read) return a.capabilities.read ? -1 : 1; return String(a.address).localeCompare(String(b.address)); }); const primary = probed.find(p => p.isPrimary); const primaryLabel = primary ? primary.address : 'signed-in user'; return { content: [{ type: 'text', text: formatResults(probed, primaryLabel) }] }; } 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 mailboxes: ${error.message}` }] }; } } const listMailboxesTool = { name: 'list-mailboxes', description: 'List the primary mailbox plus candidate shared/delegated mailboxes and probe read/folder access. Candidates come from OUTLOOK_SHARED_MAILBOXES, a local cache, and optional candidates[]. Graph cannot enumerate all mailboxes you have rights to — seed the list. sendAs is unverified until you successfully send.', inputSchema: { type: 'object', properties: { candidates: { anyOf: [ { type: 'array', items: { type: 'string' } }, { type: 'string' } ], description: 'Extra mailbox UPNs/SMTPs to probe (array or comma-separated string)' }, includeCached: { anyOf: [{ type: 'boolean' }, { type: 'string' }], description: 'Include addresses from the local mailbox cache (default true)' }, includeConfigured: { anyOf: [{ type: 'boolean' }, { type: 'string' }], description: 'Include OUTLOOK_SHARED_MAILBOXES env seeds (default true)' }, refresh: { anyOf: [{ type: 'boolean' }, { type: 'string' }], description: 'Reserved; probes always run in v1.1.0' } }, required: [] }, handler: handleListMailboxes }; module.exports = { listMailboxesTool, handleListMailboxes, probeOne };