From ab74b1380205cde85a761bfaa9bf823b51f3e7d6 Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Tue, 26 Aug 2025 21:18:09 -0700 Subject: [PATCH] improvement(forwarding+excel): added forwarding and improve excel read (#1136) * added forwarding for outlook * lint * improved excel sheet read * addressed greptile * fixed bodytext getting truncated * fixed any type * added html func --------- Co-authored-by: Adam Gough --- apps/sim/blocks/blocks/outlook.ts | 31 +++- .../lib/webhooks/outlook-polling-service.ts | 32 +++- apps/sim/package.json | 2 + apps/sim/tools/index.ts | 141 ++++++++++------ apps/sim/tools/microsoft_excel/read.ts | 16 +- apps/sim/tools/outlook/forward.ts | 154 ++++++++++++++++++ apps/sim/tools/outlook/index.ts | 3 +- apps/sim/tools/outlook/read.ts | 4 +- apps/sim/tools/outlook/types.ts | 16 ++ apps/sim/tools/registry.ts | 8 +- apps/sim/triggers/outlook/poller.ts | 2 +- bun.lock | 4 + 12 files changed, 355 insertions(+), 58 deletions(-) create mode 100644 apps/sim/tools/outlook/forward.ts diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 35580fc69..201a2cf59 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -22,6 +22,7 @@ export const OutlookBlock: BlockConfig = { { label: 'Send Email', id: 'send_outlook' }, { label: 'Draft Email', id: 'draft_outlook' }, { label: 'Read Email', id: 'read_outlook' }, + { label: 'Forward Email', id: 'forward_outlook' }, ], value: () => 'send_outlook', }, @@ -51,9 +52,30 @@ export const OutlookBlock: BlockConfig = { type: 'short-input', layout: 'full', placeholder: 'Recipient email address', - condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] }, + condition: { + field: 'operation', + value: ['send_outlook', 'draft_outlook', 'forward_outlook'], + }, required: true, }, + { + id: 'messageId', + title: 'Message ID', + type: 'short-input', + layout: 'full', + placeholder: 'Message ID to forward', + condition: { field: 'operation', value: ['forward_outlook'] }, + required: true, + }, + { + id: 'comment', + title: 'Comment', + type: 'long-input', + layout: 'full', + placeholder: 'Optional comment to include when forwarding', + condition: { field: 'operation', value: ['forward_outlook'] }, + required: false, + }, { id: 'subject', title: 'Subject', @@ -157,7 +179,7 @@ export const OutlookBlock: BlockConfig = { }, ], tools: { - access: ['outlook_send', 'outlook_draft', 'outlook_read'], + access: ['outlook_send', 'outlook_draft', 'outlook_read', 'outlook_forward'], config: { tool: (params) => { switch (params.operation) { @@ -167,6 +189,8 @@ export const OutlookBlock: BlockConfig = { return 'outlook_read' case 'draft_outlook': return 'outlook_draft' + case 'forward_outlook': + return 'outlook_forward' default: throw new Error(`Invalid Outlook operation: ${params.operation}`) } @@ -197,6 +221,9 @@ export const OutlookBlock: BlockConfig = { to: { type: 'string', description: 'Recipient email address' }, subject: { type: 'string', description: 'Email subject' }, body: { type: 'string', description: 'Email content' }, + // Forward operation inputs + messageId: { type: 'string', description: 'Message ID to forward' }, + comment: { type: 'string', description: 'Optional comment for forwarding' }, // Read operation inputs folder: { type: 'string', description: 'Email folder' }, manualFolder: { type: 'string', description: 'Manual folder name' }, diff --git a/apps/sim/lib/webhooks/outlook-polling-service.ts b/apps/sim/lib/webhooks/outlook-polling-service.ts index 655831059..9d6356add 100644 --- a/apps/sim/lib/webhooks/outlook-polling-service.ts +++ b/apps/sim/lib/webhooks/outlook-polling-service.ts @@ -1,4 +1,5 @@ import { and, eq } from 'drizzle-orm' +import { htmlToText } from 'html-to-text' import { nanoid } from 'nanoid' import { createLogger } from '@/lib/logs/console/logger' import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis' @@ -79,6 +80,24 @@ export interface OutlookWebhookPayload { rawEmail?: OutlookEmail // Only included when includeRawEmail is true } +/** + * Convert HTML content to a readable plain-text representation. + * Keeps reasonable newlines and decodes common HTML entities. + */ +function convertHtmlToPlainText(html: string): string { + if (!html) return '' + return htmlToText(html, { + wordwrap: false, + selectors: [ + { selector: 'a', options: { hideLinkHrefIfSameAsText: true, noAnchorUrl: true } }, + { selector: 'img', format: 'skip' }, + { selector: 'script', format: 'skip' }, + { selector: 'style', format: 'skip' }, + ], + preserveNewlines: true, + }) +} + export async function pollOutlookWebhooks() { logger.info('Starting Outlook webhook polling') @@ -357,7 +376,18 @@ async function processOutlookEmails( to: email.toRecipients?.map((r) => r.emailAddress.address).join(', ') || '', cc: email.ccRecipients?.map((r) => r.emailAddress.address).join(', ') || '', date: email.receivedDateTime, - bodyText: email.bodyPreview || '', + bodyText: (() => { + const content = email.body?.content || '' + const type = (email.body?.contentType || '').toLowerCase() + if (!content) { + return email.bodyPreview || '' + } + if (type === 'text' || type === 'text/plain') { + return content + } + // Default to converting HTML or unknown types + return convertHtmlToPlainText(content) + })(), bodyHtml: email.body?.content || '', hasAttachments: email.hasAttachments, isRead: email.isRead, diff --git a/apps/sim/package.json b/apps/sim/package.json index aee18f0cf..42f0fab54 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -89,6 +89,7 @@ "fuse.js": "7.1.0", "geist": "1.4.2", "groq-sdk": "^0.15.0", + "html-to-text": "^9.0.5", "input-otp": "^1.4.2", "ioredis": "^5.6.0", "jose": "6.0.11", @@ -133,6 +134,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@trigger.dev/build": "4.0.0", + "@types/html-to-text": "^9.0.4", "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", "@types/lodash": "^4.17.16", diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f268539dd..91b654a85 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -12,6 +12,61 @@ import { const logger = createLogger('Tools') +// Extract a concise, meaningful error message from diverse API error shapes +function getDeepApiErrorMessage(errorInfo?: { + status?: number + statusText?: string + data?: any +}): string { + return ( + // GraphQL errors (Linear API) + errorInfo?.data?.errors?.[0]?.message || + // X/Twitter API specific pattern + errorInfo?.data?.errors?.[0]?.detail || + // Generic details array + errorInfo?.data?.details?.[0]?.message || + // Hunter API pattern + errorInfo?.data?.errors?.[0]?.details || + // Direct errors array (when errors[0] is a string or simple object) + (Array.isArray(errorInfo?.data?.errors) + ? typeof errorInfo.data.errors[0] === 'string' + ? errorInfo.data.errors[0] + : errorInfo.data.errors[0]?.message + : undefined) || + // Notion/Discord/GitHub/Twilio pattern + errorInfo?.data?.message || + // SOAP/XML fault patterns + errorInfo?.data?.fault?.faultstring || + errorInfo?.data?.faultstring || + // Microsoft/OAuth error descriptions + errorInfo?.data?.error_description || + // Airtable/Google fallback pattern + (typeof errorInfo?.data?.error === 'object' + ? errorInfo?.data?.error?.message || JSON.stringify(errorInfo?.data?.error) + : errorInfo?.data?.error) || + // HTTP status text fallback + errorInfo?.statusText || + // Final fallback + `Request failed with status ${errorInfo?.status || 'unknown'}` + ) +} + +// Create an Error instance from errorInfo and attach useful context +function createTransformedErrorFromErrorInfo(errorInfo?: { + status?: number + statusText?: string + data?: any +}): Error { + const message = getDeepApiErrorMessage(errorInfo) + const transformed = new Error(message) + Object.assign(transformed, { + status: errorInfo?.status, + statusText: errorInfo?.statusText, + data: errorInfo?.data, + }) + return transformed +} + /** * Process file outputs for a tool result if execution context is available * Uses dynamic imports to avoid client-side bundling issues @@ -410,15 +465,46 @@ async function handleInternalRequest( const response = await fetch(fullUrl, requestOptions) - // Parse response data once + // For non-OK responses, attempt JSON first; if parsing fails, preserve legacy error expected by tests + if (!response.ok) { + let errorData: any + try { + errorData = await response.json() + } catch (jsonError) { + logger.error(`[${requestId}] JSON parse error for ${toolId}:`, { + error: jsonError instanceof Error ? jsonError.message : String(jsonError), + }) + throw new Error(`Failed to parse response from ${toolId}: ${jsonError}`) + } + + const { isError, errorInfo } = isErrorResponse(response, errorData) + if (isError) { + const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo) + + logger.error(`[${requestId}] Internal API error for ${toolId}:`, { + status: errorInfo?.status, + errorData: errorInfo?.data, + }) + + throw errorToTransform + } + } + + // Parse response data once with guard for empty 202 bodies let responseData - try { - responseData = await response.json() - } catch (jsonError) { - logger.error(`[${requestId}] JSON parse error for ${toolId}:`, { - error: jsonError instanceof Error ? jsonError.message : String(jsonError), - }) - throw new Error(`Failed to parse response from ${toolId}: ${jsonError}`) + const status = response.status + if (status === 202) { + // Many APIs (e.g., Microsoft Graph) return 202 with empty body + responseData = { status } + } else { + try { + responseData = await response.json() + } catch (jsonError) { + logger.error(`[${requestId}] JSON parse error for ${toolId}:`, { + error: jsonError instanceof Error ? jsonError.message : String(jsonError), + }) + throw new Error(`Failed to parse response from ${toolId}: ${jsonError}`) + } } // Check for error conditions @@ -426,44 +512,7 @@ async function handleInternalRequest( if (isError) { // Handle error case - const errorToTransform = new Error( - // GraphQL errors (Linear API) - errorInfo?.data?.errors?.[0]?.message || - // X/Twitter API specific pattern - errorInfo?.data?.errors?.[0]?.detail || - // Generic details array - errorInfo?.data?.details?.[0]?.message || - // Hunter API pattern - errorInfo?.data?.errors?.[0]?.details || - // Direct errors array (when errors[0] is a string or simple object) - (Array.isArray(errorInfo?.data?.errors) - ? typeof errorInfo.data.errors[0] === 'string' - ? errorInfo.data.errors[0] - : errorInfo.data.errors[0]?.message - : undefined) || - // Notion/Discord/GitHub/Twilio pattern - errorInfo?.data?.message || - // SOAP/XML fault patterns - errorInfo?.data?.fault?.faultstring || - errorInfo?.data?.faultstring || - // Microsoft/OAuth error descriptions - errorInfo?.data?.error_description || - // Airtable/Google fallback pattern - (typeof errorInfo?.data?.error === 'object' - ? errorInfo?.data?.error?.message || JSON.stringify(errorInfo?.data?.error) - : errorInfo?.data?.error) || - // HTTP status text fallback - errorInfo?.statusText || - // Final fallback - `Request failed with status ${errorInfo?.status || 'unknown'}` - ) - - // Add error context - Object.assign(errorToTransform, { - status: errorInfo?.status, - statusText: errorInfo?.statusText, - data: errorInfo?.data, - }) + const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo) logger.error(`[${requestId}] Internal API error for ${toolId}:`, { status: errorInfo?.status, diff --git a/apps/sim/tools/microsoft_excel/read.ts b/apps/sim/tools/microsoft_excel/read.ts index 2f2a2f3ab..da2e6a056 100644 --- a/apps/sim/tools/microsoft_excel/read.ts +++ b/apps/sim/tools/microsoft_excel/read.ts @@ -35,7 +35,8 @@ export const readTool: ToolConfig = { + id: 'outlook_forward', + name: 'Outlook Forward', + description: 'Forward an existing Outlook message to specified recipients', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the message to forward', + }, + to: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Recipient email address(es), comma-separated', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional comment to include with the forwarded message', + }, + }, + + request: { + url: (params) => { + return `https://graph.microsoft.com/v1.0/me/messages/${params.messageId}/forward` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params: OutlookForwardParams): Record => { + const parseEmails = (emailString?: string) => { + if (!emailString) return [] + return emailString + .split(',') + .map((email) => email.trim()) + .filter((email) => email.length > 0) + .map((email) => ({ emailAddress: { address: email } })) + } + + const toRecipients = parseEmails(params.to) + if (toRecipients.length === 0) { + throw new Error('At least one recipient is required to forward a message') + } + + return { + comment: params.comment ?? '', + toRecipients, + } + }, + }, + + transformResponse: async (response: Response) => { + const status = response.status + const requestId = + response.headers?.get('request-id') || response.headers?.get('x-ms-request-id') || undefined + + // Graph forward action typically returns 202/204 with no body. Try to read text safely. + let bodyText = '' + try { + bodyText = await response.text() + } catch (_) { + // ignore body read errors + } + + // Attempt to parse JSON if present (rare for this endpoint). Extract message identifiers if available. + let parsed: any | undefined + if (bodyText && bodyText.trim().length > 0) { + try { + parsed = JSON.parse(bodyText) + } catch (_) { + // non-JSON body; ignore + } + } + + const messageId = parsed?.id || parsed?.messageId || parsed?.internetMessageId + const internetMessageId = parsed?.internetMessageId + + return { + success: true, + output: { + message: + status === 202 || status === 204 + ? 'Email forwarded successfully' + : `Email forwarded (HTTP ${status})`, + results: { + status: 'forwarded', + timestamp: new Date().toISOString(), + httpStatus: status, + requestId, + ...(messageId ? { messageId } : {}), + ...(internetMessageId ? { internetMessageId } : {}), + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + results: { + type: 'object', + description: 'Delivery result details', + properties: { + status: { type: 'string', description: 'Delivery status of the email' }, + timestamp: { type: 'string', description: 'Timestamp when email was forwarded' }, + httpStatus: { + type: 'number', + description: 'HTTP status code returned by the API', + optional: true, + }, + requestId: { + type: 'string', + description: 'Microsoft Graph request-id header for tracing', + optional: true, + }, + messageId: { + type: 'string', + description: 'Forwarded message ID if provided by API', + optional: true, + }, + internetMessageId: { + type: 'string', + description: 'RFC 822 Message-ID if provided', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/index.ts b/apps/sim/tools/outlook/index.ts index a218db1ee..63a918b9d 100644 --- a/apps/sim/tools/outlook/index.ts +++ b/apps/sim/tools/outlook/index.ts @@ -1,5 +1,6 @@ import { outlookDraftTool } from '@/tools/outlook/draft' +import { outlookForwardTool } from '@/tools/outlook/forward' import { outlookReadTool } from '@/tools/outlook/read' import { outlookSendTool } from '@/tools/outlook/send' -export { outlookDraftTool, outlookReadTool, outlookSendTool } +export { outlookDraftTool, outlookForwardTool, outlookReadTool, outlookSendTool } diff --git a/apps/sim/tools/outlook/read.ts b/apps/sim/tools/outlook/read.ts index 5bbb4e58a..d7637669a 100644 --- a/apps/sim/tools/outlook/read.ts +++ b/apps/sim/tools/outlook/read.ts @@ -127,9 +127,7 @@ export const outlookReadTool: ToolConfig }, outputs: { - success: { type: 'boolean', description: 'Email read operation success status' }, - messageCount: { type: 'number', description: 'Number of emails retrieved' }, - messages: { type: 'array', description: 'Array of email message objects' }, message: { type: 'string', description: 'Success or status message' }, + results: { type: 'array', description: 'Array of email message objects' }, }, } diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index be0ce5456..a7d80d43e 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -136,3 +136,19 @@ export interface CleanedOutlookMessage { } export type OutlookResponse = OutlookReadResponse | OutlookSendResponse | OutlookDraftResponse + +export interface OutlookForwardParams { + accessToken: string + messageId: string + to: string + comment?: string +} + +export interface OutlookForwardResponse extends ToolResponse { + output: { + message: string + results: any + } +} + +export type OutlookExtendedResponse = OutlookResponse | OutlookForwardResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1c1b6b932..1440eb9c1 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -109,7 +109,12 @@ import { } from '@/tools/notion' import { onedriveCreateFolderTool, onedriveListTool, onedriveUploadTool } from '@/tools/onedrive' import { imageTool, embeddingsTool as openAIEmbeddings } from '@/tools/openai' -import { outlookDraftTool, outlookReadTool, outlookSendTool } from '@/tools/outlook' +import { + outlookDraftTool, + outlookForwardTool, + outlookReadTool, + outlookSendTool, +} from '@/tools/outlook' import { parallelSearchTool } from '@/tools/parallel' import { perplexityChatTool } from '@/tools/perplexity' import { @@ -302,6 +307,7 @@ export const tools: Record = { outlook_read: outlookReadTool, outlook_send: outlookSendTool, outlook_draft: outlookDraftTool, + outlook_forward: outlookForwardTool, linear_read_issues: linearReadIssuesTool, linear_create_issue: linearCreateIssueTool, onedrive_create_folder: onedriveCreateFolderTool, diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index 5356f89b2..28d2813b0 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -79,7 +79,7 @@ export const outlookPollingTrigger: TriggerConfig = { }, bodyText: { type: 'string', - description: 'Plain text email body (preview)', + description: 'Plain text email body', }, bodyHtml: { type: 'string', diff --git a/bun.lock b/bun.lock index 4584b80ef..700ea222a 100644 --- a/bun.lock +++ b/bun.lock @@ -118,6 +118,7 @@ "fuse.js": "7.1.0", "geist": "1.4.2", "groq-sdk": "^0.15.0", + "html-to-text": "^9.0.5", "input-otp": "^1.4.2", "ioredis": "^5.6.0", "jose": "6.0.11", @@ -162,6 +163,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@trigger.dev/build": "4.0.0", + "@types/html-to-text": "^9.0.4", "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", "@types/lodash": "^4.17.16", @@ -1378,6 +1380,8 @@ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/html-to-text": ["@types/html-to-text@9.0.4", "", {}, "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ=="], + "@types/inquirer": ["@types/inquirer@8.2.12", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],