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.
359 lines
11 KiB
JavaScript
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
|
|
};
|