Skip to content

Commit 796384a

Browse files
waleedlatif1claude
andauthored
feat(triggers): add Resend webhook triggers with auto-registration (#3986)
* feat(triggers): add Resend webhook triggers with auto-registration * fix(triggers): capture Resend signing secret and add Svix webhook verification * fix(triggers): add paramVisibility, event-type filtering for Resend triggers * fix(triggers): add Svix timestamp staleness check to prevent replay attacks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(triggers): use Number.parseInt and Number.isNaN for lint compliance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 62a7700 commit 796384a

File tree

15 files changed

+843
-0
lines changed

15 files changed

+843
-0
lines changed

apps/sim/blocks/blocks/resend.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ResendIcon } from '@/components/icons'
22
import type { BlockConfig } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
4+
import { getTrigger } from '@/triggers'
45

56
export const ResendBlock: BlockConfig = {
67
type: 'resend',
@@ -16,6 +17,20 @@ export const ResendBlock: BlockConfig = {
1617
icon: ResendIcon,
1718
authMode: AuthMode.ApiKey,
1819

20+
triggers: {
21+
enabled: true,
22+
available: [
23+
'resend_email_sent',
24+
'resend_email_delivered',
25+
'resend_email_bounced',
26+
'resend_email_complained',
27+
'resend_email_opened',
28+
'resend_email_clicked',
29+
'resend_email_failed',
30+
'resend_webhook',
31+
],
32+
},
33+
1934
subBlocks: [
2035
{
2136
id: 'operation',
@@ -221,6 +236,15 @@ Return ONLY the email body - no explanations, no extra text.`,
221236
condition: { field: 'operation', value: ['get_contact', 'update_contact', 'delete_contact'] },
222237
required: true,
223238
},
239+
240+
...getTrigger('resend_email_sent').subBlocks,
241+
...getTrigger('resend_email_delivered').subBlocks,
242+
...getTrigger('resend_email_bounced').subBlocks,
243+
...getTrigger('resend_email_complained').subBlocks,
244+
...getTrigger('resend_email_opened').subBlocks,
245+
...getTrigger('resend_email_clicked').subBlocks,
246+
...getTrigger('resend_email_failed').subBlocks,
247+
...getTrigger('resend_webhook').subBlocks,
224248
],
225249

226250
tools: {

apps/sim/lib/webhooks/provider-subscriptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const SYSTEM_MANAGED_FIELDS = new Set([
2323
'eventTypes',
2424
'webhookTag',
2525
'webhookSecret',
26+
'signingSecret',
27+
'secretToken',
2628
'historyId',
2729
'lastCheckedTimestamp',
2830
'setupCompleted',

apps/sim/lib/webhooks/providers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { lemlistHandler } from '@/lib/webhooks/providers/lemlist'
2121
import { linearHandler } from '@/lib/webhooks/providers/linear'
2222
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
2323
import { outlookHandler } from '@/lib/webhooks/providers/outlook'
24+
import { resendHandler } from '@/lib/webhooks/providers/resend'
2425
import { rssHandler } from '@/lib/webhooks/providers/rss'
2526
import { slackHandler } from '@/lib/webhooks/providers/slack'
2627
import { stripeHandler } from '@/lib/webhooks/providers/stripe'
@@ -55,6 +56,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
5556
jira: jiraHandler,
5657
lemlist: lemlistHandler,
5758
linear: linearHandler,
59+
resend: resendHandler,
5860
'microsoft-teams': microsoftTeamsHandler,
5961
outlook: outlookHandler,
6062
rss: rssHandler,
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import crypto from 'node:crypto'
2+
import { createLogger } from '@sim/logger'
3+
import { NextResponse } from 'next/server'
4+
import { safeCompare } from '@/lib/core/security/encryption'
5+
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
6+
import type {
7+
AuthContext,
8+
DeleteSubscriptionContext,
9+
EventMatchContext,
10+
FormatInputContext,
11+
FormatInputResult,
12+
SubscriptionContext,
13+
SubscriptionResult,
14+
WebhookProviderHandler,
15+
} from '@/lib/webhooks/providers/types'
16+
17+
const logger = createLogger('WebhookProvider:Resend')
18+
19+
const ALL_RESEND_EVENTS = [
20+
'email.sent',
21+
'email.delivered',
22+
'email.delivery_delayed',
23+
'email.bounced',
24+
'email.complained',
25+
'email.opened',
26+
'email.clicked',
27+
'email.failed',
28+
'email.received',
29+
'email.scheduled',
30+
'email.suppressed',
31+
'contact.created',
32+
'contact.updated',
33+
'contact.deleted',
34+
'domain.created',
35+
'domain.updated',
36+
'domain.deleted',
37+
]
38+
39+
/**
40+
* Verify a Resend webhook signature using the Svix signing scheme.
41+
* Resend uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}`
42+
* signed with the base64-decoded `whsec_...` secret.
43+
*/
44+
function verifySvixSignature(
45+
secret: string,
46+
msgId: string,
47+
timestamp: string,
48+
signatures: string,
49+
rawBody: string
50+
): boolean {
51+
try {
52+
const ts = Number.parseInt(timestamp, 10)
53+
const now = Math.floor(Date.now() / 1000)
54+
if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) {
55+
return false
56+
}
57+
58+
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
59+
const toSign = `${msgId}.${timestamp}.${rawBody}`
60+
const expectedSignature = crypto
61+
.createHmac('sha256', secretBytes)
62+
.update(toSign, 'utf8')
63+
.digest('base64')
64+
65+
const providedSignatures = signatures.split(' ')
66+
for (const versionedSig of providedSignatures) {
67+
const parts = versionedSig.split(',')
68+
if (parts.length !== 2) continue
69+
const sig = parts[1]
70+
if (safeCompare(sig, expectedSignature)) {
71+
return true
72+
}
73+
}
74+
return false
75+
} catch (error) {
76+
logger.error('Error verifying Resend Svix signature:', error)
77+
return false
78+
}
79+
}
80+
81+
export const resendHandler: WebhookProviderHandler = {
82+
async verifyAuth({
83+
request,
84+
rawBody,
85+
requestId,
86+
providerConfig,
87+
}: AuthContext): Promise<NextResponse | null> {
88+
const signingSecret = providerConfig.signingSecret as string | undefined
89+
if (!signingSecret) {
90+
return null
91+
}
92+
93+
const svixId = request.headers.get('svix-id')
94+
const svixTimestamp = request.headers.get('svix-timestamp')
95+
const svixSignature = request.headers.get('svix-signature')
96+
97+
if (!svixId || !svixTimestamp || !svixSignature) {
98+
logger.warn(`[${requestId}] Resend webhook missing Svix signature headers`)
99+
return new NextResponse('Unauthorized - Missing Resend signature headers', { status: 401 })
100+
}
101+
102+
if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) {
103+
logger.warn(`[${requestId}] Resend Svix signature verification failed`)
104+
return new NextResponse('Unauthorized - Invalid Resend signature', { status: 401 })
105+
}
106+
107+
return null
108+
},
109+
110+
matchEvent({ body, providerConfig, requestId }: EventMatchContext): boolean {
111+
const triggerId = providerConfig.triggerId as string | undefined
112+
if (!triggerId || triggerId === 'resend_webhook') {
113+
return true
114+
}
115+
116+
const EVENT_TYPE_MAP: Record<string, string> = {
117+
resend_email_sent: 'email.sent',
118+
resend_email_delivered: 'email.delivered',
119+
resend_email_bounced: 'email.bounced',
120+
resend_email_complained: 'email.complained',
121+
resend_email_opened: 'email.opened',
122+
resend_email_clicked: 'email.clicked',
123+
resend_email_failed: 'email.failed',
124+
}
125+
126+
const expectedType = EVENT_TYPE_MAP[triggerId]
127+
const actualType = (body as Record<string, unknown>)?.type as string | undefined
128+
129+
if (expectedType && actualType !== expectedType) {
130+
logger.debug(
131+
`[${requestId}] Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.`
132+
)
133+
return false
134+
}
135+
136+
return true
137+
},
138+
139+
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
140+
const payload = body as Record<string, unknown>
141+
const data = payload.data as Record<string, unknown> | undefined
142+
const bounce = data?.bounce as Record<string, unknown> | undefined
143+
const click = data?.click as Record<string, unknown> | undefined
144+
145+
return {
146+
input: {
147+
type: payload.type,
148+
created_at: payload.created_at,
149+
email_id: data?.email_id ?? null,
150+
from: data?.from ?? null,
151+
to: data?.to ?? null,
152+
subject: data?.subject ?? null,
153+
bounceType: bounce?.type ?? null,
154+
bounceSubType: bounce?.subType ?? null,
155+
bounceMessage: bounce?.message ?? null,
156+
clickIpAddress: click?.ipAddress ?? null,
157+
clickLink: click?.link ?? null,
158+
clickTimestamp: click?.timestamp ?? null,
159+
clickUserAgent: click?.userAgent ?? null,
160+
},
161+
}
162+
},
163+
164+
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
165+
const { webhook, requestId } = ctx
166+
try {
167+
const providerConfig = getProviderConfig(webhook)
168+
const apiKey = providerConfig.apiKey as string | undefined
169+
const triggerId = providerConfig.triggerId as string | undefined
170+
171+
if (!apiKey) {
172+
logger.warn(`[${requestId}] Missing apiKey for Resend webhook creation.`, {
173+
webhookId: webhook.id,
174+
})
175+
throw new Error(
176+
'Resend API Key is required. Please provide your Resend API Key in the trigger configuration.'
177+
)
178+
}
179+
180+
const eventTypeMap: Record<string, string[]> = {
181+
resend_email_sent: ['email.sent'],
182+
resend_email_delivered: ['email.delivered'],
183+
resend_email_bounced: ['email.bounced'],
184+
resend_email_complained: ['email.complained'],
185+
resend_email_opened: ['email.opened'],
186+
resend_email_clicked: ['email.clicked'],
187+
resend_email_failed: ['email.failed'],
188+
resend_webhook: ALL_RESEND_EVENTS,
189+
}
190+
191+
const events = eventTypeMap[triggerId ?? ''] ?? ALL_RESEND_EVENTS
192+
const notificationUrl = getNotificationUrl(webhook)
193+
194+
logger.info(`[${requestId}] Creating Resend webhook`, {
195+
triggerId,
196+
events,
197+
webhookId: webhook.id,
198+
})
199+
200+
const resendResponse = await fetch('https://api.resend.com/webhooks', {
201+
method: 'POST',
202+
headers: {
203+
Authorization: `Bearer ${apiKey}`,
204+
'Content-Type': 'application/json',
205+
},
206+
body: JSON.stringify({
207+
endpoint: notificationUrl,
208+
events,
209+
}),
210+
})
211+
212+
const responseBody = (await resendResponse.json()) as Record<string, unknown>
213+
214+
if (!resendResponse.ok) {
215+
const errorMessage =
216+
(responseBody.message as string) ||
217+
(responseBody.name as string) ||
218+
'Unknown Resend API error'
219+
logger.error(
220+
`[${requestId}] Failed to create webhook in Resend for webhook ${webhook.id}. Status: ${resendResponse.status}`,
221+
{ message: errorMessage, response: responseBody }
222+
)
223+
224+
let userFriendlyMessage = 'Failed to create webhook subscription in Resend'
225+
if (resendResponse.status === 401 || resendResponse.status === 403) {
226+
userFriendlyMessage = 'Invalid Resend API Key. Please verify your API Key is correct.'
227+
} else if (errorMessage && errorMessage !== 'Unknown Resend API error') {
228+
userFriendlyMessage = `Resend error: ${errorMessage}`
229+
}
230+
231+
throw new Error(userFriendlyMessage)
232+
}
233+
234+
logger.info(
235+
`[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`,
236+
{
237+
resendWebhookId: responseBody.id,
238+
}
239+
)
240+
241+
return {
242+
providerConfigUpdates: {
243+
externalId: responseBody.id,
244+
signingSecret: responseBody.signing_secret,
245+
},
246+
}
247+
} catch (error: unknown) {
248+
const err = error as Error
249+
logger.error(
250+
`[${requestId}] Exception during Resend webhook creation for webhook ${webhook.id}.`,
251+
{
252+
message: err.message,
253+
stack: err.stack,
254+
}
255+
)
256+
throw error
257+
}
258+
},
259+
260+
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
261+
const { webhook, requestId } = ctx
262+
try {
263+
const config = getProviderConfig(webhook)
264+
const apiKey = config.apiKey as string | undefined
265+
const externalId = config.externalId as string | undefined
266+
267+
if (!apiKey || !externalId) {
268+
logger.warn(
269+
`[${requestId}] Missing apiKey or externalId for Resend webhook deletion ${webhook.id}, skipping cleanup`
270+
)
271+
return
272+
}
273+
274+
const resendResponse = await fetch(`https://api.resend.com/webhooks/${externalId}`, {
275+
method: 'DELETE',
276+
headers: {
277+
Authorization: `Bearer ${apiKey}`,
278+
},
279+
})
280+
281+
if (!resendResponse.ok && resendResponse.status !== 404) {
282+
const responseBody = await resendResponse.json().catch(() => ({}))
283+
logger.warn(
284+
`[${requestId}] Failed to delete Resend webhook (non-fatal): ${resendResponse.status}`,
285+
{ response: responseBody }
286+
)
287+
} else {
288+
logger.info(`[${requestId}] Successfully deleted Resend webhook ${externalId}`)
289+
}
290+
} catch (error) {
291+
logger.warn(`[${requestId}] Error deleting Resend webhook (non-fatal)`, error)
292+
}
293+
},
294+
}

