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 { 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 { 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 { 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 = { 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 { 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 | null> { try { const body: unknown = await response.json() return isRecordLike(body) ? body : null } catch { return null } } function extractInstantlyError(body: Record | 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 }