Files
simstudioai--sim/apps/sim/lib/webhooks/providers/instantly.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

299 lines
10 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
import { instantlyUrl } from '@/tools/instantly/utils'
const logger = createLogger('WebhookProvider:Instantly')
const SIM_WEBHOOK_TOKEN_HEADER = 'x-sim-webhook-token'
export const instantlyHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secretToken = providerConfig.secretToken as string | undefined
if (!secretToken) {
logger.warn(`[${requestId}] Instantly webhook secret token is missing`)
return new NextResponse('Unauthorized', { status: 401 })
}
if (!verifyTokenAuth(request, secretToken, SIM_WEBHOOK_TOKEN_HEADER)) {
logger.warn(`[${requestId}] Unauthorized Instantly webhook request`)
return new NextResponse('Unauthorized', { status: 401 })
}
return null
},
async matchEvent({ body, providerConfig, requestId }: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) return true
if (!isRecordLike(body)) {
logger.warn(`[${requestId}] Instantly webhook payload was not an object`)
return false
}
const { isInstantlyEventMatch } = await import('@/triggers/instantly/utils')
if (!isInstantlyEventMatch(triggerId, body)) {
logger.info(`[${requestId}] Instantly event did not match trigger`, {
triggerId,
eventType: body.event_type,
})
return false
}
return true
},
/**
* `email_id` (Instantly's `reply_to_uuid`) identifies the sent email, not
* one occurrence of an event on that email — the same event_type can fire
* more than once for one email_id (every open, every click, every reply in
* a thread), so email_id alone would collapse those distinct, legitimate
* deliveries into one idempotency slot. `timestamp` is fixed per event
* occurrence (not regenerated on retry), so appending it keeps the key
* both retry-stable and unique per occurrence — without it there's no way
* to distinguish occurrences, so dedup is skipped rather than risking a
* false collision.
*/
extractIdempotencyId(body: unknown): string | null {
if (!isRecordLike(body)) return null
const eventType = typeof body.event_type === 'string' ? body.event_type : undefined
if (!eventType) return null
const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined
const emailId = typeof body.email_id === 'string' ? body.email_id : undefined
if (emailId) {
return timestamp ? `instantly:${eventType}:${emailId}:${timestamp}` : null
}
const campaignId = typeof body.campaign_id === 'string' ? body.campaign_id : undefined
const leadEmail = typeof body.lead_email === 'string' ? body.lead_email : undefined
if (campaignId && leadEmail && timestamp) {
return `instantly:${eventType}:${campaignId}:${leadEmail}:${timestamp}`
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = isRecordLike(body) ? body : {}
return {
input: {
timestamp: toStringOrNull(payload.timestamp),
eventType: toStringOrNull(payload.event_type),
workspace: toStringOrNull(payload.workspace),
campaignId: toStringOrNull(payload.campaign_id),
campaignName: toStringOrNull(payload.campaign_name),
leadEmail: toStringOrNull(payload.lead_email),
emailAccount: toStringOrNull(payload.email_account),
uniboxUrl: toStringOrNull(payload.unibox_url),
step: toNumberOrNull(payload.step),
variant: toNumberOrNull(payload.variant),
isFirst: toBooleanOrNull(payload.is_first),
emailId: toStringOrNull(payload.email_id),
emailSubject: toStringOrNull(payload.email_subject),
emailText: toStringOrNull(payload.email_text),
emailHtml: toStringOrNull(payload.email_html),
replyTextSnippet: toStringOrNull(payload.reply_text_snippet),
replySubject: toStringOrNull(payload.reply_subject),
replyText: toStringOrNull(payload.reply_text),
replyHtml: toStringOrNull(payload.reply_html),
payload,
},
}
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.triggerApiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
const campaignId = optionalId(providerConfig.triggerCampaignId)
if (!apiKey?.trim()) {
throw new Error('Instantly API Key is required.')
}
if (!triggerId) {
throw new Error('Instantly trigger ID is required.')
}
const { getInstantlySubscriptionEventTypeForTrigger } = await import(
'@/triggers/instantly/utils'
)
const eventType = getInstantlySubscriptionEventTypeForTrigger(triggerId)
if (!eventType) {
throw new Error(`Unknown Instantly trigger type: ${triggerId}`)
}
const secretToken =
typeof providerConfig.secretToken === 'string' && providerConfig.secretToken.length > 0
? providerConfig.secretToken
: generateShortId(32)
const requestBody: Record<string, unknown> = {
name: `Sim - ${triggerId.replace(/^instantly_/, '').replace(/_/g, ' ')}`,
target_hook_url: getNotificationUrl(webhook),
event_type: eventType,
headers: {
'X-Sim-Webhook-Token': secretToken,
},
}
if (campaignId) {
requestBody.campaign = campaignId
}
logger.info(`[${requestId}] Creating Instantly webhook`, {
triggerId,
eventType,
hasCampaignId: Boolean(campaignId),
webhookId: webhook.id,
})
const response = await fetch(instantlyUrl('/api/v2/webhooks'), {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await parseJsonResponse(response)
if (!response.ok) {
const message = extractInstantlyError(responseBody)
logger.error(`[${requestId}] Failed to create Instantly webhook`, {
status: response.status,
message,
response: responseBody,
})
if (response.status === 401 || response.status === 403) {
throw new Error('Invalid Instantly API Key or missing webhook permissions.')
}
if (response.status === 402) {
throw new Error('Instantly webhook creation requires an active paid plan.')
}
throw new Error(
message ? `Instantly error: ${message}` : 'Failed to create Instantly webhook'
)
}
const externalId = responseBody?.id
if (typeof externalId !== 'string' || externalId.length === 0) {
throw new Error('Instantly webhook was created but the API response did not include an ID.')
}
logger.info(`[${requestId}] Successfully created Instantly webhook`, {
externalId,
webhookId: webhook.id,
})
return { providerConfigUpdates: { externalId, secretToken } }
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.triggerApiKey as string | undefined
const externalId = providerConfig.externalId as string | undefined
if (!apiKey?.trim() || !externalId?.trim()) {
logger.warn(`[${requestId}] Missing Instantly webhook cleanup configuration`, {
webhookId: webhook.id,
hasApiKey: Boolean(apiKey),
hasExternalId: Boolean(externalId),
})
if (ctx.strict) throw new Error('Missing Instantly webhook cleanup configuration')
return
}
const response = await fetch(
instantlyUrl(`/api/v2/webhooks/${encodeURIComponent(externalId.trim())}`),
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
},
}
)
if (!response.ok && response.status !== 404) {
const responseBody = await parseJsonResponse(response)
logger.warn(`[${requestId}] Failed to delete Instantly webhook`, {
status: response.status,
response: responseBody,
})
if (ctx.strict) throw new Error(`Failed to delete Instantly webhook: ${response.status}`)
return
}
await response.body?.cancel()
logger.info(`[${requestId}] Successfully deleted Instantly webhook`, {
externalId,
webhookId: webhook.id,
})
} catch (error) {
logger.warn(`[${requestId}] Error deleting Instantly webhook`, {
message: toError(error).message,
})
if (ctx.strict) throw error
}
},
}
async function parseJsonResponse(response: Response): Promise<Record<string, unknown> | null> {
try {
const body: unknown = await response.json()
return isRecordLike(body) ? body : null
} catch {
return null
}
}
function extractInstantlyError(body: Record<string, unknown> | null): string | null {
if (!body) return null
if (typeof body.message === 'string') return body.message
if (typeof body.error === 'string') return body.error
return null
}
function toStringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function toBooleanOrNull(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null
}
function optionalId(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (trimmed === '' || trimmed === '-') return undefined
return trimmed
}