diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index b153aa4fc0..ab4f7f24df 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,8 +1,7 @@ import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db' import { and, desc, eq, sql } from 'drizzle-orm' -import type { NextRequest } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' -import { generateApiKey } from '@/lib/api-key/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' @@ -64,26 +63,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt)) .limit(1) - if (userApiKey.length === 0) { - try { - const newApiKeyVal = generateApiKey() - const keyName = 'Default API Key' - await db.insert(apiKey).values({ - id: uuidv4(), - userId: workflowData.userId, - workspaceId: null, - name: keyName, - key: newApiKeyVal, - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - keyInfo = { name: keyName, type: 'personal' } - logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`) - } catch (keyError) { - logger.error(`[${requestId}] Failed to generate API key:`, keyError) - } - } else { + if (userApiKey.length > 0) { keyInfo = { name: userApiKey[0].name, type: userApiKey[0].type as 'personal' | 'workspace' } } } @@ -190,34 +170,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const deployedAt = new Date() logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`) - const userApiKey = await db - .select({ - key: apiKey.key, - }) - .from(apiKey) - .where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) - .orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt)) - .limit(1) - - if (userApiKey.length === 0) { - try { - const newApiKey = generateApiKey() - await db.insert(apiKey).values({ - id: uuidv4(), - userId, - workspaceId: null, - name: 'Default API Key', - key: newApiKey, - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - logger.info(`[${requestId}] Generated new API key for user: ${userId}`) - } catch (keyError) { - logger.error(`[${requestId}] Failed to generate API key:`, keyError) - } - } - let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null let matchedKey: { id: string @@ -226,13 +178,50 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ type: 'personal' | 'workspace' } | null = null - if (providedApiKey) { - let isValidKey = false + // Use provided API key, or fall back to existing pinned API key for redeployment + const apiKeyToUse = providedApiKey || workflowData!.pinnedApiKeyId + + if (!apiKeyToUse) { + return NextResponse.json( + { error: 'API key is required. Please create or select an API key before deploying.' }, + { status: 400 } + ) + } + + let isValidKey = false + + const currentUserId = session?.user?.id + + if (currentUserId) { + const [personalKey] = await db + .select({ + id: apiKey.id, + key: apiKey.key, + name: apiKey.name, + expiresAt: apiKey.expiresAt, + }) + .from(apiKey) + .where( + and( + eq(apiKey.id, apiKeyToUse), + eq(apiKey.userId, currentUserId), + eq(apiKey.type, 'personal') + ) + ) + .limit(1) - const currentUserId = session?.user?.id + if (personalKey) { + if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) { + matchedKey = { ...personalKey, type: 'personal' } + isValidKey = true + keyInfo = { name: personalKey.name, type: 'personal' } + } + } + } - if (currentUserId) { - const [personalKey] = await db + if (!isValidKey) { + if (workflowData!.workspaceId) { + const [workspaceKey] = await db .select({ id: apiKey.id, key: apiKey.key, @@ -242,55 +231,26 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ .from(apiKey) .where( and( - eq(apiKey.id, providedApiKey), - eq(apiKey.userId, currentUserId), - eq(apiKey.type, 'personal') + eq(apiKey.id, apiKeyToUse), + eq(apiKey.workspaceId, workflowData!.workspaceId), + eq(apiKey.type, 'workspace') ) ) .limit(1) - if (personalKey) { - if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) { - matchedKey = { ...personalKey, type: 'personal' } + if (workspaceKey) { + if (!workspaceKey.expiresAt || workspaceKey.expiresAt >= new Date()) { + matchedKey = { ...workspaceKey, type: 'workspace' } isValidKey = true - keyInfo = { name: personalKey.name, type: 'personal' } - } - } - } - - if (!isValidKey) { - if (workflowData!.workspaceId) { - const [workspaceKey] = await db - .select({ - id: apiKey.id, - key: apiKey.key, - name: apiKey.name, - expiresAt: apiKey.expiresAt, - }) - .from(apiKey) - .where( - and( - eq(apiKey.id, providedApiKey), - eq(apiKey.workspaceId, workflowData!.workspaceId), - eq(apiKey.type, 'workspace') - ) - ) - .limit(1) - - if (workspaceKey) { - if (!workspaceKey.expiresAt || workspaceKey.expiresAt >= new Date()) { - matchedKey = { ...workspaceKey, type: 'workspace' } - isValidKey = true - keyInfo = { name: workspaceKey.name, type: 'workspace' } - } + keyInfo = { name: workspaceKey.name, type: 'workspace' } } } } + } - if (!isValidKey) { - logger.warn(`[${requestId}] Invalid API key ID provided for workflow deployment: ${id}`) - return createErrorResponse('Invalid API key provided', 400) - } + if (!isValidKey) { + logger.warn(`[${requestId}] Invalid API key ID provided for workflow deployment: ${id}`) + return createErrorResponse('Invalid API key provided', 400) } // Attribution: this route is UI-only; require session user as actor diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index 9b02f9416c..e13d4df43e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -361,6 +361,8 @@ export function DeployModal({ await fetchVersions() } catch (error: unknown) { logger.error('Error deploying workflow:', { error }) + const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' + setApiDeployError(errorMessage) } finally { setIsSubmitting(false) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 9166be1e39..09ef321eef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -432,6 +432,17 @@ export const WorkflowBlock = memo( } }, [id, blockHeight, blockWidth, updateBlockLayoutMetrics, updateNodeInternals, debounce]) + // Subscribe to this block's subblock values to track changes for conditional rendering + const blockSubBlockValues = useSubBlockStore( + useCallback( + (state) => { + if (!activeWorkflowId) return {} + return state.workflowValues[activeWorkflowId]?.[id] || {} + }, + [activeWorkflowId, id] + ) + ) + // Memoized SubBlock layout management - only recalculate when dependencies change const subBlockRows = useMemo(() => { const rows: SubBlockConfig[][] = [] @@ -450,8 +461,7 @@ export const WorkflowBlock = memo( } else { // In normal mode, use merged state const blocks = useWorkflowStore.getState().blocks - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId || undefined - const mergedState = mergeSubblockState(blocks, activeWorkflowId, id)[id] + const mergedState = mergeSubblockState(blocks, activeWorkflowId || undefined, id)[id] stateToUse = mergedState?.subBlocks || {} } @@ -552,6 +562,8 @@ export const WorkflowBlock = memo( data.subBlockValues, currentWorkflow.isDiffMode, currentBlock, + blockSubBlockValues, + activeWorkflowId, ]) // Name editing handlers diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index b60b6740b3..16105e123e 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils' +import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webhooks/utils' import { loadDeployedWorkflowState, @@ -20,9 +21,66 @@ import { Executor } from '@/executor' import type { ExecutionResult } from '@/executor/types' import { Serializer } from '@/serializer' import { mergeSubblockState } from '@/stores/workflows/server-utils' +import { getTrigger } from '@/triggers' const logger = createLogger('TriggerWebhookExecution') +/** + * Process trigger outputs based on their schema definitions + * Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage + */ +async function processTriggerFileOutputs( + input: any, + triggerOutputs: Record, + context: { + workspaceId: string + workflowId: string + executionId: string + requestId: string + }, + path = '' +): Promise { + if (!input || typeof input !== 'object') { + return input + } + + const processed: any = Array.isArray(input) ? [] : {} + + for (const [key, value] of Object.entries(input)) { + const currentPath = path ? `${path}.${key}` : key + const outputDef = triggerOutputs[key] + const val: any = value + + // If this field is marked as file or file[], process it + if (outputDef?.type === 'file[]' && Array.isArray(val)) { + try { + processed[key] = await WebhookAttachmentProcessor.processAttachments(val as any, context) + } catch (error) { + processed[key] = [] + } + } else if (outputDef?.type === 'file' && val) { + try { + const [processedFile] = await WebhookAttachmentProcessor.processAttachments( + [val as any], + context + ) + processed[key] = processedFile + } catch (error) { + logger.error(`[${context.requestId}] Error processing ${currentPath}:`, error) + processed[key] = val + } + } else if (outputDef && typeof outputDef === 'object' && !outputDef.type) { + // Nested object in schema - recurse with the nested schema + processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath) + } else { + // Not a file output - keep as is + processed[key] = val + } + } + + return processed +} + export type WebhookExecutionPayload = { webhookId: string workflowId: string @@ -250,6 +308,7 @@ async function executeWebhookJobInternal( totalDurationMs: totalDuration || 0, finalOutput: executionResult.output || {}, traceSpans: traceSpans as any, + workflowInput: airtableInput, }) return { @@ -312,6 +371,32 @@ async function executeWebhookJobInternal( } } + // Process trigger file outputs based on schema + if (input && payload.blockId && blocks[payload.blockId]) { + try { + const triggerBlock = blocks[payload.blockId] + const triggerId = triggerBlock?.subBlocks?.triggerId?.value + + if (triggerId && typeof triggerId === 'string') { + const triggerConfig = getTrigger(triggerId) + + if (triggerConfig?.outputs) { + logger.debug(`[${requestId}] Processing trigger ${triggerId} file outputs`) + const processedInput = await processTriggerFileOutputs(input, triggerConfig.outputs, { + workspaceId: workspaceId || '', + workflowId: payload.workflowId, + executionId, + requestId, + }) + Object.assign(input, processedInput) + } + } + } catch (error) { + logger.error(`[${requestId}] Error processing trigger file outputs:`, error) + // Continue without processing attachments rather than failing execution + } + } + // Create executor and execute const executor = new Executor({ workflow: serializedWorkflow, @@ -367,6 +452,7 @@ async function executeWebhookJobInternal( totalDurationMs: totalDuration || 0, finalOutput: executionResult.output || {}, traceSpans: traceSpans as any, + workflowInput: input, }) return { diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 27dbb7e628..31cf6c8ca2 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -173,6 +173,13 @@ export const OutlookBlock: BlockConfig = { placeholder: 'Number of emails to retrieve (default: 1, max: 10)', condition: { field: 'operation', value: 'read_outlook' }, }, + { + id: 'includeAttachments', + title: 'Include Attachments', + type: 'switch', + layout: 'full', + condition: { field: 'operation', value: 'read_outlook' }, + }, // TRIGGER MODE: Trigger configuration (only shown when trigger mode is active) { id: 'triggerConfig', @@ -231,6 +238,7 @@ export const OutlookBlock: BlockConfig = { folder: { type: 'string', description: 'Email folder' }, manualFolder: { type: 'string', description: 'Manual folder name' }, maxResults: { type: 'number', description: 'Maximum emails' }, + includeAttachments: { type: 'boolean', description: 'Include email attachments' }, }, outputs: { // Common outputs @@ -255,6 +263,10 @@ export const OutlookBlock: BlockConfig = { receivedDateTime: { type: 'string', description: 'Email received timestamp' }, sentDateTime: { type: 'string', description: 'Email sent timestamp' }, hasAttachments: { type: 'boolean', description: 'Whether email has attachments' }, + attachments: { + type: 'json', + description: 'Email attachments (if includeAttachments is enabled)', + }, isRead: { type: 'boolean', description: 'Whether email is read' }, importance: { type: 'string', description: 'Email importance level' }, // Trigger outputs diff --git a/apps/sim/lib/webhooks/attachment-processor.ts b/apps/sim/lib/webhooks/attachment-processor.ts new file mode 100644 index 0000000000..7eb2841b6d --- /dev/null +++ b/apps/sim/lib/webhooks/attachment-processor.ts @@ -0,0 +1,107 @@ +import { createLogger } from '@/lib/logs/console/logger' +import { uploadExecutionFile } from '@/lib/workflows/execution-file-storage' +import type { UserFile } from '@/executor/types' + +const logger = createLogger('WebhookAttachmentProcessor') + +export interface WebhookAttachment { + name: string + data: Buffer + contentType: string + size: number +} + +/** + * Processes webhook/trigger attachments and converts them to UserFile objects. + * This enables triggers to include file attachments that get automatically stored + * in the execution filesystem and made available as UserFile objects for workflow use. + */ +export class WebhookAttachmentProcessor { + /** + * Process attachments and upload them to execution storage + */ + static async processAttachments( + attachments: WebhookAttachment[], + executionContext: { + workspaceId: string + workflowId: string + executionId: string + requestId: string + } + ): Promise { + if (!attachments || attachments.length === 0) { + return [] + } + + logger.info( + `[${executionContext.requestId}] Processing ${attachments.length} attachments for execution ${executionContext.executionId}` + ) + + const processedFiles: UserFile[] = [] + + for (const attachment of attachments) { + try { + const userFile = await WebhookAttachmentProcessor.processAttachment( + attachment, + executionContext + ) + processedFiles.push(userFile) + } catch (error) { + logger.error( + `[${executionContext.requestId}] Error processing attachment '${attachment.name}':`, + error + ) + // Continue with other attachments rather than failing the entire request + } + } + + logger.info( + `[${executionContext.requestId}] Successfully processed ${processedFiles.length}/${attachments.length} attachments` + ) + + return processedFiles + } + + /** + * Process a single attachment and upload to execution storage + */ + private static async processAttachment( + attachment: WebhookAttachment, + executionContext: { + workspaceId: string + workflowId: string + executionId: string + requestId: string + } + ): Promise { + const data = attachment.data as any + + if (!data || typeof data !== 'object' || data.type !== 'Buffer' || !Array.isArray(data.data)) { + throw new Error(`Attachment '${attachment.name}' data must be a serialized Buffer`) + } + + const buffer = Buffer.from(data.data) + + if (buffer.length === 0) { + throw new Error(`Attachment '${attachment.name}' has zero bytes`) + } + + logger.info( + `[${executionContext.requestId}] Uploading attachment '${attachment.name}' (${attachment.size} bytes, ${attachment.contentType})` + ) + + // Upload to execution storage + const userFile = await uploadExecutionFile( + executionContext, + buffer, + attachment.name, + attachment.contentType + ) + + logger.info( + `[${executionContext.requestId}] Successfully stored attachment '${attachment.name}' with key: ${userFile.key}` + ) + + return userFile + } +} diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts index f4790c9847..c32d3fcc84 100644 --- a/apps/sim/lib/webhooks/gmail-polling-service.ts +++ b/apps/sim/lib/webhooks/gmail-polling-service.ts @@ -6,6 +6,8 @@ import { pollingIdempotency } from '@/lib/idempotency/service' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import type { GmailAttachment } from '@/tools/gmail/types' +import { downloadAttachments, extractAttachmentInfo } from '@/tools/gmail/utils' const logger = createLogger('GmailPollingService') @@ -17,6 +19,7 @@ interface GmailWebhookConfig { lastCheckedTimestamp?: string historyId?: string pollingInterval?: number + includeAttachments?: boolean includeRawEmail?: boolean } @@ -42,7 +45,7 @@ export interface SimplifiedEmail { bodyHtml: string labels: string[] hasAttachments: boolean - attachments: Array<{ filename: string; mimeType: string; size: number }> + attachments: GmailAttachment[] } export interface GmailWebhookPayload { @@ -530,32 +533,25 @@ async function processEmails( date = new Date(Number.parseInt(email.internalDate)).toISOString() } - // Extract attachment information if present - const attachments: Array<{ filename: string; mimeType: string; size: number }> = [] + // Download attachments if requested (raw Buffers - will be uploaded during execution) + let attachments: GmailAttachment[] = [] + const hasAttachments = email.payload + ? extractAttachmentInfo(email.payload).length > 0 + : false - const findAttachments = (part: any) => { - if (!part) return - - if (part.filename && part.filename.length > 0) { - attachments.push({ - filename: part.filename, - mimeType: part.mimeType || 'application/octet-stream', - size: part.body?.size || 0, - }) - } - - // Look for attachments in nested parts - if (part.parts && Array.isArray(part.parts)) { - for (const subPart of part.parts) { - findAttachments(subPart) - } + if (config.includeAttachments && hasAttachments && email.payload) { + try { + const attachmentInfo = extractAttachmentInfo(email.payload) + attachments = await downloadAttachments(email.id, attachmentInfo, accessToken) + } catch (error) { + logger.error( + `[${requestId}] Error downloading attachments for email ${email.id}:`, + error + ) + // Continue without attachments rather than failing the entire request } } - if (email.payload) { - findAttachments(email.payload) - } - // Create simplified email object const simplifiedEmail: SimplifiedEmail = { id: email.id, @@ -568,8 +564,8 @@ async function processEmails( bodyText: textContent, bodyHtml: htmlContent, labels: email.labelIds || [], - hasAttachments: attachments.length > 0, - attachments: attachments, + hasAttachments, + attachments, } // Prepare webhook payload with simplified email and optionally raw email diff --git a/apps/sim/lib/webhooks/outlook-polling-service.ts b/apps/sim/lib/webhooks/outlook-polling-service.ts index 89a2d89470..76158c8b54 100644 --- a/apps/sim/lib/webhooks/outlook-polling-service.ts +++ b/apps/sim/lib/webhooks/outlook-polling-service.ts @@ -18,6 +18,7 @@ interface OutlookWebhookConfig { maxEmailsPerPoll?: number lastCheckedTimestamp?: string pollingInterval?: number + includeAttachments?: boolean includeRawEmail?: boolean } @@ -55,6 +56,13 @@ interface OutlookEmail { parentFolderId: string } +export interface OutlookAttachment { + name: string + data: Buffer + contentType: string + size: number +} + export interface SimplifiedOutlookEmail { id: string conversationId: string @@ -66,6 +74,7 @@ export interface SimplifiedOutlookEmail { bodyText: string bodyHtml: string hasAttachments: boolean + attachments: OutlookAttachment[] isRead: boolean folderId: string // Thread support fields @@ -343,6 +352,18 @@ async function processOutlookEmails( 'outlook', `${webhookData.id}:${email.id}`, async () => { + let attachments: OutlookAttachment[] = [] + if (config.includeAttachments && email.hasAttachments) { + try { + attachments = await downloadOutlookAttachments(accessToken, email.id, requestId) + } catch (error) { + logger.error( + `[${requestId}] Error downloading attachments for email ${email.id}:`, + error + ) + } + } + // Convert to simplified format const simplifiedEmail: SimplifiedOutlookEmail = { id: email.id, @@ -365,6 +386,7 @@ async function processOutlookEmails( })(), bodyHtml: email.body?.content || '', hasAttachments: email.hasAttachments, + attachments, isRead: email.isRead, folderId: email.parentFolderId, // Thread support fields @@ -435,6 +457,68 @@ async function processOutlookEmails( return processedCount } +async function downloadOutlookAttachments( + accessToken: string, + messageId: string, + requestId: string +): Promise { + const attachments: OutlookAttachment[] = [] + + try { + // Fetch attachments list from Microsoft Graph API + const response = await fetch( + `https://graph.microsoft.com/v1.0/me/messages/${messageId}/attachments`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + logger.error(`[${requestId}] Failed to fetch attachments for message ${messageId}`) + return attachments + } + + const data = await response.json() + const attachmentsList = data.value || [] + + for (const attachment of attachmentsList) { + try { + // Microsoft Graph returns attachment data directly in the list response for file attachments + if (attachment['@odata.type'] === '#microsoft.graph.fileAttachment') { + const contentBytes = attachment.contentBytes + if (contentBytes) { + // contentBytes is base64 encoded + const buffer = Buffer.from(contentBytes, 'base64') + attachments.push({ + name: attachment.name, + data: buffer, + contentType: attachment.contentType, + size: attachment.size, + }) + } + } + } catch (error) { + logger.error( + `[${requestId}] Error processing attachment ${attachment.id} for message ${messageId}:`, + error + ) + // Continue with other attachments + } + } + + logger.info( + `[${requestId}] Downloaded ${attachments.length} attachments for message ${messageId}` + ) + } catch (error) { + logger.error(`[${requestId}] Error downloading attachments for message ${messageId}:`, error) + } + + return attachments +} + async function markOutlookEmailAsRead(accessToken: string, messageId: string) { try { const response = await fetch(`https://graph.microsoft.com/v1.0/me/messages/${messageId}`, { diff --git a/apps/sim/tools/gmail/read.ts b/apps/sim/tools/gmail/read.ts index 9b8f52addc..907a8c2186 100644 --- a/apps/sim/tools/gmail/read.ts +++ b/apps/sim/tools/gmail/read.ts @@ -1,4 +1,4 @@ -import type { GmailReadParams, GmailToolResponse } from '@/tools/gmail/types' +import type { GmailAttachment, GmailReadParams, GmailToolResponse } from '@/tools/gmail/types' import { createMessagesSummary, GMAIL_API_BASE, @@ -195,15 +195,32 @@ export const gmailReadTool: ToolConfig = { const messages = await Promise.all(messagePromises) - // Process all messages and create a summary - const processedMessages = messages.map(processMessageForSummary) + // Create summary from processed messages first + const summaryMessages = messages.map(processMessageForSummary) + + const allAttachments: GmailAttachment[] = [] + if (params?.includeAttachments) { + for (const msg of messages) { + try { + const processedResult = await processMessage(msg, params) + if ( + processedResult.output.attachments && + processedResult.output.attachments.length > 0 + ) { + allAttachments.push(...processedResult.output.attachments) + } + } catch (error: any) { + console.error(`Error processing message ${msg.id} for attachments:`, error) + } + } + } return { success: true, output: { - content: createMessagesSummary(processedMessages), + content: createMessagesSummary(summaryMessages), metadata: { - results: processedMessages.map((msg) => ({ + results: summaryMessages.map((msg) => ({ id: msg.id, threadId: msg.threadId, subject: msg.subject, @@ -211,6 +228,7 @@ export const gmailReadTool: ToolConfig = { date: msg.date, })), }, + attachments: allAttachments, }, } } catch (error: any) { @@ -224,6 +242,7 @@ export const gmailReadTool: ToolConfig = { threadId: msg.threadId, })), }, + attachments: [], }, } } diff --git a/apps/sim/tools/outlook/read.ts b/apps/sim/tools/outlook/read.ts index d7637669a7..07ac14476a 100644 --- a/apps/sim/tools/outlook/read.ts +++ b/apps/sim/tools/outlook/read.ts @@ -1,5 +1,6 @@ import type { CleanedOutlookMessage, + OutlookAttachment, OutlookMessage, OutlookMessagesResponse, OutlookReadParams, @@ -7,6 +8,61 @@ import type { } from '@/tools/outlook/types' import type { ToolConfig } from '@/tools/types' +/** + * Download attachments from an Outlook message + */ +async function downloadAttachments( + messageId: string, + accessToken: string +): Promise { + const attachments: OutlookAttachment[] = [] + + try { + // Fetch attachments list from Microsoft Graph API + const response = await fetch( + `https://graph.microsoft.com/v1.0/me/messages/${messageId}/attachments`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + return attachments + } + + const data = await response.json() + const attachmentsList = data.value || [] + + for (const attachment of attachmentsList) { + try { + // Microsoft Graph returns attachment data directly in the list response for file attachments + if (attachment['@odata.type'] === '#microsoft.graph.fileAttachment') { + const contentBytes = attachment.contentBytes + if (contentBytes) { + // contentBytes is base64 encoded + const buffer = Buffer.from(contentBytes, 'base64') + attachments.push({ + name: attachment.name, + data: buffer, + contentType: attachment.contentType, + size: attachment.size, + }) + } + } + } catch (error) { + // Continue with other attachments + } + } + } catch (error) { + // Return empty array on error + } + + return attachments +} + export const outlookReadTool: ToolConfig = { id: 'outlook_read', name: 'Outlook Read', @@ -37,6 +93,12 @@ export const outlookReadTool: ToolConfig visibility: 'user-only', description: 'Maximum number of emails to retrieve (default: 1, max: 10)', }, + includeAttachments: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Download and include email attachments', + }, }, request: { @@ -67,7 +129,7 @@ export const outlookReadTool: ToolConfig }, }, - transformResponse: async (response: Response) => { + transformResponse: async (response: Response, params?: OutlookReadParams) => { const data: OutlookMessagesResponse = await response.json() // Microsoft Graph API returns messages in a 'value' array @@ -84,44 +146,68 @@ export const outlookReadTool: ToolConfig } // Clean up the message data to only include essential fields - const cleanedMessages: CleanedOutlookMessage[] = messages.map((message: OutlookMessage) => ({ - id: message.id, - subject: message.subject, - bodyPreview: message.bodyPreview, - body: { - contentType: message.body?.contentType, - content: message.body?.content, - }, - sender: { - name: message.sender?.emailAddress?.name, - address: message.sender?.emailAddress?.address, - }, - from: { - name: message.from?.emailAddress?.name, - address: message.from?.emailAddress?.address, - }, - toRecipients: - message.toRecipients?.map((recipient) => ({ - name: recipient.emailAddress?.name, - address: recipient.emailAddress?.address, - })) || [], - ccRecipients: - message.ccRecipients?.map((recipient) => ({ - name: recipient.emailAddress?.name, - address: recipient.emailAddress?.address, - })) || [], - receivedDateTime: message.receivedDateTime, - sentDateTime: message.sentDateTime, - hasAttachments: message.hasAttachments, - isRead: message.isRead, - importance: message.importance, - })) + const cleanedMessages: CleanedOutlookMessage[] = await Promise.all( + messages.map(async (message: OutlookMessage) => { + // Download attachments if requested + let attachments: OutlookAttachment[] | undefined + if (params?.includeAttachments && message.hasAttachments && params?.accessToken) { + try { + attachments = await downloadAttachments(message.id, params.accessToken) + } catch (error) { + // Continue without attachments rather than failing the entire request + } + } + + return { + id: message.id, + subject: message.subject, + bodyPreview: message.bodyPreview, + body: { + contentType: message.body?.contentType, + content: message.body?.content, + }, + sender: { + name: message.sender?.emailAddress?.name, + address: message.sender?.emailAddress?.address, + }, + from: { + name: message.from?.emailAddress?.name, + address: message.from?.emailAddress?.address, + }, + toRecipients: + message.toRecipients?.map((recipient) => ({ + name: recipient.emailAddress?.name, + address: recipient.emailAddress?.address, + })) || [], + ccRecipients: + message.ccRecipients?.map((recipient) => ({ + name: recipient.emailAddress?.name, + address: recipient.emailAddress?.address, + })) || [], + receivedDateTime: message.receivedDateTime, + sentDateTime: message.sentDateTime, + hasAttachments: message.hasAttachments, + attachments: attachments || [], + isRead: message.isRead, + importance: message.importance, + } + }) + ) + + // Flatten all attachments from all emails to top level for FileToolProcessor + const allAttachments: OutlookAttachment[] = [] + for (const email of cleanedMessages) { + if (email.attachments && email.attachments.length > 0) { + allAttachments.push(...email.attachments) + } + } return { success: true, output: { message: `Successfully read ${cleanedMessages.length} email(s).`, results: cleanedMessages, + attachments: allAttachments, }, } }, @@ -129,5 +215,6 @@ export const outlookReadTool: ToolConfig outputs: { message: { type: 'string', description: 'Success or status message' }, results: { type: 'array', description: 'Array of email message objects' }, + attachments: { type: 'file[]', description: 'All email attachments flattened from all emails' }, }, } diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index a7d80d43e7..a891a5b7ad 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -24,6 +24,7 @@ export interface OutlookReadParams { folder: string maxResults: number messageId?: string + includeAttachments?: boolean } export interface OutlookReadResponse extends ToolResponse { @@ -103,6 +104,14 @@ export interface OutlookMessagesResponse { value: OutlookMessage[] } +// Outlook attachment interface (for tool responses) +export interface OutlookAttachment { + name: string + data: Buffer + contentType: string + size: number +} + // Cleaned message interface for our response export interface CleanedOutlookMessage { id: string @@ -131,6 +140,7 @@ export interface CleanedOutlookMessage { receivedDateTime?: string sentDateTime?: string hasAttachments?: boolean + attachments?: OutlookAttachment[] | any[] isRead?: boolean importance?: string } diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 4301b6f9a2..b201da9190 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -38,6 +38,13 @@ export const gmailPollingTrigger: TriggerConfig = { description: 'Automatically mark emails as read after processing', required: false, }, + includeAttachments: { + type: 'boolean', + label: 'Include Attachments', + defaultValue: false, + description: 'Download and include email attachments in the trigger payload', + required: false, + }, includeRawEmail: { type: 'boolean', label: 'Include Raw Email Data', @@ -94,8 +101,8 @@ export const gmailPollingTrigger: TriggerConfig = { description: 'Whether email has attachments', }, attachments: { - type: 'json', - description: 'Array of attachment information', + type: 'file[]', + description: 'Array of email attachments as files (if includeAttachments is enabled)', }, }, timestamp: { @@ -129,13 +136,7 @@ export const gmailPollingTrigger: TriggerConfig = { '

Hello,

Please find attached the monthly report for April 2025.

Best regards,
Sender

', labels: ['INBOX', 'IMPORTANT'], hasAttachments: true, - attachments: [ - { - filename: 'report-april-2025.pdf', - mimeType: 'application/pdf', - size: 2048576, - }, - ], + attachments: [], }, timestamp: '2025-05-10T10:15:30.123Z', }, diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index 28d2813b0a..e9181b8bf0 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -38,6 +38,13 @@ export const outlookPollingTrigger: TriggerConfig = { description: 'Automatically mark emails as read after processing', required: false, }, + includeAttachments: { + type: 'boolean', + label: 'Include Attachments', + defaultValue: false, + description: 'Download and include email attachments in the trigger payload', + required: false, + }, includeRawEmail: { type: 'boolean', label: 'Include Raw Email Data', @@ -89,6 +96,10 @@ export const outlookPollingTrigger: TriggerConfig = { type: 'boolean', description: 'Whether email has attachments', }, + attachments: { + type: 'file[]', + description: 'Array of email attachments as files (if includeAttachments is enabled)', + }, isRead: { type: 'boolean', description: 'Whether email is read', @@ -136,6 +147,7 @@ export const outlookPollingTrigger: TriggerConfig = { bodyHtml: '

Hi Team,

Please find attached the Q1 2025 business review document. We need to discuss the results in our next meeting.

Best regards,
Manager

', hasAttachments: true, + attachments: [], isRead: false, folderId: 'AQMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjAC4AAAJzE3bU', messageId: 'AAMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgBGAAAAAACE3bU',