Files
simstudioai--sim/apps/sim/lib/webhooks/providers/emailbison.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

338 lines
12 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import {
type SecureFetchResponse,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { emailBisonHeaders, emailBisonUrl } from '@/tools/emailbison/utils'
const logger = createLogger('WebhookProvider:EmailBison')
export const emailBisonHandler: WebhookProviderHandler = {
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}] Email Bison webhook payload was not an object`)
return false
}
const { isEmailBisonEventMatch } = await import('@/triggers/emailbison/utils')
if (!isEmailBisonEventMatch(triggerId, unwrapEmailBisonPayload(body))) {
logger.info(`[${requestId}] Email Bison event did not match trigger`, {
triggerId,
})
return false
}
return true
},
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
const payload = isRecordLike(body) ? unwrapEmailBisonPayload(body) : {}
const event = isRecordLike(payload.event) ? payload.event : null
const data = isRecordLike(payload.data) ? payload.data : null
const providerConfig = getProviderConfig(webhook)
const triggerId = providerConfig.triggerId as string | undefined
const input: Record<string, unknown> = {
eventType: toStringOrNull(event?.type),
eventName: toStringOrNull(event?.name),
instanceUrl: toStringOrNull(event?.instance_url),
workspaceId: toNumberOrNull(event?.workspace_id),
workspaceName: toStringOrNull(event?.workspace_name),
event,
data,
}
if (
triggerId === 'emailbison_email_sent' ||
triggerId === 'emailbison_lead_first_contacted' ||
triggerId === 'emailbison_lead_unsubscribed' ||
triggerId === 'emailbison_email_opened'
) {
input.scheduledEmail = toRecordOrNull(data?.scheduled_email)
input.campaignEvent = renameTypeField(data?.campaign_event, 'event_type')
input.lead = toRecordOrNull(data?.lead)
input.campaign = toRecordOrNull(data?.campaign)
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (
triggerId === 'emailbison_lead_replied' ||
triggerId === 'emailbison_lead_interested' ||
triggerId === 'emailbison_email_bounced'
) {
input.reply = renameTypeField(data?.reply, 'reply_type')
input.campaignEvent = renameTypeField(data?.campaign_event, 'event_type')
input.lead = toRecordOrNull(data?.lead)
input.campaign = toRecordOrNull(data?.campaign)
input.scheduledEmail = toRecordOrNull(data?.scheduled_email)
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (triggerId === 'emailbison_untracked_reply_received') {
input.reply = renameTypeField(data?.reply, 'reply_type')
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (
triggerId === 'emailbison_email_account_added' ||
triggerId === 'emailbison_email_account_removed' ||
triggerId === 'emailbison_email_account_disconnected' ||
triggerId === 'emailbison_email_account_reconnected' ||
triggerId === 'emailbison_warmup_disabled_receiving_bounces' ||
triggerId === 'emailbison_warmup_disabled_causing_bounces'
) {
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (triggerId === 'emailbison_manual_email_sent') {
input.reply = renameTypeField(data?.reply, 'reply_type')
input.lead = toRecordOrNull(data?.lead)
input.campaign = toRecordOrNull(data?.campaign)
input.scheduledEmail = toRecordOrNull(data?.scheduled_email)
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (triggerId === 'emailbison_tag_attached' || triggerId === 'emailbison_tag_removed') {
input.tagId = toNumberOrNull(data?.tag_id)
input.tagName = toStringOrNull(data?.tag_name)
input.taggableId = toNumberOrNull(data?.taggable_id)
input.taggableType = toStringOrNull(data?.taggable_type)
}
return {
input,
}
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const apiBaseUrl = providerConfig.apiBaseUrl as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
if (!apiKey?.trim()) {
throw new Error('Email Bison API Key is required.')
}
if (!apiBaseUrl?.trim()) {
throw new Error('Email Bison Instance URL is required.')
}
if (!triggerId) {
throw new Error('Email Bison trigger ID is required.')
}
const { getEmailBisonEventTypeForTrigger } = await import('@/triggers/emailbison/utils')
const eventType = getEmailBisonEventTypeForTrigger(triggerId)
if (!eventType) {
throw new Error(`Unknown Email Bison trigger type: ${triggerId}`)
}
const notificationUrl = getNotificationUrl(webhook)
logger.info(`[${requestId}] Creating Email Bison webhook`, {
triggerId,
eventType,
webhookId: webhook.id,
})
const targetUrl = emailBisonUrl('/api/webhook-url', {}, apiBaseUrl)
const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`)
throw new Error('Email Bison Instance URL could not be validated.')
}
const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, {
method: 'POST',
headers: emailBisonHeaders({ apiKey, apiBaseUrl }),
body: JSON.stringify({
name: `Sim - ${eventType}`,
url: notificationUrl,
events: [eventType],
}),
})
const responseBody = await parseJsonResponse(response)
if (!response.ok) {
const message = extractEmailBisonError(responseBody)
logger.error(`[${requestId}] Failed to create Email Bison webhook`, {
status: response.status,
message,
response: responseBody,
})
if (response.status === 401 || response.status === 403) {
throw new Error(
'Invalid Email Bison API Key or Instance URL. Confirm both came from the same Email Bison instance.'
)
}
throw new Error(
message ? `Email Bison error: ${message}` : 'Failed to create Email Bison webhook'
)
}
const data = isRecordLike(responseBody?.data) ? responseBody.data : null
const externalId = data?.id
if (externalId === undefined || externalId === null || externalId === '') {
throw new Error('Email Bison webhook was created but the API response did not include an ID.')
}
logger.info(`[${requestId}] Successfully created Email Bison webhook`, {
externalId,
webhookId: webhook.id,
})
return { providerConfigUpdates: { externalId: String(externalId) } }
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const apiBaseUrl = providerConfig.apiBaseUrl as string | undefined
const externalId = providerConfig.externalId as string | undefined
if (!apiKey?.trim() || !apiBaseUrl?.trim() || !externalId?.trim()) {
logger.warn(`[${requestId}] Missing Email Bison webhook cleanup configuration`, {
webhookId: webhook.id,
hasApiKey: Boolean(apiKey),
hasApiBaseUrl: Boolean(apiBaseUrl),
hasExternalId: Boolean(externalId),
})
if (ctx.strict)
throw new AlreadyLoggedError('Missing Email Bison webhook cleanup configuration')
return
}
const targetUrl = emailBisonUrl(
`/api/webhook-url/${encodeURIComponent(externalId)}`,
{},
apiBaseUrl
)
const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`, {
webhookId: webhook.id,
})
if (ctx.strict)
throw new AlreadyLoggedError('Email Bison Instance URL could not be validated.')
return
}
const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, {
method: 'DELETE',
headers: emailBisonHeaders({ apiKey, apiBaseUrl }),
})
if (!response.ok && response.status !== 404) {
const responseBody = await parseJsonResponse(response)
logger.warn(`[${requestId}] Failed to delete Email Bison webhook`, {
status: response.status,
response: responseBody,
})
if (ctx.strict)
throw new AlreadyLoggedError(`Failed to delete Email Bison webhook: ${response.status}`)
return
}
await response.body?.cancel()
logger.info(`[${requestId}] Successfully deleted Email Bison webhook`, {
externalId,
webhookId: webhook.id,
})
} catch (error) {
if (!(error instanceof AlreadyLoggedError)) {
logger.warn(`[${requestId}] Error deleting Email Bison webhook`, {
message: toError(error).message,
})
}
if (ctx.strict) throw error
}
},
}
/**
* Marks an error whose failure reason has already been logged with full context
* at the throw site, so the outer catch in `deleteSubscription` does not emit
* a second, redundant warning for the same failure.
*/
class AlreadyLoggedError extends Error {}
async function parseJsonResponse(
response: SecureFetchResponse
): Promise<Record<string, unknown> | null> {
try {
const body: unknown = await response.json()
return isRecordLike(body) ? body : null
} catch {
return null
}
}
function extractEmailBisonError(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
const data = body.data
if (isRecordLike(data) && typeof data.message === 'string') return data.message
return null
}
function unwrapEmailBisonPayload(body: Record<string, unknown>): Record<string, unknown> {
if (isRecordLike(body.event)) return body
const data = body.data
if (isRecordLike(data) && isRecordLike(data.event)) return data
const payload = isRecordLike(data) ? data.payload : body.payload
if (isRecordLike(payload) && isRecordLike(payload.event)) return payload
return body
}
function toStringOrNull(value: unknown): string | null {
if (value === undefined || value === null) return null
return String(value)
}
function toNumberOrNull(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null
if (typeof value !== 'string' || value.trim() === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function toRecordOrNull(value: unknown): Record<string, unknown> | null {
return isRecordLike(value) ? value : null
}
function renameTypeField(value: unknown, targetKey: string): Record<string, unknown> | null {
if (!isRecordLike(value)) return null
const { type, ...rest } = value
return { ...rest, [targetKey]: type ?? null }
}