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

211 lines
6.5 KiB
JavaScript

/**
* Shared / delegated mailbox routing helpers.
*
* Graph has no API that lists "mailboxes this user can access".
* We rewrite me/... -> users/{upn}/... when a mailbox is supplied,
* and support candidate+probe discovery with a local cache + env seeds.
*/
const fs = require('fs');
const path = require('path');
const config = require('../config');
/**
* Normalize a mailbox argument into a routing context.
* @param {string|null|undefined} mailbox - UPN or SMTP, or empty for primary
* @returns {{ kind: 'me'|'user', key: string, smtpOrUpn: string|null, graphRoot: string }}
*/
function normalizeMailbox(mailbox) {
if (mailbox == null) {
return { kind: 'me', key: 'me', smtpOrUpn: null, graphRoot: 'me' };
}
const trimmed = String(mailbox).trim();
if (!trimmed || trimmed.toLowerCase() === 'me' || trimmed.toLowerCase() === 'primary') {
return { kind: 'me', key: 'me', smtpOrUpn: null, graphRoot: 'me' };
}
// Strip mailto: and angle brackets if pasted from UI
let addr = trimmed.replace(/^mailto:/i, '').replace(/^<|>$/g, '').trim();
// callGraphAPI encodes each path segment; pass raw UPN as one segment (no pre-encode)
return {
kind: 'user',
key: addr.toLowerCase(),
smtpOrUpn: addr,
graphRoot: `users/${addr}`
};
}
/**
* Build a Graph path under the mailbox root.
* relativePath should not start with "/".
* Examples: buildPath(ctx, 'messages'), buildPath(ctx, 'mailFolders/inbox')
*/
function buildPath(mailboxCtx, relativePath) {
const ctx = typeof mailboxCtx === 'string' || mailboxCtx == null
? normalizeMailbox(mailboxCtx)
: mailboxCtx;
const rel = String(relativePath || '').replace(/^\/+/, '');
if (!rel) return ctx.graphRoot;
return `${ctx.graphRoot}/${rel}`;
}
/**
* Extra HTTPS headers for shared-mailbox routing.
*/
function withMailboxHeaders(mailboxCtx) {
const ctx = typeof mailboxCtx === 'string' || mailboxCtx == null
? normalizeMailbox(mailboxCtx)
: mailboxCtx;
if (ctx.kind !== 'user' || !ctx.smtpOrUpn) return {};
return { 'X-AnchorMailbox': ctx.smtpOrUpn };
}
/**
* Parse OUTLOOK_SHARED_MAILBOXES env (comma/semicolon/whitespace separated).
* @returns {string[]}
*/
function parseSharedMailboxEnv(envValue = process.env.OUTLOOK_SHARED_MAILBOXES) {
if (!envValue || !String(envValue).trim()) return [];
return String(envValue)
.split(/[,;\s]+/)
.map(s => s.trim())
.filter(Boolean);
}
function defaultMailboxCachePath() {
if (process.env.OUTLOOK_MAILBOX_CACHE_PATH) {
return process.env.OUTLOOK_MAILBOX_CACHE_PATH;
}
const tokenPath = config.AUTH_CONFIG.tokenStorePath;
return `${tokenPath}.mailboxes.json`;
}
/**
* @returns {{ version: number, updatedAt: string|null, mailboxes: Array<object> }}
*/
function loadMailboxCache(cachePath = defaultMailboxCachePath()) {
try {
if (!fs.existsSync(cachePath)) {
return { version: 1, updatedAt: null, mailboxes: [] };
}
const raw = fs.readFileSync(cachePath, 'utf8');
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.mailboxes)) {
return { version: 1, updatedAt: null, mailboxes: [] };
}
return {
version: data.version || 1,
updatedAt: data.updatedAt || null,
mailboxes: data.mailboxes
};
} catch (e) {
console.error('[mailbox] cache read error:', e.message);
return { version: 1, updatedAt: null, mailboxes: [] };
}
}
/**
* Upsert mailbox probe results into the cache file.
* @param {Array<object>} entries
* @param {string} [cachePath]
*/
function saveMailboxCache(entries, cachePath = defaultMailboxCachePath()) {
const existing = loadMailboxCache(cachePath);
const byKey = new Map();
for (const m of existing.mailboxes) {
const k = (m.address || m.key || '').toLowerCase();
if (k) byKey.set(k, m);
}
for (const entry of entries) {
const k = (entry.address || entry.key || '').toLowerCase();
if (!k || k === 'me') continue; // do not cache primary as shared
const prev = byKey.get(k) || {};
const sources = new Set([...(prev.source || []), ...(entry.source || [])]);
byKey.set(k, {
...prev,
...entry,
address: entry.address || prev.address || k,
source: Array.from(sources),
lastProbedAt: entry.lastProbedAt || new Date().toISOString()
});
}
const payload = {
version: 1,
updatedAt: new Date().toISOString(),
mailboxes: Array.from(byKey.values())
};
try {
const dir = path.dirname(cachePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2));
} catch (e) {
console.error('[mailbox] cache write error:', e.message);
}
return payload;
}
/**
* Encode path the same way callGraphAPI does (for unit tests).
*/
function encodeGraphPath(pathStr) {
return String(pathStr)
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
}
/**
* Decode JWT payload (middle segment) without verifying signature.
* Used only to surface scp claims in check-auth-status.
* @param {string} token
* @returns {object|null}
*/
function decodeJwtPayload(token) {
try {
if (!token || typeof token !== 'string') return null;
const parts = token.split('.');
if (parts.length < 2) return null;
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
} catch {
return null;
}
}
/**
* Shared scopes expected when OUTLOOK_ENABLE_SHARED_MAILBOXES is on.
*/
function expectedSharedScopes() {
return ['Mail.Read.Shared', 'Mail.ReadWrite.Shared', 'Mail.Send.Shared'];
}
/**
* @param {string} token
* @returns {{ scp: string, present: string[], missing: string[] }}
*/
function analyzeTokenScopes(token) {
const payload = decodeJwtPayload(token) || {};
const scp = typeof payload.scp === 'string' ? payload.scp : '';
const roles = Array.isArray(payload.roles) ? payload.roles : [];
const granted = new Set([
...scp.split(/\s+/).filter(Boolean),
...roles
]);
const expected = expectedSharedScopes();
const present = expected.filter(s => granted.has(s));
const missing = expected.filter(s => !granted.has(s));
return { scp, present, missing, allGranted: Array.from(granted).sort() };
}
module.exports = {
normalizeMailbox,
buildPath,
withMailboxHeaders,
parseSharedMailboxEnv,
defaultMailboxCachePath,
loadMailboxCache,
saveMailboxCache,
encodeGraphPath,
decodeJwtPayload,
expectedSharedScopes,
analyzeTokenScopes
};