outlook-mcp/mailbox/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

359 lines
11 KiB
JavaScript

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