mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-25 17:15:37 +02:00
574 lines
17 KiB
JavaScript
574 lines
17 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 = 20 * 1024 * 1024; // 20MB limit for all attachments combined
|
|
|
|
/**
|
|
* Validates and prepares a file attachment for email.
|
|
* Note: Does not check total size limit - caller should track cumulative size.
|
|
* @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: {
|
|
name: fileName,
|
|
contentType,
|
|
data: 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.
|
|
* Writes the base64 data to a temp file, parses it, then cleans up.
|
|
* @param {Object} attachment - The attachment object with name, contentType, size, data (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,
|
|
`gmail-attachment-${Date.now()}-${safeFilename}`
|
|
);
|
|
|
|
try {
|
|
const buffer = Buffer.from(attachment.data, "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 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collect attachments from messages and optionally parse them with user approval.
|
|
* Specific files may not show (images) and are pre-stripped by the app script.
|
|
* If two attachments have the same name, only the first one will be kept (handling fwd emails)
|
|
* @param {Object} context - The handler context (this) from the aibitat function
|
|
* @param {Array} messages - Array of message objects (single message should be wrapped in array)
|
|
* @returns {Promise<{allAttachments: Array, parsedContent: string}>}
|
|
*/
|
|
async function handleAttachments(context, messages) {
|
|
const allAttachments = [];
|
|
const uniqueAttachments = new Set();
|
|
messages.forEach((msg, msgIndex) => {
|
|
if (msg.attachments?.length > 0) {
|
|
msg.attachments.forEach((att) => {
|
|
if (uniqueAttachments.has(att.name)) return;
|
|
uniqueAttachments.add(att.name);
|
|
allAttachments.push({
|
|
...att,
|
|
messageIndex: msgIndex + 1,
|
|
messageId: msg.id,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
let parsedContent = "";
|
|
const citations = [];
|
|
if (allAttachments.length > 0 && context.super.requestToolApproval) {
|
|
const attachmentNames = allAttachments.map((a) => a.name).join(", ");
|
|
|
|
const approval = await context.super.requestToolApproval({
|
|
skillName: context.name,
|
|
payload: { attachments: attachmentNames },
|
|
description: `Parse attachments (${attachmentNames}) to extract text content?`,
|
|
});
|
|
|
|
if (approval.approved) {
|
|
context.super.introspect(
|
|
`${context.caller}: Parsing ${allAttachments.length} attachment(s)...`
|
|
);
|
|
|
|
const parsedResults = [];
|
|
for (const attachment of allAttachments) {
|
|
if (!attachment.data) continue;
|
|
context.super.introspect(
|
|
`${context.caller}: Parsing "${attachment.name}"...`
|
|
);
|
|
const parseResult = await parseAttachment(attachment);
|
|
if (!parseResult.success) continue;
|
|
|
|
citations.push({
|
|
id: `gmail-attachment-${attachment.messageId}-${attachment.name}`,
|
|
title: attachment.name,
|
|
text: parseResult.content,
|
|
chunkSource: "gmail-attachment://" + attachment.name,
|
|
score: null,
|
|
});
|
|
parsedResults.push({
|
|
name: attachment.name,
|
|
messageIndex: attachment.messageIndex,
|
|
...parseResult,
|
|
});
|
|
}
|
|
|
|
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`
|
|
);
|
|
} else {
|
|
context.super.introspect(
|
|
`${context.caller}: User declined to parse attachments`
|
|
);
|
|
}
|
|
}
|
|
|
|
citations.forEach((c) => context.super.addCitation?.(c));
|
|
return { allAttachments, parsedContent };
|
|
}
|
|
|
|
/**
|
|
* Gmail Bridge Library
|
|
* Handles communication with the AnythingLLM Gmail Google Apps Script deployment.
|
|
*/
|
|
class GmailBridge {
|
|
#deploymentId = null;
|
|
#apiKey = null;
|
|
#isInitialized = false;
|
|
|
|
#log(text, ...args) {
|
|
console.log(`\x1b[36m[GmailBridge]\x1b[0m ${text}`, ...args);
|
|
}
|
|
|
|
/**
|
|
* Resets the bridge state, forcing re-initialization on next use.
|
|
* Call this when configuration changes (e.g., deployment ID updated).
|
|
*/
|
|
reset() {
|
|
this.#deploymentId = null;
|
|
this.#apiKey = null;
|
|
this.#isInitialized = false;
|
|
}
|
|
|
|
/**
|
|
* Gets the current Gmail agent configuration from system settings.
|
|
* @returns {Promise<{deploymentId?: string, apiKey?: string}>}
|
|
*/
|
|
static async getConfig() {
|
|
const configJson = await SystemSettings.getValueOrFallback(
|
|
{ label: "gmail_agent_config" },
|
|
"{}"
|
|
);
|
|
return safeJsonParse(configJson, {});
|
|
}
|
|
|
|
/**
|
|
* Updates the Gmail 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({
|
|
gmail_agent_config: JSON.stringify(updates),
|
|
});
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes the Gmail 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:
|
|
"Gmail integration is not available in multi-user mode for security reasons.",
|
|
};
|
|
}
|
|
|
|
const config = await GmailBridge.getConfig();
|
|
if (!config.deploymentId || !config.apiKey) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
"Gmail integration is not configured. Please set the Deployment ID and API Key in the agent settings.",
|
|
};
|
|
}
|
|
|
|
this.#deploymentId = config.deploymentId;
|
|
this.#apiKey = config.apiKey;
|
|
this.#isInitialized = true;
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the Gmail bridge is properly configured and available.
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async isAvailable() {
|
|
const result = await this.initialize();
|
|
return result.success;
|
|
}
|
|
|
|
/**
|
|
* Checks if Gmail 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 GmailBridge.getConfig();
|
|
return !!(config.deploymentId && config.apiKey);
|
|
}
|
|
|
|
get maskedDeploymentId() {
|
|
if (!this.#deploymentId) return "(not configured)";
|
|
return (
|
|
this.#deploymentId.substring(0, 5) +
|
|
"..." +
|
|
this.#deploymentId.substring(this.#deploymentId.length - 5)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gets the base URL for the Gmail Google Apps Script deployment.
|
|
* @returns {string}
|
|
*/
|
|
#getBaseUrl() {
|
|
this.#log(`Getting base URL for deployment ID ${this.maskedDeploymentId}`);
|
|
return `https://script.google.com/macros/s/${this.#deploymentId}/exec`;
|
|
}
|
|
|
|
/**
|
|
* Makes a request to the Gmail Google Apps Script API.
|
|
* @param {string} action - The action to perform
|
|
* @param {object} params - Additional parameters for the action
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async request(action, params = {}) {
|
|
const initResult = await this.initialize();
|
|
if (!initResult.success) {
|
|
return { success: false, error: initResult.error };
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this.#getBaseUrl(), {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-AnythingLLM-UA": "AnythingLLM-Gmail-Agent/1.0",
|
|
},
|
|
body: JSON.stringify({
|
|
key: this.#apiKey,
|
|
action,
|
|
...params,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
success: false,
|
|
error: `Gmail API request failed with status ${response.status}`,
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.status === "error") {
|
|
return { success: false, error: result.error };
|
|
}
|
|
|
|
return { success: true, data: result.data, quota: result.quota };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: `Gmail API request failed: ${error.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search emails using Gmail query syntax.
|
|
* @param {string} query - Gmail search query
|
|
* @param {number} limit - Maximum results to return
|
|
* @param {number} start - Starting offset
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async search(query = "is:inbox", limit = 10, start = 0) {
|
|
return this.request("search", { query, limit, start });
|
|
}
|
|
|
|
/**
|
|
* Read a full thread by ID.
|
|
* @param {string} threadId - The thread ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async readThread(threadId) {
|
|
return this.request("read_thread", { threadId });
|
|
}
|
|
|
|
/**
|
|
* 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, htmlBody, etc.)
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async createDraft(to, subject, body, options = {}) {
|
|
return this.request("create_draft", { to, subject, body, ...options });
|
|
}
|
|
|
|
/**
|
|
* Create a draft reply to an existing thread.
|
|
* @param {string} threadId - The thread 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(threadId, body, replyAll = false, options = {}) {
|
|
return this.request("create_draft_reply", {
|
|
threadId,
|
|
body,
|
|
replyAll,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update an existing draft.
|
|
* @param {string} draftId - The draft ID
|
|
* @param {string} to - Recipient email
|
|
* @param {string} subject - Email subject
|
|
* @param {string} body - Email body
|
|
* @param {object} options - Additional options
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async updateDraft(draftId, to, subject, body, options = {}) {
|
|
return this.request("update_draft", {
|
|
draftId,
|
|
to,
|
|
subject,
|
|
body,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.request("get_draft", { draftId });
|
|
}
|
|
|
|
/**
|
|
* List all drafts.
|
|
* @param {number} limit - Maximum drafts to return
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async listDrafts(limit = 25) {
|
|
return this.request("list_drafts", { limit });
|
|
}
|
|
|
|
/**
|
|
* Delete a draft.
|
|
* @param {string} draftId - The draft ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async deleteDraft(draftId) {
|
|
return this.request("delete_draft", { draftId });
|
|
}
|
|
|
|
/**
|
|
* Send an existing draft.
|
|
* @param {string} draftId - The draft ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async sendDraft(draftId) {
|
|
return this.request("send_draft", { draftId });
|
|
}
|
|
|
|
/**
|
|
* 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, data?: object, error?: string}>}
|
|
*/
|
|
async sendEmail(to, subject, body, options = {}) {
|
|
return this.request("send_email", { to, subject, body, ...options });
|
|
}
|
|
|
|
/**
|
|
* Reply to a thread immediately.
|
|
* @param {string} threadId - The thread ID
|
|
* @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 replyToThread(threadId, body, replyAll = false, options = {}) {
|
|
return this.request("reply_to_thread", {
|
|
threadId,
|
|
body,
|
|
replyAll,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mark a thread as read.
|
|
* @param {string} threadId - The thread ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async markRead(threadId) {
|
|
return this.request("mark_read", { threadId });
|
|
}
|
|
|
|
/**
|
|
* Mark a thread as unread.
|
|
* @param {string} threadId - The thread ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async markUnread(threadId) {
|
|
return this.request("mark_unread", { threadId });
|
|
}
|
|
|
|
/**
|
|
* Move a thread to trash.
|
|
* @param {string} threadId - The thread ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async moveToTrash(threadId) {
|
|
return this.request("move_to_trash", { threadId });
|
|
}
|
|
|
|
/**
|
|
* Archive a thread.
|
|
* @param {string} threadId - The thread ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async moveToArchive(threadId) {
|
|
return this.request("move_to_archive", { threadId });
|
|
}
|
|
|
|
/**
|
|
* Move a thread to inbox.
|
|
* @param {string} threadId - The thread ID
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async moveToInbox(threadId) {
|
|
return this.request("move_to_inbox", { threadId });
|
|
}
|
|
|
|
/**
|
|
* Get mailbox statistics.
|
|
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
|
*/
|
|
async getMailboxStats() {
|
|
return this.request("get_mailbox_stats");
|
|
}
|
|
}
|
|
|
|
module.exports = new GmailBridge();
|
|
module.exports.GmailBridge = GmailBridge;
|
|
module.exports.prepareAttachment = prepareAttachment;
|
|
module.exports.parseAttachment = parseAttachment;
|
|
module.exports.handleAttachments = handleAttachments;
|
|
module.exports.MAX_TOTAL_ATTACHMENT_SIZE = MAX_TOTAL_ATTACHMENT_SIZE;
|