From 5925c87689facfd64f5ae5711a59c8a233f768d4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 10:55:36 -0700 Subject: [PATCH 1/5] feat(triggers): add Resend webhook triggers with auto-registration --- apps/sim/blocks/blocks/resend.ts | 24 +++ apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/lib/webhooks/providers/resend.ts | 185 ++++++++++++++++++ apps/sim/triggers/registry.ts | 18 ++ apps/sim/triggers/resend/email_bounced.ts | 38 ++++ apps/sim/triggers/resend/email_clicked.ts | 38 ++++ apps/sim/triggers/resend/email_complained.ts | 38 ++++ apps/sim/triggers/resend/email_delivered.ts | 38 ++++ apps/sim/triggers/resend/email_failed.ts | 38 ++++ apps/sim/triggers/resend/email_opened.ts | 38 ++++ apps/sim/triggers/resend/email_sent.ts | 41 ++++ apps/sim/triggers/resend/index.ts | 8 + apps/sim/triggers/resend/utils.ts | 187 +++++++++++++++++++ apps/sim/triggers/resend/webhook.ts | 38 ++++ 14 files changed, 731 insertions(+) create mode 100644 apps/sim/lib/webhooks/providers/resend.ts create mode 100644 apps/sim/triggers/resend/email_bounced.ts create mode 100644 apps/sim/triggers/resend/email_clicked.ts create mode 100644 apps/sim/triggers/resend/email_complained.ts create mode 100644 apps/sim/triggers/resend/email_delivered.ts create mode 100644 apps/sim/triggers/resend/email_failed.ts create mode 100644 apps/sim/triggers/resend/email_opened.ts create mode 100644 apps/sim/triggers/resend/email_sent.ts create mode 100644 apps/sim/triggers/resend/index.ts create mode 100644 apps/sim/triggers/resend/utils.ts create mode 100644 apps/sim/triggers/resend/webhook.ts diff --git a/apps/sim/blocks/blocks/resend.ts b/apps/sim/blocks/blocks/resend.ts index db3b77506a9..f4533978af0 100644 --- a/apps/sim/blocks/blocks/resend.ts +++ b/apps/sim/blocks/blocks/resend.ts @@ -1,6 +1,7 @@ import { ResendIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' export const ResendBlock: BlockConfig = { type: 'resend', @@ -16,6 +17,20 @@ export const ResendBlock: BlockConfig = { icon: ResendIcon, authMode: AuthMode.ApiKey, + triggers: { + enabled: true, + available: [ + 'resend_email_sent', + 'resend_email_delivered', + 'resend_email_bounced', + 'resend_email_complained', + 'resend_email_opened', + 'resend_email_clicked', + 'resend_email_failed', + 'resend_webhook', + ], + }, + subBlocks: [ { id: 'operation', @@ -221,6 +236,15 @@ Return ONLY the email body - no explanations, no extra text.`, condition: { field: 'operation', value: ['get_contact', 'update_contact', 'delete_contact'] }, required: true, }, + + ...getTrigger('resend_email_sent').subBlocks, + ...getTrigger('resend_email_delivered').subBlocks, + ...getTrigger('resend_email_bounced').subBlocks, + ...getTrigger('resend_email_complained').subBlocks, + ...getTrigger('resend_email_opened').subBlocks, + ...getTrigger('resend_email_clicked').subBlocks, + ...getTrigger('resend_email_failed').subBlocks, + ...getTrigger('resend_webhook').subBlocks, ], tools: { diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 00ae58a21b1..60e88706a90 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -21,6 +21,7 @@ import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' import { linearHandler } from '@/lib/webhooks/providers/linear' import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' import { outlookHandler } from '@/lib/webhooks/providers/outlook' +import { resendHandler } from '@/lib/webhooks/providers/resend' import { rssHandler } from '@/lib/webhooks/providers/rss' import { slackHandler } from '@/lib/webhooks/providers/slack' import { stripeHandler } from '@/lib/webhooks/providers/stripe' @@ -55,6 +56,7 @@ const PROVIDER_HANDLERS: Record = { jira: jiraHandler, lemlist: lemlistHandler, linear: linearHandler, + resend: resendHandler, 'microsoft-teams': microsoftTeamsHandler, outlook: outlookHandler, rss: rssHandler, diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts new file mode 100644 index 00000000000..37986796085 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -0,0 +1,185 @@ +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Resend') + +const ALL_RESEND_EVENTS = [ + 'email.sent', + 'email.delivered', + 'email.delivery_delayed', + 'email.bounced', + 'email.complained', + 'email.opened', + 'email.clicked', + 'email.failed', + 'email.received', + 'email.scheduled', + 'email.suppressed', + 'contact.created', + 'contact.updated', + 'contact.deleted', + 'domain.created', + 'domain.updated', + 'domain.deleted', +] + +export const resendHandler: WebhookProviderHandler = { + 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 + + return { + input: { + type: payload.type, + created_at: payload.created_at, + email_id: data?.email_id ?? 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 eventTypeMap: Record = { + resend_email_sent: ['email.sent'], + resend_email_delivered: ['email.delivered'], + resend_email_bounced: ['email.bounced'], + resend_email_complained: ['email.complained'], + resend_email_opened: ['email.opened'], + resend_email_clicked: ['email.clicked'], + resend_email_failed: ['email.failed'], + resend_webhook: ALL_RESEND_EVENTS, + } + + const events = eventTypeMap[triggerId ?? ''] ?? ALL_RESEND_EVENTS + 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) + } + + logger.info( + `[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`, + { + resendWebhookId: responseBody.id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody.id } } + } 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` + ) + 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 } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Resend webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Resend webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 4390bfeefff..a066b16f47d 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -162,6 +162,16 @@ import { microsoftTeamsWebhookTrigger, } from '@/triggers/microsoftteams' import { outlookPollingTrigger } from '@/triggers/outlook' +import { + resendEmailBouncedTrigger, + resendEmailClickedTrigger, + resendEmailComplainedTrigger, + resendEmailDeliveredTrigger, + resendEmailFailedTrigger, + resendEmailOpenedTrigger, + resendEmailSentTrigger, + resendWebhookTrigger, +} from '@/triggers/resend' import { rssPollingTrigger } from '@/triggers/rss' import { slackWebhookTrigger } from '@/triggers/slack' import { stripeWebhookTrigger } from '@/triggers/stripe' @@ -298,6 +308,14 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { microsoftteams_webhook: microsoftTeamsWebhookTrigger, microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger, outlook_poller: outlookPollingTrigger, + resend_email_sent: resendEmailSentTrigger, + resend_email_delivered: resendEmailDeliveredTrigger, + resend_email_bounced: resendEmailBouncedTrigger, + resend_email_complained: resendEmailComplainedTrigger, + resend_email_opened: resendEmailOpenedTrigger, + resend_email_clicked: resendEmailClickedTrigger, + resend_email_failed: resendEmailFailedTrigger, + resend_webhook: resendWebhookTrigger, rss_poller: rssPollingTrigger, stripe_webhook: stripeWebhookTrigger, telegram_webhook: telegramWebhookTrigger, diff --git a/apps/sim/triggers/resend/email_bounced.ts b/apps/sim/triggers/resend/email_bounced.ts new file mode 100644 index 00000000000..8d2c4779b5a --- /dev/null +++ b/apps/sim/triggers/resend/email_bounced.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailBouncedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Bounced Trigger + * Triggers when an email permanently bounces. + */ +export const resendEmailBouncedTrigger: TriggerConfig = { + id: 'resend_email_bounced', + name: 'Resend Email Bounced', + provider: 'resend', + description: 'Trigger workflow when an email bounces', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_bounced', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.bounced'), + extraFields: buildResendExtraFields('resend_email_bounced'), + }), + + outputs: buildEmailBouncedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_clicked.ts b/apps/sim/triggers/resend/email_clicked.ts new file mode 100644 index 00000000000..437f3c9a30b --- /dev/null +++ b/apps/sim/triggers/resend/email_clicked.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailClickedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Clicked Trigger + * Triggers when a recipient clicks a link in an email. + */ +export const resendEmailClickedTrigger: TriggerConfig = { + id: 'resend_email_clicked', + name: 'Resend Email Clicked', + provider: 'resend', + description: 'Trigger workflow when a link in an email is clicked', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_clicked', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.clicked'), + extraFields: buildResendExtraFields('resend_email_clicked'), + }), + + outputs: buildEmailClickedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_complained.ts b/apps/sim/triggers/resend/email_complained.ts new file mode 100644 index 00000000000..211ab85bd8c --- /dev/null +++ b/apps/sim/triggers/resend/email_complained.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailComplainedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Complained Trigger + * Triggers when a recipient marks an email as spam. + */ +export const resendEmailComplainedTrigger: TriggerConfig = { + id: 'resend_email_complained', + name: 'Resend Email Complained', + provider: 'resend', + description: 'Trigger workflow when an email is marked as spam', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_complained', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.complained'), + extraFields: buildResendExtraFields('resend_email_complained'), + }), + + outputs: buildEmailComplainedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_delivered.ts b/apps/sim/triggers/resend/email_delivered.ts new file mode 100644 index 00000000000..ac7a0d9e914 --- /dev/null +++ b/apps/sim/triggers/resend/email_delivered.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailDeliveredOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Delivered Trigger + * Triggers when an email is successfully delivered to the recipient's mail server. + */ +export const resendEmailDeliveredTrigger: TriggerConfig = { + id: 'resend_email_delivered', + name: 'Resend Email Delivered', + provider: 'resend', + description: 'Trigger workflow when an email is delivered', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_delivered', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.delivered'), + extraFields: buildResendExtraFields('resend_email_delivered'), + }), + + outputs: buildEmailDeliveredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_failed.ts b/apps/sim/triggers/resend/email_failed.ts new file mode 100644 index 00000000000..f1a753aece0 --- /dev/null +++ b/apps/sim/triggers/resend/email_failed.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailFailedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Failed Trigger + * Triggers when an email fails to send. + */ +export const resendEmailFailedTrigger: TriggerConfig = { + id: 'resend_email_failed', + name: 'Resend Email Failed', + provider: 'resend', + description: 'Trigger workflow when an email fails to send', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_failed', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.failed'), + extraFields: buildResendExtraFields('resend_email_failed'), + }), + + outputs: buildEmailFailedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_opened.ts b/apps/sim/triggers/resend/email_opened.ts new file mode 100644 index 00000000000..0aaee9bb7cc --- /dev/null +++ b/apps/sim/triggers/resend/email_opened.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailOpenedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Opened Trigger + * Triggers when a recipient opens an email. + */ +export const resendEmailOpenedTrigger: TriggerConfig = { + id: 'resend_email_opened', + name: 'Resend Email Opened', + provider: 'resend', + description: 'Trigger workflow when an email is opened', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_opened', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.opened'), + extraFields: buildResendExtraFields('resend_email_opened'), + }), + + outputs: buildEmailOpenedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_sent.ts b/apps/sim/triggers/resend/email_sent.ts new file mode 100644 index 00000000000..d4abd6e7adb --- /dev/null +++ b/apps/sim/triggers/resend/email_sent.ts @@ -0,0 +1,41 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailSentOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Sent Trigger + * Triggers when an email is sent by Resend. + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + */ +export const resendEmailSentTrigger: TriggerConfig = { + id: 'resend_email_sent', + name: 'Resend Email Sent', + provider: 'resend', + description: 'Trigger workflow when an email is sent', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_sent', + triggerOptions: resendTriggerOptions, + includeDropdown: true, + setupInstructions: resendSetupInstructions('email.sent'), + extraFields: buildResendExtraFields('resend_email_sent'), + }), + + outputs: buildEmailSentOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/index.ts b/apps/sim/triggers/resend/index.ts new file mode 100644 index 00000000000..86cac19449b --- /dev/null +++ b/apps/sim/triggers/resend/index.ts @@ -0,0 +1,8 @@ +export { resendEmailBouncedTrigger } from './email_bounced' +export { resendEmailClickedTrigger } from './email_clicked' +export { resendEmailComplainedTrigger } from './email_complained' +export { resendEmailDeliveredTrigger } from './email_delivered' +export { resendEmailFailedTrigger } from './email_failed' +export { resendEmailOpenedTrigger } from './email_opened' +export { resendEmailSentTrigger } from './email_sent' +export { resendWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/resend/utils.ts b/apps/sim/triggers/resend/utils.ts new file mode 100644 index 00000000000..24d925e4897 --- /dev/null +++ b/apps/sim/triggers/resend/utils.ts @@ -0,0 +1,187 @@ +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Resend triggers + */ +export const resendTriggerOptions = [ + { label: 'Email Sent', id: 'resend_email_sent' }, + { label: 'Email Delivered', id: 'resend_email_delivered' }, + { label: 'Email Bounced', id: 'resend_email_bounced' }, + { label: 'Email Complained', id: 'resend_email_complained' }, + { label: 'Email Opened', id: 'resend_email_opened' }, + { label: 'Email Clicked', id: 'resend_email_clicked' }, + { label: 'Email Failed', id: 'resend_email_failed' }, + { label: 'Generic Webhook (All Events)', id: 'resend_webhook' }, +] + +/** + * Generates setup instructions for Resend webhooks. + * The webhook is automatically created in Resend when you save. + */ +export function resendSetupInstructions(eventType: string): string { + const instructions = [ + 'Enter your Resend API Key above.', + 'You can find your API key in Resend at Settings > API Keys. See the Resend API documentation for details.', + `Click "Save Configuration" to automatically create the webhook in Resend for ${eventType} events.`, + 'The webhook will be automatically deleted when you remove this trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Helper to build Resend-specific extra fields. + * Includes API key (required). + * Use with the generic buildTriggerSubBlocks from @/triggers. + */ +export function buildResendExtraFields(triggerId: string) { + return [ + { + id: 'apiKey', + title: 'API Key', + type: 'short-input' as const, + placeholder: 'Enter your Resend API key (re_...)', + description: 'Required to create the webhook in Resend.', + password: true, + required: true, + mode: 'trigger' as const, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Common fields present in all Resend email webhook payloads + */ +const commonEmailOutputs = { + type: { + type: 'string', + description: 'Event type (e.g., email.sent, email.delivered)', + }, + created_at: { + type: 'string', + description: 'Event creation timestamp (ISO 8601)', + }, + email_id: { + type: 'string', + description: 'Unique email identifier', + }, + from: { + type: 'string', + description: 'Sender email address', + }, + subject: { + type: 'string', + description: 'Email subject line', + }, +} as const + +/** + * Recipient fields present in email webhook payloads + */ +const recipientOutputs = { + to: { + type: 'json', + description: 'Array of recipient email addresses', + }, +} as const + +/** + * Build outputs for email sent events + */ +export function buildEmailSentOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email delivered events + */ +export function buildEmailDeliveredOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email bounced events + */ +export function buildEmailBouncedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + bounceType: { type: 'string', description: 'Bounce type (e.g., Permanent)' }, + bounceSubType: { type: 'string', description: 'Bounce sub-type (e.g., Suppressed)' }, + bounceMessage: { type: 'string', description: 'Bounce error message' }, + } as Record +} + +/** + * Build outputs for email complained events + */ +export function buildEmailComplainedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email opened events + */ +export function buildEmailOpenedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email clicked events + */ +export function buildEmailClickedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + clickIpAddress: { type: 'string', description: 'IP address of the click' }, + clickLink: { type: 'string', description: 'URL that was clicked' }, + clickTimestamp: { type: 'string', description: 'Click timestamp (ISO 8601)' }, + clickUserAgent: { type: 'string', description: 'Browser user agent string' }, + } as Record +} + +/** + * Build outputs for email failed events + */ +export function buildEmailFailedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for generic webhook (all events). + * Includes all possible fields across event types. + */ +export function buildResendOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + bounceType: { type: 'string', description: 'Bounce type (e.g., Permanent)' }, + bounceSubType: { type: 'string', description: 'Bounce sub-type (e.g., Suppressed)' }, + bounceMessage: { type: 'string', description: 'Bounce error message' }, + clickIpAddress: { type: 'string', description: 'IP address of the click' }, + clickLink: { type: 'string', description: 'URL that was clicked' }, + clickTimestamp: { type: 'string', description: 'Click timestamp (ISO 8601)' }, + clickUserAgent: { type: 'string', description: 'Browser user agent string' }, + } as Record +} diff --git a/apps/sim/triggers/resend/webhook.ts b/apps/sim/triggers/resend/webhook.ts new file mode 100644 index 00000000000..e320f0be7aa --- /dev/null +++ b/apps/sim/triggers/resend/webhook.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildResendExtraFields, + buildResendOutputs, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Generic Resend Webhook Trigger + * Captures all Resend webhook events + */ +export const resendWebhookTrigger: TriggerConfig = { + id: 'resend_webhook', + name: 'Resend Webhook (All Events)', + provider: 'resend', + description: 'Trigger workflow on any Resend webhook event', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_webhook', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('All Events'), + extraFields: buildResendExtraFields('resend_webhook'), + }), + + outputs: buildResendOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} From 62c0e7e2f040f242a5b92790fa25ac0ff9d560eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:05:36 -0700 Subject: [PATCH 2/5] fix(triggers): capture Resend signing secret and add Svix webhook verification --- .../lib/webhooks/provider-subscriptions.ts | 2 + apps/sim/lib/webhooks/providers/resend.ts | 75 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 227e05753ab..0d9906e378a 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -23,6 +23,8 @@ const SYSTEM_MANAGED_FIELDS = new Set([ 'eventTypes', 'webhookTag', 'webhookSecret', + 'signingSecret', + 'secretToken', 'historyId', 'lastCheckedTimestamp', 'setupCompleted', diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index 37986796085..e9e014df222 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -1,6 +1,10 @@ +import crypto from 'node:crypto' import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + AuthContext, DeleteSubscriptionContext, FormatInputContext, FormatInputResult, @@ -31,7 +35,71 @@ const ALL_RESEND_EVENTS = [ 'domain.deleted', ] +/** + * 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 secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64') + const toSign = `${msgId}.${timestamp}.${rawBody}` + const expectedSignature = crypto + .createHmac('sha256', secretBytes) + .update(toSign, 'utf8') + .digest('base64') + + 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) { + return null + } + + 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 + }, + async formatInput({ body }: FormatInputContext): Promise { const payload = body as Record const data = payload.data as Record | undefined @@ -134,7 +202,12 @@ export const resendHandler: WebhookProviderHandler = { } ) - return { providerConfigUpdates: { externalId: responseBody.id } } + return { + providerConfigUpdates: { + externalId: responseBody.id, + signingSecret: responseBody.signing_secret, + }, + } } catch (error: unknown) { const err = error as Error logger.error( From 38506d19a7780e8e593244cb2f6bcb11f1578887 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:07:58 -0700 Subject: [PATCH 3/5] fix(triggers): add paramVisibility, event-type filtering for Resend triggers --- apps/sim/lib/webhooks/providers/resend.ts | 30 +++++++++++++++++++++++ apps/sim/triggers/resend/utils.ts | 1 + 2 files changed, 31 insertions(+) diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index e9e014df222..10ee166bc3e 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -6,6 +6,7 @@ import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/ import type { AuthContext, DeleteSubscriptionContext, + EventMatchContext, FormatInputContext, FormatInputResult, SubscriptionContext, @@ -100,6 +101,35 @@ export const resendHandler: WebhookProviderHandler = { return null }, + matchEvent({ body, providerConfig, requestId }: EventMatchContext): boolean { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'resend_webhook') { + return true + } + + const EVENT_TYPE_MAP: Record = { + resend_email_sent: 'email.sent', + resend_email_delivered: 'email.delivered', + resend_email_bounced: 'email.bounced', + resend_email_complained: 'email.complained', + resend_email_opened: 'email.opened', + resend_email_clicked: 'email.clicked', + resend_email_failed: 'email.failed', + } + + const expectedType = EVENT_TYPE_MAP[triggerId] + const actualType = (body as Record)?.type as string | undefined + + if (expectedType && 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 diff --git a/apps/sim/triggers/resend/utils.ts b/apps/sim/triggers/resend/utils.ts index 24d925e4897..3ab99c35692 100644 --- a/apps/sim/triggers/resend/utils.ts +++ b/apps/sim/triggers/resend/utils.ts @@ -48,6 +48,7 @@ export function buildResendExtraFields(triggerId: string) { placeholder: 'Enter your Resend API key (re_...)', description: 'Required to create the webhook in Resend.', password: true, + paramVisibility: 'user-only' as const, required: true, mode: 'trigger' as const, condition: { field: 'selectedTriggerId', value: triggerId }, From 2b8a9e595ceeba0fa6d6030e20cfbd981ad4d49b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:27:17 -0700 Subject: [PATCH 4/5] fix(triggers): add Svix timestamp staleness check to prevent replay attacks Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/providers/resend.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index 10ee166bc3e..8dbc4317139 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -49,6 +49,12 @@ function verifySvixSignature( rawBody: string ): boolean { try { + const ts = parseInt(timestamp, 10) + const now = Math.floor(Date.now() / 1000) + if (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 = crypto From f4365a70ebf59677cabcb135a53fc80376928173 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:31:45 -0700 Subject: [PATCH 5/5] fix(triggers): use Number.parseInt and Number.isNaN for lint compliance Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/providers/resend.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index 8dbc4317139..82d452ba8cd 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -49,9 +49,9 @@ function verifySvixSignature( rawBody: string ): boolean { try { - const ts = parseInt(timestamp, 10) + const ts = Number.parseInt(timestamp, 10) const now = Math.floor(Date.now() / 1000) - if (isNaN(ts) || Math.abs(now - ts) > 5 * 60) { + if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) { return false }