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.
202 lines
6.8 KiB
JavaScript
202 lines
6.8 KiB
JavaScript
/**
|
|
* Microsoft Graph API helper functions
|
|
*/
|
|
const https = require('https');
|
|
const config = require('../config');
|
|
const mockData = require('./mock-data');
|
|
|
|
/**
|
|
* Sleep helper for backoff delays.
|
|
* @param {number} ms - Milliseconds to wait
|
|
* @returns {Promise<void>}
|
|
*/
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Compute the next retry delay.
|
|
* Honors a Retry-After header (seconds) when present, otherwise uses
|
|
* exponential backoff with full jitter. Clamps to MAX_RETRY_DELAY_MS.
|
|
* @param {number} attempt - 0-based attempt index
|
|
* @param {number|undefined} retryAfterSeconds - Retry-After header value
|
|
* @returns {number} - Delay in milliseconds
|
|
*/
|
|
function getRetryDelay(attempt, retryAfterSeconds) {
|
|
if (retryAfterSeconds && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
return Math.min(retryAfterSeconds * 1000, config.MAX_RETRY_DELAY_MS);
|
|
}
|
|
const exponential = config.BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
const jitter = Math.random() * exponential;
|
|
return Math.min(exponential + jitter, config.MAX_RETRY_DELAY_MS);
|
|
}
|
|
|
|
/**
|
|
* Makes a request to the Microsoft Graph API
|
|
* @param {string} accessToken - The access token for authentication
|
|
* @param {string} method - HTTP method (GET, POST, etc.)
|
|
* @param {string} path - API endpoint path
|
|
* @param {object} data - Data to send for POST/PUT requests
|
|
* @param {object} queryParams - Query parameters
|
|
* @param {object} [options] - Extra options
|
|
* @param {object} [options.headers] - Additional HTTP headers (e.g. X-AnchorMailbox)
|
|
* @returns {Promise<object>} - The API response
|
|
*/
|
|
async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}, options = {}) {
|
|
// For test tokens, we'll simulate the API call
|
|
if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
|
|
console.error(`TEST MODE: Simulating ${method} ${path} API call`);
|
|
return mockData.simulateGraphAPIResponse(method, path, data, queryParams);
|
|
}
|
|
|
|
try {
|
|
console.error(`Making real API call: ${method} ${path}`);
|
|
|
|
// Encode path segments properly (do NOT pre-encode UPNs before calling this)
|
|
const encodedPath = path.split('/')
|
|
.map(segment => encodeURIComponent(segment))
|
|
.join('/');
|
|
|
|
// Build query string from parameters with special handling for OData filters
|
|
let queryString = '';
|
|
if (queryParams && Object.keys(queryParams).length > 0) {
|
|
// Copy so we do not mutate the caller's object when deleting $filter
|
|
const qp = { ...queryParams };
|
|
// Handle $filter parameter specially to ensure proper URI encoding
|
|
const filter = qp.$filter;
|
|
if (filter) {
|
|
delete qp.$filter; // Remove from regular params
|
|
}
|
|
|
|
// Build query string with proper encoding for regular params
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(qp)) {
|
|
params.append(key, value);
|
|
}
|
|
|
|
queryString = params.toString();
|
|
|
|
// Add filter parameter separately with proper encoding
|
|
if (filter) {
|
|
if (queryString) {
|
|
queryString += `&$filter=${encodeURIComponent(filter)}`;
|
|
} else {
|
|
queryString = `$filter=${encodeURIComponent(filter)}`;
|
|
}
|
|
}
|
|
|
|
if (queryString) {
|
|
queryString = '?' + queryString;
|
|
}
|
|
|
|
console.error(`Query string: ${queryString}`);
|
|
}
|
|
|
|
const url = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
|
|
console.error(`Full URL: ${url}`);
|
|
|
|
const maxAttempts = Math.max(1, config.MAX_RETRIES + 1);
|
|
const extraHeaders = (options && options.headers) || {};
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
const result = await makeSingleRequest(url, optionsForRequest(method, accessToken, extraHeaders), data);
|
|
|
|
if (result.success) {
|
|
return result.body;
|
|
}
|
|
|
|
// Do not retry authentication failures.
|
|
if (result.status === 401) {
|
|
throw new Error('UNAUTHORIZED');
|
|
}
|
|
|
|
// Retry throttling / transient server errors.
|
|
const isRetryable = result.status === 429 || result.status === 503;
|
|
if (!isRetryable || attempt === maxAttempts - 1) {
|
|
throw new Error(`API call failed with status ${result.status}: ${result.body}`);
|
|
}
|
|
|
|
const retryAfterSeconds = result.retryAfter ? parseInt(result.retryAfter, 10) : undefined;
|
|
const delayMs = getRetryDelay(attempt, retryAfterSeconds);
|
|
console.error(`Retry ${attempt + 1}/${config.MAX_RETRIES} after ${delayMs}ms (status ${result.status})`);
|
|
await sleep(delayMs);
|
|
}
|
|
|
|
// Should not reach here; final failure is thrown inside the loop.
|
|
throw new Error(`API call failed after ${maxAttempts} attempts`);
|
|
} catch (error) {
|
|
console.error('Error calling Graph API:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the https request options object.
|
|
* @param {string} method
|
|
* @param {string} accessToken
|
|
* @param {object} [extraHeaders]
|
|
*/
|
|
function optionsForRequest(method, accessToken, extraHeaders = {}) {
|
|
return {
|
|
method: method,
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
...extraHeaders
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute one HTTPS request and return a normalized result.
|
|
* @returns {Promise<{success: boolean, status: number, body: any, retryAfter?: string}>}
|
|
*/
|
|
function makeSingleRequest(url, options, data) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = https.request(url, options, (res) => {
|
|
let responseData = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
responseData += chunk;
|
|
});
|
|
|
|
res.on('end', () => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
const trimmed = responseData.trim();
|
|
if (!trimmed) {
|
|
return resolve({ success: true, status: res.statusCode, body: {} });
|
|
}
|
|
try {
|
|
const jsonResponse = JSON.parse(trimmed);
|
|
resolve({ success: true, status: res.statusCode, body: jsonResponse });
|
|
} catch (error) {
|
|
reject(new Error(`Error parsing API response: ${error.message}`));
|
|
}
|
|
} else {
|
|
resolve({
|
|
success: false,
|
|
status: res.statusCode,
|
|
body: responseData,
|
|
retryAfter: res.headers['retry-after']
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', (error) => {
|
|
reject(new Error(`Network error during API call: ${error.message}`));
|
|
});
|
|
|
|
if (data && (options.method === 'POST' || options.method === 'PATCH' || options.method === 'PUT')) {
|
|
req.write(JSON.stringify(data));
|
|
}
|
|
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
callGraphAPI,
|
|
getRetryDelay,
|
|
sleep
|
|
};
|