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.
211 lines
6.5 KiB
JavaScript
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
|
|
};
|