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