Skip to content

Commit 9e5780f

Browse files
committed
fix(helpscout): strict custom field and snooze input, unsafe connector reconciliation, safe inbox IDs
1 parent 5f08395 commit 9e5780f

6 files changed

Lines changed: 42 additions & 35 deletions

File tree

‎apps/sim/connectors/helpscout/helpscout.ts‎

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ const ConversationPageSchema = z
8484
.passthrough()
8585
.optional(),
8686
_links: LinksSchema,
87-
page: z.object({ totalElements: z.number().optional() }).passthrough().optional(),
8887
})
8988
.passthrough()
9089

@@ -219,7 +218,9 @@ function conversationChangeMarker(conversation: HelpScoutConversation): string {
219218
conversation.preview ?? '',
220219
conversation.mailboxId ?? '',
221220
conversation.assignee?.id ?? '',
221+
conversation.assignee ? personName(conversation.assignee) : '',
222222
conversation.primaryCustomer?.email ?? '',
223+
conversation.primaryCustomer ? personName(conversation.primaryCustomer) : '',
223224
(conversation.tags ?? [])
224225
.map((tag) => tag.tag ?? '')
225226
.sort(compareStrings)
@@ -335,8 +336,6 @@ export const helpScoutConnector: ConnectorConfig = {
335336
const seen = new Set<number>()
336337
const documents: ExternalDocument[] = []
337338
let capped = false
338-
let firstTotal: number | undefined
339-
let lastTotal: number | undefined
340339
const maxPages = Math.ceil(maxConversations / CONVERSATIONS_PER_PAGE) + 5
341340

342341
for (let page = 1; ; page++) {
@@ -351,8 +350,6 @@ export const helpScoutConnector: ConnectorConfig = {
351350
const data = ConversationPageSchema.parse(
352351
await helpScoutGet('/conversations', accessToken, query)
353352
)
354-
lastTotal = data.page?.totalElements
355-
if (page === 1) firstTotal = lastTotal
356353
for (const conversation of data._embedded?.conversations ?? []) {
357354
if (seen.has(conversation.id) || !isIndexedConversation(conversation)) continue
358355
if (documents.length >= maxConversations) {
@@ -369,25 +366,21 @@ export const helpScoutConnector: ConnectorConfig = {
369366
}
370367
}
371368

372-
/**
373-
* Pages are numbered, so a conversation that leaves the filtered set mid-scan shifts later
374-
* ones up a page and one can be skipped. A changed total means that may have happened, so
375-
* this listing must not drive deletion.
376-
*/
377-
const totalChanged = firstTotal !== undefined && lastTotal !== firstTotal
378-
if (syncContext) {
379-
if (capped) syncContext.listingCapped = true
380-
if (totalChanged) syncContext.reconciliationUnsafe = true
381-
}
369+
if (capped && syncContext) syncContext.listingCapped = true
382370
if (capped) {
383-
logger.warn(
384-
'Help Scout listing truncated by Max Conversations; skipping deletion reconciliation',
385-
{ count: documents.length, maxConversations }
386-
)
371+
logger.warn('Help Scout listing truncated by Max Conversations', {
372+
count: documents.length,
373+
maxConversations,
374+
})
387375
} else {
388-
logger.info('Listed Help Scout conversations', { count: documents.length, totalChanged })
376+
logger.info('Listed Help Scout conversations', { count: documents.length })
389377
}
390-
return { documents, hasMore: false }
378+
/**
379+
* Pages are numbered, so a conversation that leaves the filtered set mid-scan shifts later
380+
* ones up a page and one can be skipped even when the total stays the same. Absence from
381+
* this listing is therefore never treated as a deletion.
382+
*/
383+
return { documents, hasMore: false, reconciliationSafe: false }
391384
},
392385

393386
getDocument: async (
@@ -402,7 +395,9 @@ export const helpScoutConnector: ConnectorConfig = {
402395
await helpScoutGet(`/conversations/${externalId}`, accessToken)
403396
)
404397
// A merged conversation redirects to the conversation it was merged into.
405-
if (conversation.state === 'deleted' || String(conversation.id) !== externalId) return null
398+
if (!isIndexedConversation(conversation) || String(conversation.id) !== externalId) {
399+
return null
400+
}
406401
const threads = (await listThreads(externalId, accessToken)).filter((thread) =>
407402
isIndexedThread(thread, includeNotes)
408403
)

‎apps/sim/connectors/helpscout/meta.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const helpScoutConnectorMeta: ConnectorMeta = {
8080
required: false,
8181
placeholder: `e.g. 200 (default: ${DEFAULT_MAX_CONVERSATIONS})`,
8282
description:
83-
'Maximum number of conversations to sync, newest first. When more exist, conversations deleted in Help Scout stay in the knowledge base until the limit covers them.',
83+
'Maximum number of conversations to sync, newest first. Conversations deleted in Help Scout are not removed from the knowledge base automatically.',
8484
},
8585
],
8686

‎apps/sim/lib/selectors/server/providers/helpscout.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ type HelpScoutSelectorKey = Extract<
2626
>
2727

2828
const MAX_PAGE_ITEMS = 1_000
29-
/** Help Scout documents no single-team endpoint, so a team detail scans at most this many pages. */
30-
const MAX_TEAM_DETAIL_PAGES = 5
29+
/** Help Scout documents no single-team endpoint, so a team detail scans the teams list. */
30+
const MAX_TEAM_DETAIL_PAGES = 200
3131

3232
const idSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
3333

‎apps/sim/lib/webhooks/providers/helpscout.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,11 @@ function readEventType(headers: Record<string, string>): string {
6060
function resolveInboxFilter(config: Record<string, unknown>): number | undefined {
6161
const raw = toStringOrNull(config.triggerMailboxId)?.trim()
6262
if (!raw) return undefined
63-
if (!HELPSCOUT_ID_PATTERN.test(raw)) {
63+
const id = Number(raw)
64+
if (!HELPSCOUT_ID_PATTERN.test(raw) || !Number.isSafeInteger(id) || id <= 0) {
6465
throw new WebhookDeploymentConfigurationError('The Help Scout inbox ID must be numeric.')
6566
}
66-
return Number(raw)
67+
return id
6768
}
6869

6970
/**

‎apps/sim/tools/helpscout/snooze-conversation.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,28 @@ import {
1313
} from '@/tools/helpscout/utils'
1414
import type { ToolConfig } from '@/tools/types'
1515

16-
/** Help Scout requires a future ISO 8601 time no later than the year 2100. */
16+
const ISO_DATE_TIME_PATTERN =
17+
/^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/
18+
19+
/** Help Scout requires a future ISO 8601 time, in whole seconds, no later than the year 2100. */
1720
function parseSnoozeUntil(value: string): string {
1821
const text = value?.trim() ?? ''
22+
const match = ISO_DATE_TIME_PATTERN.exec(text)
1923
const time = Date.parse(text)
20-
if (!text || Number.isNaN(time)) {
24+
if (!match || Number.isNaN(time)) {
2125
throw new Error('snoozedUntil must be an ISO 8601 date-time, e.g. 2025-02-15T12:00:00Z')
2226
}
23-
if (time <= Date.now()) throw new Error('snoozedUntil must be in the future')
24-
if (new Date(time).getUTCFullYear() > 2100) {
27+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
28+
const calendarDate = new Date(Date.UTC(year, month - 1, day))
29+
if (calendarDate.getUTCMonth() !== month - 1 || calendarDate.getUTCDate() !== day) {
30+
throw new Error(`snoozedUntil has an invalid calendar date: ${match[0].slice(0, 10)}`)
31+
}
32+
const wholeSeconds = Math.ceil(time / 1000) * 1000
33+
if (wholeSeconds <= Date.now()) throw new Error('snoozedUntil must be in the future')
34+
if (new Date(wholeSeconds).getUTCFullYear() > 2100) {
2535
throw new Error('snoozedUntil must not be after the year 2100')
2636
}
27-
return new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z')
37+
return new Date(wholeSeconds).toISOString().replace(/\.\d{3}Z$/, 'Z')
2838
}
2939

3040
export const helpScoutSnoozeConversationTool: ToolConfig<

‎apps/sim/tools/helpscout/update-custom-fields.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
import type { ToolConfig } from '@/tools/types'
1717

1818
function parseCustomFields(value: unknown): Array<{ id: number; value: string }> {
19-
const parsed = parseJsonParam(value, 'customFields') ?? []
19+
// Sending an empty list clears every field, so only an explicit JSON array is accepted.
20+
const parsed = parseJsonParam(value, 'customFields')
2021
if (!Array.isArray(parsed)) {
2122
throw new Error('customFields must be a JSON array of { "id": number, "value": string }')
2223
}
2324
return parsed.map((entry) => {
2425
const field = entry as { id?: unknown; value?: unknown }
25-
if (field.value === undefined || field.value === null) {
26-
throw new Error('Every custom field needs a value; use an empty string to clear one')
26+
if (typeof field.value !== 'string' && typeof field.value !== 'number') {
27+
throw new Error('Every custom field value must be a string or number; use "" to clear one')
2728
}
2829
return { id: parseHelpScoutId(field.id, 'customFields[].id'), value: String(field.value) }
2930
})

0 commit comments

Comments
 (0)