Files
anything-llm/server/utils/agents/aibitat/plugins/outlook/lib.js
Timothy Carambat 4172751858 5427 translations (#5429)
* Outlook agent via Entra Application

* translations
2026-04-14 13:38:03 -07:00

1410 lines
43 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const os = require("os");
const mime = require("mime");
const { SystemSettings } = require("../../../../../models/systemSettings");
const { CollectorApi } = require("../../../../collectorApi");
const { humanFileSize } = require("../../../../helpers");
const { safeJsonParse } = require("../../../../http");
const MAX_TOTAL_ATTACHMENT_SIZE = 25 * 1024 * 1024; // 25MB limit for Outlook
/**
* Parses a comma-separated email string into Graph API recipient format.
* @param {string} emailString - Comma-separated email addresses
* @returns {Array<{emailAddress: {address: string}}>}
*/
function parseEmailRecipients(emailString) {
if (!emailString) return [];
return emailString.split(",").map((email) => ({
emailAddress: { address: email.trim() },
}));
}
/**
* Validates organization auth type configuration.
* @param {Object} config - The Outlook configuration object
* @returns {{valid: boolean, error?: string}}
*/
function validateOrganizationAuth(config) {
const authType = config.authType || AUTH_TYPES.common;
if (authType === AUTH_TYPES.organization && !config.tenantId) {
return {
valid: false,
error: "Tenant ID is required for organization-only authentication.",
};
}
return { valid: true };
}
/**
* Maps a Microsoft Graph message object to a simplified format.
* @param {Object} msg - The Graph API message object
* @param {Object} options - Mapping options
* @param {boolean} options.includeBody - Include full body content
* @param {boolean} options.includeAttachments - Include attachment details
* @returns {Object} Simplified message object
*/
function mapGraphMessage(msg, options = {}) {
const base = {
id: msg.id,
conversationId: msg.conversationId,
subject: msg.subject,
from: msg.from?.emailAddress?.address || "Unknown",
fromName: msg.from?.emailAddress?.name || "",
to: msg.toRecipients?.map((r) => r.emailAddress?.address).join(", ") || "",
cc: msg.ccRecipients?.map((r) => r.emailAddress?.address).join(", ") || "",
isRead: msg.isRead,
hasAttachments: msg.hasAttachments,
};
if (msg.receivedDateTime) {
base.receivedDateTime = msg.receivedDateTime;
}
if (msg.bodyPreview !== undefined) {
base.preview = msg.bodyPreview;
}
if (options.includeBody && msg.body) {
base.date = msg.receivedDateTime;
base.body = msg.body?.content || "";
base.bodyType = msg.body?.contentType || "text";
}
if (options.includeAttachments && msg.attachments) {
base.attachments = (msg.attachments || []).map((att) => ({
id: att.id,
name: att.name,
contentType: att.contentType,
size: att.size,
contentBytes: att.contentBytes,
}));
}
return base;
}
/**
* Formats an array of messages into a human-readable summary string.
* @param {Array} messages - Array of simplified message objects
* @returns {string} Formatted summary
*/
function formatMessageSummary(messages) {
return messages
.map(
(m, i) =>
`${i + 1}. [${m.isRead ? "READ" : "UNREAD"}] "${m.subject}" from ${m.fromName || m.from} (${new Date(m.receivedDateTime).toLocaleString()})${m.hasAttachments ? " 📎" : ""}\n ID: ${m.id}\n Conversation ID: ${m.conversationId}`
)
.join("\n\n");
}
/**
* Handles errors in Outlook skill handlers with consistent logging and messaging.
* @param {Object} context - The handler context (this) from the aibitat function
* @param {string} skillName - The name of the skill (e.g., "outlook-get-inbox")
* @param {Error} error - The error object
* @returns {string} User-friendly error message
*/
function handleSkillError(context, skillName, error) {
context.super.handlerProps.log(`${skillName} error: ${error.message}`);
context.super.introspect(`Error: ${error.message}`);
return `Error in ${skillName}: ${error.message}`;
}
/**
* Normalizes a token expiry value to a number.
* @param {number|string|null|undefined} expiry - The token expiry value
* @returns {number|null} The normalized expiry as a number, or null if invalid
*/
function normalizeTokenExpiry(expiry) {
if (expiry === null || expiry === undefined) return null;
return typeof expiry === "number" ? expiry : parseInt(expiry, 10);
}
/**
* Prepares and validates attachments for email sending/drafting.
* @param {Object} context - The handler context with introspect and caller
* @param {Array<string>} attachmentPaths - Array of absolute file paths
* @param {Object} options - Options for attachment handling
* @param {boolean} options.requireApprovalPerFile - Request approval for each file
* @param {string} options.recipientInfo - Recipient info for approval message
* @returns {Promise<{success: boolean, attachments?: Array, summaries?: Array, totalSize?: number, error?: string}>}
*/
async function prepareAttachmentsWithValidation(
context,
attachmentPaths,
options = {}
) {
if (!Array.isArray(attachmentPaths) || attachmentPaths.length === 0) {
return { success: true, attachments: [], summaries: [], totalSize: 0 };
}
const preparedAttachments = [];
const attachmentSummaries = [];
let totalAttachmentSize = 0;
context.super.introspect(
`${context.caller}: Validating ${attachmentPaths.length} attachment(s)...`
);
for (const filePath of attachmentPaths) {
const result = prepareAttachment(filePath);
if (!result.success) {
context.super.introspect(
`${context.caller}: Attachment validation failed - ${result.error}`
);
return { success: false, error: result.error };
}
totalAttachmentSize += result.fileInfo.size;
if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) {
const totalFormatted = humanFileSize(totalAttachmentSize, true);
context.super.introspect(
`${context.caller}: Total attachment size (${totalFormatted}) exceeds 25MB limit`
);
return {
success: false,
error: `Total attachment size (${totalFormatted}) exceeds the 25MB limit.`,
};
}
if (options.requireApprovalPerFile && context.super.requestToolApproval) {
const approval = await context.super.requestToolApproval({
skillName: context.name,
payload: {
fileName: result.fileInfo.name,
fileSize: result.fileInfo.sizeFormatted,
filePath: result.fileInfo.path,
},
description:
`Attach file "${result.fileInfo.name}" (${result.fileInfo.sizeFormatted}) to email? ` +
`This file will be sent to ${options.recipientInfo || "recipients"}.`,
});
if (!approval.approved) {
context.super.introspect(
`${context.caller}: User rejected attaching "${result.fileInfo.name}"`
);
return {
success: false,
error: `Attachment rejected by user: ${result.fileInfo.name}. ${approval.message || ""}`,
};
}
}
preparedAttachments.push(result.attachment);
attachmentSummaries.push(
`${result.fileInfo.name} (${result.fileInfo.sizeFormatted})`
);
context.super.introspect(
`${context.caller}: Prepared attachment "${result.fileInfo.name}"`
);
}
return {
success: true,
attachments: preparedAttachments,
summaries: attachmentSummaries,
totalSize: totalAttachmentSize,
};
}
// ============================================================================
// Attachment Functions
// ============================================================================
/**
* Validates and prepares a file attachment for email.
* @param {string} filePath - Absolute path to the file
* @returns {{success: boolean, attachment?: object, error?: string, fileInfo?: object}}
*/
function prepareAttachment(filePath) {
if (process.env.ANYTHING_LLM_RUNTIME === "docker") {
return {
success: false,
error: "File attachments are not supported in Docker environments.",
};
}
if (!path.isAbsolute(filePath)) {
return { success: false, error: `Path must be absolute: ${filePath}` };
}
if (!fs.existsSync(filePath)) {
return { success: false, error: `File does not exist: ${filePath}` };
}
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
return { success: false, error: `Path is not a file: ${filePath}` };
}
if (stats.size === 0) {
return { success: false, error: `File is empty: ${filePath}` };
}
try {
const fileBuffer = fs.readFileSync(filePath);
const base64Data = fileBuffer.toString("base64");
const fileName = path.basename(filePath);
const contentType = mime.getType(filePath) || "application/octet-stream";
return {
success: true,
attachment: {
"@odata.type": "#microsoft.graph.fileAttachment",
name: fileName,
contentType,
contentBytes: base64Data,
},
fileInfo: {
path: filePath,
name: fileName,
size: stats.size,
sizeFormatted: humanFileSize(stats.size, true),
contentType,
},
};
} catch (e) {
return { success: false, error: `Failed to read file: ${e.message}` };
}
}
/**
* Parse an attachment using the CollectorApi for secure content extraction.
* @param {Object} attachment - The attachment object with name, contentType, size, contentBytes (base64)
* @returns {Promise<{success: boolean, content: string|null, error: string|null}>}
*/
async function parseAttachment(attachment) {
const tempDir = os.tmpdir();
const safeFilename = attachment.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const tempFilePath = path.join(
tempDir,
`outlook-attachment-${Date.now()}-${safeFilename}`
);
try {
const buffer = Buffer.from(attachment.contentBytes, "base64");
fs.writeFileSync(tempFilePath, buffer);
const collector = new CollectorApi();
const result = await collector.parseDocument(safeFilename, {
absolutePath: tempFilePath,
});
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
if (!result.success) {
return {
success: false,
content: null,
error: result.reason || "Failed to parse attachment",
};
}
const textContent = result.documents
?.map((doc) => doc.pageContent || doc.content || "")
.filter(Boolean)
.join("\n\n");
return {
success: true,
content: textContent || "(No text content extracted)",
error: null,
};
} catch (e) {
if (fs.existsSync(tempFilePath)) {
try {
fs.unlinkSync(tempFilePath);
} catch {}
}
return { success: false, content: null, error: e.message };
}
}
/**
* MIME types that can be parsed by the collector to extract text content.
* These are a subset of ACCEPTED_MIMES from collector/utils/constants.js
* that are suitable for attachment parsing (excludes audio/video and images).
* Images are excluded because they're typically signature images in emails.
*/
const PARSEABLE_ATTACHMENT_MIMES = [
"text/plain",
"text/html",
"text/csv",
"application/json",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"application/vnd.oasis.opendocument.text", // .odt
"application/vnd.oasis.opendocument.presentation", // .odp
"application/pdf",
"application/epub+zip",
];
/**
* Checks if an attachment's MIME type can be parsed for text extraction.
* @param {string} contentType - The MIME type of the attachment
* @returns {boolean}
*/
function isParseableMimeType(contentType) {
if (!contentType) return false;
const baseMime = contentType.split(";")[0].trim().toLowerCase();
return PARSEABLE_ATTACHMENT_MIMES.includes(baseMime);
}
/**
* Collect attachments from messages and optionally parse them with user approval.
* Only attachments with parseable MIME types will be offered for parsing.
* @param {Object} context - The handler context (this) from the aibitat function
* @param {Array} messages - Array of message objects
* @returns {Promise<{allAttachments: Array, parsedContent: string}>}
*/
async function handleAttachments(context, messages) {
const allAttachments = [];
messages.forEach((msg, msgIndex) => {
if (msg.attachments?.length > 0) {
msg.attachments.forEach((att) => {
allAttachments.push({
...att,
messageIndex: msgIndex + 1,
messageId: msg.id,
});
});
}
});
const parseableAttachments = allAttachments.filter((att) =>
isParseableMimeType(att.contentType)
);
let parsedContent = "";
const citations = [];
if (parseableAttachments.length > 0 && context.super.requestToolApproval) {
const attachmentNames = parseableAttachments
.map((a) => `${a.name} (${a.contentType})`)
.join(", ");
const approval = await context.super.requestToolApproval({
skillName: context.name,
payload: { attachments: attachmentNames },
description: `Download and parse ${parseableAttachments.length} attachment(s) to extract text content? (${attachmentNames})`,
});
if (approval.approved) {
context.super.introspect(
`${context.caller}: Downloading and parsing ${parseableAttachments.length} attachment(s)...`
);
const parsedResults = [];
for (const attachment of parseableAttachments) {
if (!attachment.contentBytes) {
context.super.introspect(
`${context.caller}: Skipping "${attachment.name}" - no content available`
);
continue;
}
context.super.introspect(
`${context.caller}: Parsing "${attachment.name}"...`
);
const parseResult = await parseAttachment(attachment);
if (!parseResult.success) {
context.super.introspect(
`${context.caller}: Failed to parse "${attachment.name}": ${parseResult.error}`
);
continue;
}
citations.push({
id: `outlook-attachment-${attachment.messageId}-${attachment.name}`,
title: attachment.name,
text: parseResult.content,
chunkSource: "outlook-attachment://" + attachment.name,
score: null,
});
parsedResults.push({
name: attachment.name,
messageIndex: attachment.messageIndex,
...parseResult,
});
}
if (parsedResults.length > 0) {
parsedContent =
"\n\n--- Parsed Attachment Content ---\n" +
parsedResults
.map(
(r) => `\n[Message ${r.messageIndex}: ${r.name}]\n${r.content}`
)
.join("\n");
}
context.super.introspect(
`${context.caller}: Finished parsing attachments (${parsedResults.length}/${parseableAttachments.length} successful)`
);
} else {
context.super.introspect(
`${context.caller}: User declined to parse attachments`
);
}
}
citations.forEach((c) => context.super.addCitation?.(c));
return { allAttachments, parsedContent };
}
/**
* Microsoft Graph API OAuth2 Configuration
* Uses Authorization Code Flow with PKCE
*/
const MICROSOFT_AUTH_URL = "https://login.microsoftonline.com";
const GRAPH_API_URL = "https://graph.microsoft.com/v1.0";
const SCOPES = [
"offline_access",
"Mail.Read",
"Mail.ReadWrite",
"Mail.Send",
"User.Read",
].join(" ");
/**
* Authentication types for Microsoft OAuth2.
* - "organization": Use tenant ID endpoint (work/school accounts from a specific tenant only)
* - "common": Use /common endpoint (both personal and work/school accounts)
* - "consumers": Use /consumers endpoint (personal Microsoft accounts only)
*/
const AUTH_TYPES = {
organization: "organization",
common: "common",
consumers: "consumers",
};
/**
* Gets the appropriate OAuth2 authority endpoint based on auth type.
* @param {string} authType - The authentication type
* @param {string} tenantId - The tenant ID (used only for "organization" type)
* @returns {string} The authority path segment
*/
function getAuthority(authType, tenantId) {
switch (authType) {
case AUTH_TYPES.consumers:
return "consumers";
case AUTH_TYPES.organization:
return tenantId || "common";
case AUTH_TYPES.common:
default:
return "common";
}
}
/**
* Outlook Bridge Library
* Handles OAuth2 authentication and Microsoft Graph API communication for Outlook mail.
*/
class OutlookBridge {
#accessToken = null;
#isInitialized = false;
#log(text, ...args) {
console.log(`\x1b[35m[OutlookBridge]\x1b[0m ${text}`, ...args);
}
/**
* Decodes a JWT token and logs relevant info.
* @param {string} token - The JWT token to decode
* @param {string} context - Context label for logging (e.g., "NEW token", "Token")
* @returns {Object|null} The decoded payload or null if decoding fails
*/
#decodeAndLogToken(token, context = "Token") {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString());
this.#log(
`${context} for: ${payload.upn || payload.email || payload.unique_name || "unknown"}`
);
return payload;
} catch {
this.#log(`Could not decode ${context.toLowerCase()}`);
return null;
}
}
/**
* Resets the bridge state, forcing re-initialization on next use.
*/
reset() {
this.#accessToken = null;
this.#isInitialized = false;
}
/**
* Gets the current Outlook agent configuration from system settings.
* @returns {Promise<{clientId?: string, tenantId?: string, clientSecret?: string, authType?: string, accessToken?: string, refreshToken?: string, tokenExpiry?: number}>}
*/
static async getConfig() {
const configJson = await SystemSettings.getValueOrFallback(
{ label: "outlook_agent_config" },
"{}"
);
return safeJsonParse(configJson, {});
}
/**
* Updates the Outlook agent configuration in system settings.
* @param {Object} updates - Fields to update
* @returns {Promise<{success: boolean, error?: string}>}
*/
static async updateConfig(updates) {
try {
await SystemSettings.updateSettings({
outlook_agent_config: JSON.stringify(updates),
});
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Generates the OAuth2 authorization URL for the user to authenticate.
* @param {string} redirectUri - The callback URL for OAuth
* @returns {Promise<{success: boolean, url?: string, error?: string}>}
*/
async getAuthUrl(redirectUri) {
const config = await OutlookBridge.getConfig();
if (!config.clientId) {
return {
success: false,
error: "Outlook configuration incomplete. Please set Client ID.",
};
}
const orgAuth = validateOrganizationAuth(config);
if (!orgAuth.valid) {
return { success: false, error: orgAuth.error };
}
const authType = config.authType || AUTH_TYPES.common;
const authority = getAuthority(authType, config.tenantId);
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
response_mode: "query",
scope: SCOPES,
prompt: "consent",
});
const url = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/authorize?${params.toString()}`;
this.#log(`Auth URL using authType: ${authType}, authority: ${authority}`);
return { success: true, url };
}
/**
* Exchanges the authorization code for access and refresh tokens.
* @param {string} code - The authorization code from OAuth callback
* @param {string} redirectUri - The callback URL used in the initial auth request
* @returns {Promise<{success: boolean, error?: string}>}
*/
async exchangeCodeForToken(code, redirectUri) {
const config = await OutlookBridge.getConfig();
if (!config.clientId || !config.clientSecret) {
return {
success: false,
error: "Outlook configuration incomplete.",
};
}
const orgAuth = validateOrganizationAuth(config);
if (!orgAuth.valid) {
return { success: false, error: orgAuth.error };
}
try {
const authType = config.authType || AUTH_TYPES.common;
const authority = getAuthority(authType, config.tenantId);
const tokenUrl = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/token`;
this.#log(
`Token exchange using authType: ${authType}, authority: ${authority}`
);
const params = new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
code,
redirect_uri: redirectUri,
grant_type: "authorization_code",
scope: SCOPES,
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
const data = await response.json();
if (!response.ok) {
this.#log("Token exchange failed:", data);
return {
success: false,
error:
data.error_description || data.error || "Token exchange failed",
};
}
const expiresAt = Date.now() + (data.expires_in - 60) * 1000;
this.#decodeAndLogToken(data.access_token, "NEW token received");
await OutlookBridge.updateConfig({
...config,
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenExpiry: expiresAt,
});
this.#accessToken = data.access_token;
this.#isInitialized = false; // Force re-initialization
this.#log("Successfully obtained tokens");
return { success: true };
} catch (error) {
this.#log("Token exchange error:", error.message);
return { success: false, error: error.message };
}
}
/**
* Refreshes the access token using the refresh token.
* @returns {Promise<{success: boolean, error?: string}>}
*/
async #refreshAccessToken() {
const config = await OutlookBridge.getConfig();
if (!config.clientId || !config.clientSecret || !config.refreshToken) {
return {
success: false,
error: "Cannot refresh token. Missing configuration or refresh token.",
};
}
const orgAuth = validateOrganizationAuth(config);
if (!orgAuth.valid) {
return { success: false, error: orgAuth.error };
}
try {
const authType = config.authType || AUTH_TYPES.common;
const authority = getAuthority(authType, config.tenantId);
const tokenUrl = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/token`;
this.#log(
`Token refresh using authType: ${authType}, authority: ${authority}`
);
const params = new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
refresh_token: config.refreshToken,
grant_type: "refresh_token",
scope: SCOPES,
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
const data = await response.json();
if (!response.ok) {
this.#log("Token refresh failed:", data);
return {
success: false,
error: data.error_description || data.error || "Token refresh failed",
};
}
const expiresAt = Date.now() + (data.expires_in - 60) * 1000;
await OutlookBridge.updateConfig({
...config,
accessToken: data.access_token,
refreshToken: data.refresh_token || config.refreshToken,
tokenExpiry: expiresAt,
});
this.#accessToken = data.access_token;
this.#log("Successfully refreshed access token");
return { success: true };
} catch (error) {
this.#log("Token refresh error:", error.message);
return { success: false, error: error.message };
}
}
/**
* Ensures we have a valid access token, refreshing if necessary.
* @returns {Promise<{success: boolean, error?: string}>}
*/
async #ensureValidToken() {
const config = await OutlookBridge.getConfig();
if (!config.accessToken || !config.tokenExpiry) {
this.#log("No access token or expiry found in config");
return {
success: false,
error: "Outlook is not authenticated. Please complete the OAuth flow.",
};
}
const expiryTime = normalizeTokenExpiry(config.tokenExpiry);
const now = Date.now();
const timeUntilExpiry = expiryTime - now;
this.#log(
`Token check: expires in ${Math.round(timeUntilExpiry / 1000)}s (at ${new Date(expiryTime).toISOString()})`
);
const payload = this.#decodeAndLogToken(config.accessToken, "Token check");
if (payload) {
this.#log(`Token aud: ${payload.aud}`);
this.#log(`Token scp: ${payload.scp}`);
}
if (now >= expiryTime) {
this.#log("Access token expired, refreshing...");
return this.#refreshAccessToken();
}
this.#accessToken = config.accessToken;
return { success: true };
}
/**
* Initializes the Outlook bridge by fetching configuration from system settings.
* @returns {Promise<{success: boolean, error?: string}>}
*/
async initialize() {
if (this.#isInitialized) return { success: true };
try {
const isMultiUser = await SystemSettings.isMultiUserMode();
if (isMultiUser) {
return {
success: false,
error:
"Outlook integration is not available in multi-user mode for security reasons.",
};
}
const config = await OutlookBridge.getConfig();
if (!config.clientId || !config.clientSecret) {
return {
success: false,
error:
"Outlook integration is not configured. Please set Client ID and Client Secret in the agent settings.",
};
}
const orgAuth = validateOrganizationAuth(config);
if (!orgAuth.valid) {
return { success: false, error: orgAuth.error };
}
this.#log(
`Initializing with authType: ${config.authType || AUTH_TYPES.common}`
);
if (!config.accessToken) {
return {
success: false,
error:
"Outlook is not authenticated. Please complete the OAuth authorization flow.",
};
}
const tokenResult = await this.#ensureValidToken();
if (!tokenResult.success) {
return tokenResult;
}
this.#isInitialized = true;
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Checks if the Outlook bridge is properly configured and available.
* @returns {Promise<boolean>}
*/
async isAvailable() {
const result = await this.initialize();
return result.success;
}
/**
* Checks if Outlook tools are available (not in multi-user mode and has configuration).
* @returns {Promise<boolean>}
*/
static async isToolAvailable() {
const isMultiUser = await SystemSettings.isMultiUserMode();
if (isMultiUser) return false;
const config = await OutlookBridge.getConfig();
if (!config.clientId || !config.clientSecret || !config.accessToken) {
return false;
}
const orgAuth = validateOrganizationAuth(config);
return orgAuth.valid;
}
/**
* Makes a request to the Microsoft Graph API.
* @param {string} endpoint - The API endpoint (relative to /v1.0)
* @param {object} options - Fetch options
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async request(endpoint, options = {}) {
const initResult = await this.initialize();
if (!initResult.success) {
this.#log(`Initialize failed: ${initResult.error}`);
return { success: false, error: initResult.error };
}
const tokenResult = await this.#ensureValidToken();
if (!tokenResult.success) {
this.#log(`Token validation failed: ${tokenResult.error}`);
return { success: false, error: tokenResult.error };
}
try {
const url = endpoint.startsWith("http")
? endpoint
: `${GRAPH_API_URL}${endpoint}`;
this.#log(`Making request to: ${url}`);
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${this.#accessToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
let errorData = {};
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { raw: errorText };
}
this.#log(
`API request failed: ${response.status} ${response.statusText}`,
`\n Endpoint: ${endpoint}`,
`\n Headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`,
`\n Error: ${JSON.stringify(errorData, null, 2)}`
);
return {
success: false,
error:
errorData.error?.message ||
`Request failed with status ${response.status}`,
};
}
// Handle responses with no content (204 No Content, 202 Accepted with empty body, etc.)
if (response.status === 204 || response.status === 202) {
return { success: true, data: {} };
}
const text = await response.text();
if (!text || text.trim() === "") {
return { success: true, data: {} };
}
const data = JSON.parse(text);
return { success: true, data };
} catch (error) {
return {
success: false,
error: `Outlook API request failed: ${error.message}`,
};
}
}
/**
* Search emails using OData filter syntax.
* Note: Microsoft Graph API does not support $skip with $search, so pagination
* is only available when no query is provided.
* @param {string} query - Search query (uses Microsoft Search syntax)
* @param {number} limit - Maximum results to return
* @param {number} skip - Number of results to skip for pagination (ignored when query is provided)
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async search(query = "", limit = 10, skip = 0) {
let endpoint;
if (query) {
// $skip is not supported with $search in Microsoft Graph API
// $orderby is also not supported with $search - results are ordered by relevance
endpoint = `/me/messages?$top=${limit}&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId&$search="${encodeURIComponent(query)}"`;
} else {
endpoint = `/me/messages?$top=${limit}&$skip=${skip}&$orderby=receivedDateTime desc&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId`;
}
const result = await this.request(endpoint);
if (!result.success) return result;
const messages = result.data.value || [];
return {
success: true,
data: {
messages: messages.map((msg) => mapGraphMessage(msg)),
resultCount: messages.length,
hasMore: !!result.data["@odata.nextLink"],
},
};
}
/**
* Get inbox messages (only from the Inbox folder, not archived/other folders).
* @param {number} limit - Maximum results to return
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async getInbox(limit = 25) {
const endpoint = `/me/mailFolders/inbox/messages?$top=${limit}&$orderby=receivedDateTime desc&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId`;
const result = await this.request(endpoint);
if (!result.success) return result;
const messages = result.data.value || [];
return {
success: true,
data: {
messages: messages.map((msg) => mapGraphMessage(msg)),
resultCount: messages.length,
hasMore: !!result.data["@odata.nextLink"],
},
};
}
/**
* Read a full conversation thread by conversation ID.
* Note: We avoid combining $filter with $orderby due to Microsoft Graph API
* "InefficientFilter" errors. Instead, we fetch without orderby and sort client-side.
* @param {string} conversationId - The conversation ID
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async readThread(conversationId) {
const endpoint = `/me/messages?$filter=conversationId eq '${conversationId}'&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,attachments&$expand=attachments`;
const result = await this.request(endpoint);
if (!result.success) return result;
let messages = result.data.value || [];
if (messages.length === 0) {
return {
success: false,
error: "No messages found in this conversation.",
};
}
// Sort by receivedDateTime ascending (oldest first) client-side
messages.sort(
(a, b) => new Date(a.receivedDateTime) - new Date(b.receivedDateTime)
);
return {
success: true,
data: {
conversationId,
subject: messages[0]?.subject || "No Subject",
messageCount: messages.length,
messages: messages.map((msg) =>
mapGraphMessage(msg, { includeBody: true, includeAttachments: true })
),
},
};
}
/**
* Read a single message by ID.
* @param {string} messageId - The message ID
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async readMessage(messageId) {
const endpoint = `/me/messages/${messageId}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,conversationId&$expand=attachments`;
const result = await this.request(endpoint);
if (!result.success) return result;
return {
success: true,
data: mapGraphMessage(result.data, {
includeBody: true,
includeAttachments: true,
}),
};
}
/**
* Create a new draft email.
* @param {string} to - Recipient email
* @param {string} subject - Email subject
* @param {string} body - Email body
* @param {object} options - Additional options (cc, bcc, isHtml, attachments)
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async createDraft(to, subject, body, options = {}) {
const message = {
subject,
body: {
contentType: options.isHtml ? "HTML" : "Text",
content: body,
},
toRecipients: parseEmailRecipients(to),
};
if (options.cc) {
message.ccRecipients = parseEmailRecipients(options.cc);
}
if (options.bcc) {
message.bccRecipients = parseEmailRecipients(options.bcc);
}
const result = await this.request("/me/messages", {
method: "POST",
body: JSON.stringify(message),
});
if (!result.success) return result;
const draftId = result.data.id;
if (options.attachments && options.attachments.length > 0) {
for (const attachment of options.attachments) {
const attachResult = await this.request(
`/me/messages/${draftId}/attachments`,
{
method: "POST",
body: JSON.stringify(attachment),
}
);
if (!attachResult.success) {
this.#log(`Failed to add attachment: ${attachResult.error}`);
}
}
}
return {
success: true,
data: {
draftId: result.data.id,
subject: result.data.subject,
to,
webLink: result.data.webLink,
},
};
}
/**
* Create a draft reply to an existing message.
* @param {string} messageId - The message ID to reply to
* @param {string} body - Reply body
* @param {boolean} replyAll - Whether to reply all
* @param {object} options - Additional options
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async createDraftReply(messageId, body, replyAll = false, _options = {}) {
const endpoint = replyAll
? `/me/messages/${messageId}/createReplyAll`
: `/me/messages/${messageId}/createReply`;
const result = await this.request(endpoint, {
method: "POST",
body: JSON.stringify({
comment: body,
}),
});
if (!result.success) return result;
return {
success: true,
data: {
draftId: result.data.id,
subject: result.data.subject,
webLink: result.data.webLink,
},
};
}
/**
* Get a specific draft by ID.
* @param {string} draftId - The draft ID
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async getDraft(draftId) {
return this.readMessage(draftId);
}
/**
* List all drafts.
* @param {number} limit - Maximum drafts to return
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async listDrafts(limit = 25) {
const endpoint = `/me/mailFolders/drafts/messages?$top=${limit}&$orderby=lastModifiedDateTime desc&$select=id,subject,toRecipients,lastModifiedDateTime,bodyPreview`;
const result = await this.request(endpoint);
if (!result.success) return result;
const drafts = result.data.value || [];
return {
success: true,
data: {
drafts: drafts.map((draft) => ({
id: draft.id,
subject: draft.subject,
to:
draft.toRecipients
?.map((r) => r.emailAddress?.address)
.join(", ") || "",
lastModified: draft.lastModifiedDateTime,
preview: draft.bodyPreview,
})),
count: drafts.length,
},
};
}
/**
* Update an existing draft.
* @param {string} draftId - The draft ID
* @param {object} updates - Fields to update
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async updateDraft(draftId, updates) {
const message = {};
if (updates.subject) message.subject = updates.subject;
if (updates.body) {
message.body = {
contentType: updates.isHtml ? "HTML" : "Text",
content: updates.body,
};
}
if (updates.to) {
message.toRecipients = parseEmailRecipients(updates.to);
}
if (updates.cc) {
message.ccRecipients = parseEmailRecipients(updates.cc);
}
const result = await this.request(`/me/messages/${draftId}`, {
method: "PATCH",
body: JSON.stringify(message),
});
if (!result.success) return result;
return {
success: true,
data: {
draftId: result.data.id,
subject: result.data.subject,
},
};
}
/**
* Delete a draft.
* @param {string} draftId - The draft ID
* @returns {Promise<{success: boolean, error?: string}>}
*/
async deleteDraft(draftId) {
return this.request(`/me/messages/${draftId}`, {
method: "DELETE",
});
}
/**
* Send an existing draft.
* @param {string} draftId - The draft ID
* @returns {Promise<{success: boolean, error?: string}>}
*/
async sendDraft(draftId) {
return this.request(`/me/messages/${draftId}/send`, {
method: "POST",
});
}
/**
* Send an email immediately.
* @param {string} to - Recipient email
* @param {string} subject - Email subject
* @param {string} body - Email body
* @param {object} options - Additional options
* @returns {Promise<{success: boolean, error?: string}>}
*/
async sendEmail(to, subject, body, options = {}) {
const message = {
subject,
body: {
contentType: options.isHtml ? "HTML" : "Text",
content: body,
},
toRecipients: parseEmailRecipients(to),
};
if (options.cc) {
message.ccRecipients = parseEmailRecipients(options.cc);
}
if (options.bcc) {
message.bccRecipients = parseEmailRecipients(options.bcc);
}
if (options.attachments && options.attachments.length > 0) {
message.attachments = options.attachments;
}
return this.request("/me/sendMail", {
method: "POST",
body: JSON.stringify({
message,
saveToSentItems: true,
}),
});
}
/**
* Reply to a message immediately.
* @param {string} messageId - The message ID to reply to
* @param {string} body - Reply body
* @param {boolean} replyAll - Whether to reply all
* @returns {Promise<{success: boolean, error?: string}>}
*/
async replyToMessage(messageId, body, replyAll = false) {
const endpoint = replyAll
? `/me/messages/${messageId}/replyAll`
: `/me/messages/${messageId}/reply`;
return this.request(endpoint, {
method: "POST",
body: JSON.stringify({
comment: body,
}),
});
}
/**
* Mark a message as read.
* @param {string} messageId - The message ID
* @returns {Promise<{success: boolean, error?: string}>}
*/
async markRead(messageId) {
return this.request(`/me/messages/${messageId}`, {
method: "PATCH",
body: JSON.stringify({ isRead: true }),
});
}
/**
* Mark a message as unread.
* @param {string} messageId - The message ID
* @returns {Promise<{success: boolean, error?: string}>}
*/
async markUnread(messageId) {
return this.request(`/me/messages/${messageId}`, {
method: "PATCH",
body: JSON.stringify({ isRead: false }),
});
}
/**
* Move a message to trash.
* @param {string} messageId - The message ID
* @returns {Promise<{success: boolean, error?: string}>}
*/
async moveToTrash(messageId) {
return this.request(`/me/messages/${messageId}/move`, {
method: "POST",
body: JSON.stringify({
destinationId: "deleteditems",
}),
});
}
/**
* Get mailbox folder statistics.
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async getMailboxStats() {
const folders = ["inbox", "drafts", "sentitems", "deleteditems"];
const stats = {};
for (const folder of folders) {
const result = await this.request(
`/me/mailFolders/${folder}?$select=displayName,totalItemCount,unreadItemCount`
);
if (result.success) {
stats[folder] = {
name: result.data.displayName,
total: result.data.totalItemCount,
unread: result.data.unreadItemCount,
};
}
}
const profileResult = await this.request("/me?$select=displayName,mail");
return {
success: true,
data: {
email: profileResult.data?.mail || "Unknown",
displayName: profileResult.data?.displayName || "Unknown",
folders: stats,
},
};
}
}
module.exports = new OutlookBridge();
module.exports.OutlookBridge = OutlookBridge;
module.exports.prepareAttachment = prepareAttachment;
module.exports.parseAttachment = parseAttachment;
module.exports.handleAttachments = handleAttachments;
module.exports.isParseableMimeType = isParseableMimeType;
module.exports.PARSEABLE_ATTACHMENT_MIMES = PARSEABLE_ATTACHMENT_MIMES;
module.exports.MAX_TOTAL_ATTACHMENT_SIZE = MAX_TOTAL_ATTACHMENT_SIZE;
module.exports.AUTH_TYPES = AUTH_TYPES;
module.exports.formatMessageSummary = formatMessageSummary;
module.exports.mapGraphMessage = mapGraphMessage;
module.exports.parseEmailRecipients = parseEmailRecipients;
module.exports.validateOrganizationAuth = validateOrganizationAuth;
module.exports.prepareAttachmentsWithValidation =
prepareAttachmentsWithValidation;
module.exports.handleSkillError = handleSkillError;
module.exports.normalizeTokenExpiry = normalizeTokenExpiry;