d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
338 lines
12 KiB
TypeScript
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 }
|
|
}
|