Skip to content

Commit ab74b13

Browse files
aadamgoughAdam Gough
andauthored
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 <[email protected]>
1 parent 861ab14 commit ab74b13

File tree

12 files changed

+355
-58
lines changed

12 files changed

+355
-58
lines changed

apps/sim/blocks/blocks/outlook.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
2222
{ label: 'Send Email', id: 'send_outlook' },
2323
{ label: 'Draft Email', id: 'draft_outlook' },
2424
{ label: 'Read Email', id: 'read_outlook' },
25+
{ label: 'Forward Email', id: 'forward_outlook' },
2526
],
2627
value: () => 'send_outlook',
2728
},
@@ -51,9 +52,30 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
5152
type: 'short-input',
5253
layout: 'full',
5354
placeholder: 'Recipient email address',
54-
condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] },
55+
condition: {
56+
field: 'operation',
57+
value: ['send_outlook', 'draft_outlook', 'forward_outlook'],
58+
},
59+
required: true,
60+
},
61+
{
62+
id: 'messageId',
63+
title: 'Message ID',
64+
type: 'short-input',
65+
layout: 'full',
66+
placeholder: 'Message ID to forward',
67+
condition: { field: 'operation', value: ['forward_outlook'] },
5568
required: true,
5669
},
70+
{
71+
id: 'comment',
72+
title: 'Comment',
73+
type: 'long-input',
74+
layout: 'full',
75+
placeholder: 'Optional comment to include when forwarding',
76+
condition: { field: 'operation', value: ['forward_outlook'] },
77+
required: false,
78+
},
5779
{
5880
id: 'subject',
5981
title: 'Subject',
@@ -157,7 +179,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
157179
},
158180
],
159181
tools: {
160-
access: ['outlook_send', 'outlook_draft', 'outlook_read'],
182+
access: ['outlook_send', 'outlook_draft', 'outlook_read', 'outlook_forward'],
161183
config: {
162184
tool: (params) => {
163185
switch (params.operation) {
@@ -167,6 +189,8 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
167189
return 'outlook_read'
168190
case 'draft_outlook':
169191
return 'outlook_draft'
192+
case 'forward_outlook':
193+
return 'outlook_forward'
170194
default:
171195
throw new Error(`Invalid Outlook operation: ${params.operation}`)
172196
}
@@ -197,6 +221,9 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
197221
to: { type: 'string', description: 'Recipient email address' },
198222
subject: { type: 'string', description: 'Email subject' },
199223
body: { type: 'string', description: 'Email content' },
224+
// Forward operation inputs
225+
messageId: { type: 'string', description: 'Message ID to forward' },
226+
comment: { type: 'string', description: 'Optional comment for forwarding' },
200227
// Read operation inputs
201228
folder: { type: 'string', description: 'Email folder' },
202229
manualFolder: { type: 'string', description: 'Manual folder name' },

apps/sim/lib/webhooks/outlook-polling-service.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { and, eq } from 'drizzle-orm'
2+
import { htmlToText } from 'html-to-text'
23
import { nanoid } from 'nanoid'
34
import { createLogger } from '@/lib/logs/console/logger'
45
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
@@ -79,6 +80,24 @@ export interface OutlookWebhookPayload {
7980
rawEmail?: OutlookEmail // Only included when includeRawEmail is true
8081
}
8182

83+
/**
84+
* Convert HTML content to a readable plain-text representation.
85+
* Keeps reasonable newlines and decodes common HTML entities.
86+
*/
87+
function convertHtmlToPlainText(html: string): string {
88+
if (!html) return ''
89+
return htmlToText(html, {
90+
wordwrap: false,
91+
selectors: [
92+
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true, noAnchorUrl: true } },
93+
{ selector: 'img', format: 'skip' },
94+
{ selector: 'script', format: 'skip' },
95+
{ selector: 'style', format: 'skip' },
96+
],
97+
preserveNewlines: true,
98+
})
99+
}
100+
82101
export async function pollOutlookWebhooks() {
83102
logger.info('Starting Outlook webhook polling')
84103

@@ -357,7 +376,18 @@ async function processOutlookEmails(
357376
to: email.toRecipients?.map((r) => r.emailAddress.address).join(', ') || '',
358377
cc: email.ccRecipients?.map((r) => r.emailAddress.address).join(', ') || '',
359378
date: email.receivedDateTime,
360-
bodyText: email.bodyPreview || '',
379+
bodyText: (() => {
380+
const content = email.body?.content || ''
381+
const type = (email.body?.contentType || '').toLowerCase()
382+
if (!content) {
383+
return email.bodyPreview || ''
384+
}
385+
if (type === 'text' || type === 'text/plain') {
386+
return content
387+
}
388+
// Default to converting HTML or unknown types
389+
return convertHtmlToPlainText(content)
390+
})(),
361391
bodyHtml: email.body?.content || '',
362392
hasAttachments: email.hasAttachments,
363393
isRead: email.isRead,

apps/sim/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"fuse.js": "7.1.0",
9090
"geist": "1.4.2",
9191
"groq-sdk": "^0.15.0",
92+
"html-to-text": "^9.0.5",
9293
"input-otp": "^1.4.2",
9394
"ioredis": "^5.6.0",
9495
"jose": "6.0.11",
@@ -133,6 +134,7 @@
133134
"@testing-library/react": "^16.3.0",
134135
"@testing-library/user-event": "^14.6.1",
135136
"@trigger.dev/build": "4.0.0",
137+
"@types/html-to-text": "^9.0.4",
136138
"@types/js-yaml": "4.0.9",
137139
"@types/jsdom": "21.1.7",
138140
"@types/lodash": "^4.17.16",

apps/sim/tools/index.ts

Lines changed: 95 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,61 @@ import {
1212

1313
const logger = createLogger('Tools')
1414

15+
// Extract a concise, meaningful error message from diverse API error shapes
16+
function getDeepApiErrorMessage(errorInfo?: {
17+
status?: number
18+
statusText?: string
19+
data?: any
20+
}): string {
21+
return (
22+
// GraphQL errors (Linear API)
23+
errorInfo?.data?.errors?.[0]?.message ||
24+
// X/Twitter API specific pattern
25+
errorInfo?.data?.errors?.[0]?.detail ||
26+
// Generic details array
27+
errorInfo?.data?.details?.[0]?.message ||
28+
// Hunter API pattern
29+
errorInfo?.data?.errors?.[0]?.details ||
30+
// Direct errors array (when errors[0] is a string or simple object)
31+
(Array.isArray(errorInfo?.data?.errors)
32+
? typeof errorInfo.data.errors[0] === 'string'
33+
? errorInfo.data.errors[0]
34+
: errorInfo.data.errors[0]?.message
35+
: undefined) ||
36+
// Notion/Discord/GitHub/Twilio pattern
37+
errorInfo?.data?.message ||
38+
// SOAP/XML fault patterns
39+
errorInfo?.data?.fault?.faultstring ||
40+
errorInfo?.data?.faultstring ||
41+
// Microsoft/OAuth error descriptions
42+
errorInfo?.data?.error_description ||
43+
// Airtable/Google fallback pattern
44+
(typeof errorInfo?.data?.error === 'object'
45+
? errorInfo?.data?.error?.message || JSON.stringify(errorInfo?.data?.error)
46+
: errorInfo?.data?.error) ||
47+
// HTTP status text fallback
48+
errorInfo?.statusText ||
49+
// Final fallback
50+
`Request failed with status ${errorInfo?.status || 'unknown'}`
51+
)
52+
}
53+
54+
// Create an Error instance from errorInfo and attach useful context
55+
function createTransformedErrorFromErrorInfo(errorInfo?: {
56+
status?: number
57+
statusText?: string
58+
data?: any
59+
}): Error {
60+
const message = getDeepApiErrorMessage(errorInfo)
61+
const transformed = new Error(message)
62+
Object.assign(transformed, {
63+
status: errorInfo?.status,
64+
statusText: errorInfo?.statusText,
65+
data: errorInfo?.data,
66+
})
67+
return transformed
68+
}
69+
1570
/**
1671
* Process file outputs for a tool result if execution context is available
1772
* Uses dynamic imports to avoid client-side bundling issues
@@ -410,60 +465,54 @@ async function handleInternalRequest(
410465

411466
const response = await fetch(fullUrl, requestOptions)
412467

413-
// Parse response data once
468+
// For non-OK responses, attempt JSON first; if parsing fails, preserve legacy error expected by tests
469+
if (!response.ok) {
470+
let errorData: any
471+
try {
472+
errorData = await response.json()
473+
} catch (jsonError) {
474+
logger.error(`[${requestId}] JSON parse error for ${toolId}:`, {
475+
error: jsonError instanceof Error ? jsonError.message : String(jsonError),
476+
})
477+
throw new Error(`Failed to parse response from ${toolId}: ${jsonError}`)
478+
}
479+
480+
const { isError, errorInfo } = isErrorResponse(response, errorData)
481+
if (isError) {
482+
const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo)
483+
484+
logger.error(`[${requestId}] Internal API error for ${toolId}:`, {
485+
status: errorInfo?.status,
486+
errorData: errorInfo?.data,
487+
})
488+
489+
throw errorToTransform
490+
}
491+
}
492+
493+
// Parse response data once with guard for empty 202 bodies
414494
let responseData
415-
try {
416-
responseData = await response.json()
417-
} catch (jsonError) {
418-
logger.error(`[${requestId}] JSON parse error for ${toolId}:`, {
419-
error: jsonError instanceof Error ? jsonError.message : String(jsonError),
420-
})
421-
throw new Error(`Failed to parse response from ${toolId}: ${jsonError}`)
495+
const status = response.status
496+
if (status === 202) {
497+
// Many APIs (e.g., Microsoft Graph) return 202 with empty body
498+
responseData = { status }
499+
} else {
500+
try {
501+
responseData = await response.json()
502+
} catch (jsonError) {
503+
logger.error(`[${requestId}] JSON parse error for ${toolId}:`, {
504+
error: jsonError instanceof Error ? jsonError.message : String(jsonError),
505+
})
506+
throw new Error(`Failed to parse response from ${toolId}: ${jsonError}`)
507+
}
422508
}
423509

424510
// Check for error conditions
425511
const { isError, errorInfo } = isErrorResponse(response, responseData)
426512

427513
if (isError) {
428514
// Handle error case
429-
const errorToTransform = new Error(
430-
// GraphQL errors (Linear API)
431-
errorInfo?.data?.errors?.[0]?.message ||
432-
// X/Twitter API specific pattern
433-
errorInfo?.data?.errors?.[0]?.detail ||
434-
// Generic details array
435-
errorInfo?.data?.details?.[0]?.message ||
436-
// Hunter API pattern
437-
errorInfo?.data?.errors?.[0]?.details ||
438-
// Direct errors array (when errors[0] is a string or simple object)
439-
(Array.isArray(errorInfo?.data?.errors)
440-
? typeof errorInfo.data.errors[0] === 'string'
441-
? errorInfo.data.errors[0]
442-
: errorInfo.data.errors[0]?.message
443-
: undefined) ||
444-
// Notion/Discord/GitHub/Twilio pattern
445-
errorInfo?.data?.message ||
446-
// SOAP/XML fault patterns
447-
errorInfo?.data?.fault?.faultstring ||
448-
errorInfo?.data?.faultstring ||
449-
// Microsoft/OAuth error descriptions
450-
errorInfo?.data?.error_description ||
451-
// Airtable/Google fallback pattern
452-
(typeof errorInfo?.data?.error === 'object'
453-
? errorInfo?.data?.error?.message || JSON.stringify(errorInfo?.data?.error)
454-
: errorInfo?.data?.error) ||
455-
// HTTP status text fallback
456-
errorInfo?.statusText ||
457-
// Final fallback
458-
`Request failed with status ${errorInfo?.status || 'unknown'}`
459-
)
460-
461-
// Add error context
462-
Object.assign(errorToTransform, {
463-
status: errorInfo?.status,
464-
statusText: errorInfo?.statusText,
465-
data: errorInfo?.data,
466-
})
515+
const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo)
467516

468517
logger.error(`[${requestId}] Internal API error for ${toolId}:`, {
469518
status: errorInfo?.status,

apps/sim/tools/microsoft_excel/read.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
3535
type: 'string',
3636
required: false,
3737
visibility: 'user-or-llm',
38-
description: 'The range of cells to read from',
38+
description:
39+
'The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet.',
3940
},
4041
},
4142

@@ -53,10 +54,19 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
5354
}
5455

5556
const rangeInput = params.range.trim()
57+
58+
// If the input contains no '!', treat it as a sheet name only and fetch usedRange
59+
if (!rangeInput.includes('!')) {
60+
const sheetOnly = encodeURIComponent(rangeInput)
61+
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${sheetOnly}')/usedRange(valuesOnly=true)`
62+
}
63+
5664
const match = rangeInput.match(/^([^!]+)!(.+)$/)
5765

5866
if (!match) {
59-
throw new Error(`Invalid range format: "${params.range}". Use the format "Sheet1!A1:B2"`)
67+
throw new Error(
68+
`Invalid range format: "${params.range}". Use "Sheet1!A1:B2" or just "Sheet1" to read the whole sheet`
69+
)
6070
}
6171

6272
const sheetName = encodeURIComponent(match[1])
@@ -104,7 +114,7 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
104114
if (!rangeResp.ok) {
105115
// Normalize Microsoft Graph sheet/range errors to a friendly message
106116
throw new Error(
107-
'Invalid range provided or worksheet not found. Provide a range like "Sheet1!A1:B2"'
117+
'Invalid range provided or worksheet not found. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet'
108118
)
109119
}
110120

0 commit comments

Comments
 (0)