import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' 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 { RESEND_ALL_WEBHOOK_EVENT_TYPES, RESEND_TRIGGER_TO_EVENT_TYPE, } from '@/triggers/resend/utils' const logger = createLogger('WebhookProvider:Resend') /** * Verify a Resend webhook signature using the Svix signing scheme. * Resend uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}` * signed with the base64-decoded `whsec_...` secret. */ function verifySvixSignature( secret: string, msgId: string, timestamp: string, signatures: string, rawBody: string ): boolean { try { const ts = Number.parseInt(timestamp, 10) const now = Math.floor(Date.now() / 1000) if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) { return false } const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64') const toSign = `${msgId}.${timestamp}.${rawBody}` const expectedSignature = hmacSha256Base64(toSign, secretBytes) const providedSignatures = signatures.split(' ') for (const versionedSig of providedSignatures) { const parts = versionedSig.split(',') if (parts.length !== 2) continue const sig = parts[1] if (safeCompare(sig, expectedSignature)) { return true } } return false } catch (error) { logger.error('Error verifying Resend Svix signature:', error) return false } } export const resendHandler: WebhookProviderHandler = { async verifyAuth({ request, rawBody, requestId, providerConfig, }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret?.trim()) { logger.warn(`[${requestId}] Resend webhook missing signing secret in provider configuration`) return new NextResponse('Unauthorized - Resend signing secret is required', { status: 401 }) } const svixId = request.headers.get('svix-id') const svixTimestamp = request.headers.get('svix-timestamp') const svixSignature = request.headers.get('svix-signature') if (!svixId || !svixTimestamp || !svixSignature) { logger.warn(`[${requestId}] Resend webhook missing Svix signature headers`) return new NextResponse('Unauthorized - Missing Resend signature headers', { status: 401 }) } if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) { logger.warn(`[${requestId}] Resend Svix signature verification failed`) return new NextResponse('Unauthorized - Invalid Resend signature', { status: 401 }) } return null }, matchEvent({ body, providerConfig, requestId }: EventMatchContext): boolean { const triggerId = providerConfig.triggerId as string | undefined if (!triggerId || triggerId === 'resend_webhook') { return true } const expectedType = RESEND_TRIGGER_TO_EVENT_TYPE[triggerId] if (!expectedType) { logger.debug(`[${requestId}] Unknown Resend triggerId ${triggerId}, skipping.`) return false } const actualType = (body as Record)?.type as string | undefined if (actualType !== expectedType) { logger.debug( `[${requestId}] Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.` ) return false } return true }, async formatInput({ body }: FormatInputContext): Promise { const payload = body as Record const data = payload.data as Record | undefined const bounce = data?.bounce as Record | undefined const click = data?.click as Record | undefined const dataCreatedAt = data?.created_at const dataCreatedAtStr = typeof dataCreatedAt === 'string' ? dataCreatedAt : dataCreatedAt != null ? String(dataCreatedAt) : null return { input: { type: payload.type, created_at: payload.created_at, data_created_at: dataCreatedAtStr, data: data ?? null, email_id: data?.email_id ?? null, broadcast_id: data?.broadcast_id ?? null, template_id: data?.template_id ?? null, tags: data?.tags ?? null, from: data?.from ?? null, to: data?.to ?? null, subject: data?.subject ?? null, bounceType: bounce?.type ?? null, bounceSubType: bounce?.subType ?? null, bounceMessage: bounce?.message ?? null, clickIpAddress: click?.ipAddress ?? null, clickLink: click?.link ?? null, clickTimestamp: click?.timestamp ?? null, clickUserAgent: click?.userAgent ?? null, }, } }, async createSubscription(ctx: SubscriptionContext): Promise { const { webhook, requestId } = ctx try { const providerConfig = getProviderConfig(webhook) const apiKey = providerConfig.apiKey as string | undefined const triggerId = providerConfig.triggerId as string | undefined if (!apiKey) { logger.warn(`[${requestId}] Missing apiKey for Resend webhook creation.`, { webhookId: webhook.id, }) throw new Error( 'Resend API Key is required. Please provide your Resend API Key in the trigger configuration.' ) } const events = triggerId === 'resend_webhook' ? RESEND_ALL_WEBHOOK_EVENT_TYPES : triggerId && RESEND_TRIGGER_TO_EVENT_TYPE[triggerId] ? [RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]] : null if (!events?.length) { throw new Error(`Unknown or unsupported Resend trigger type: ${triggerId ?? '(missing)'}`) } const notificationUrl = getNotificationUrl(webhook) logger.info(`[${requestId}] Creating Resend webhook`, { triggerId, events, webhookId: webhook.id, }) const resendResponse = await fetch('https://api.resend.com/webhooks', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ endpoint: notificationUrl, events, }), }) const responseBody = (await resendResponse.json()) as Record if (!resendResponse.ok) { const errorMessage = (responseBody.message as string) || (responseBody.name as string) || 'Unknown Resend API error' logger.error( `[${requestId}] Failed to create webhook in Resend for webhook ${webhook.id}. Status: ${resendResponse.status}`, { message: errorMessage, response: responseBody } ) let userFriendlyMessage = 'Failed to create webhook subscription in Resend' if (resendResponse.status === 401 || resendResponse.status === 403) { userFriendlyMessage = 'Invalid Resend API Key. Please verify your API Key is correct.' } else if (errorMessage && errorMessage !== 'Unknown Resend API error') { userFriendlyMessage = `Resend error: ${errorMessage}` } throw new Error(userFriendlyMessage) } const externalId = responseBody.id const signingSecretOut = responseBody.signing_secret if (typeof externalId !== 'string' || !externalId.trim()) { throw new Error( 'Resend webhook was created but the API response did not include a webhook id.' ) } if (typeof signingSecretOut !== 'string' || !signingSecretOut.trim()) { throw new Error( 'Resend webhook was created but the API response did not include a signing secret.' ) } logger.info( `[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`, { resendWebhookId: externalId, } ) return { providerConfigUpdates: { externalId, signingSecret: signingSecretOut, }, } } catch (error: unknown) { const err = error as Error logger.error( `[${requestId}] Exception during Resend webhook creation for webhook ${webhook.id}.`, { message: err.message, stack: err.stack, } ) throw error } }, async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { const { webhook, requestId } = ctx try { const config = getProviderConfig(webhook) const apiKey = config.apiKey as string | undefined const externalId = config.externalId as string | undefined if (!apiKey || !externalId) { logger.warn( `[${requestId}] Missing apiKey or externalId for Resend webhook deletion ${webhook.id}, skipping cleanup` ) if (ctx.strict) throw new Error('Missing Resend webhook deletion credentials') return } const resendResponse = await fetch(`https://api.resend.com/webhooks/${externalId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, }, }) if (!resendResponse.ok && resendResponse.status !== 404) { const responseBody = await resendResponse.json().catch(() => ({})) logger.warn( `[${requestId}] Failed to delete Resend webhook (non-fatal): ${resendResponse.status}`, { response: responseBody } ) if (ctx.strict) throw new Error(`Failed to delete Resend webhook: ${resendResponse.status}`) } else { logger.info(`[${requestId}] Successfully deleted Resend webhook ${externalId}`) } } catch (error) { logger.warn(`[${requestId}] Error deleting Resend webhook (non-fatal)`, error) if (ctx.strict) throw error } }, }