apps/sim/triggers/registry.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,16 @@ import {
171171
microsoftTeamsWebhookTrigger,
172172
} from '@/triggers/microsoftteams'
173173
import { outlookPollingTrigger } from '@/triggers/outlook'
174+
import {
175+
resendEmailBouncedTrigger,
176+
resendEmailClickedTrigger,
177+
resendEmailComplainedTrigger,
178+
resendEmailDeliveredTrigger,
179+
resendEmailFailedTrigger,
180+
resendEmailOpenedTrigger,
181+
resendEmailSentTrigger,
182+
resendWebhookTrigger,
183+
} from '@/triggers/resend'
174184
import { rssPollingTrigger } from '@/triggers/rss'
175185
import {
176186
salesforceCaseStatusChangedTrigger,
@@ -315,6 +325,14 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
315325
microsoftteams_webhook: microsoftTeamsWebhookTrigger,
316326
microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger,
317327
outlook_poller: outlookPollingTrigger,
328+
resend_email_sent: resendEmailSentTrigger,
329+
resend_email_delivered: resendEmailDeliveredTrigger,
330+
resend_email_bounced: resendEmailBouncedTrigger,
331+
resend_email_complained: resendEmailComplainedTrigger,
332+
resend_email_opened: resendEmailOpenedTrigger,
333+
resend_email_clicked: resendEmailClickedTrigger,
334+
resend_email_failed: resendEmailFailedTrigger,
335+
resend_webhook: resendWebhookTrigger,
318336
rss_poller: rssPollingTrigger,
319337
salesforce_record_created: salesforceRecordCreatedTrigger,
320338
salesforce_record_updated: salesforceRecordUpdatedTrigger,

0 commit comments

Comments
 (0)