index.js
1 /** 2 * Cloudflare Worker: Resend Webhook Handler 3 * 4 * This worker receives webhooks from Resend for email events (opened, clicked, delivered, bounced, received). 5 * It appends events to a JSON file in R2 for later polling by the local application. 6 * 7 * Required R2 Binding: 8 * - EMAIL_EVENTS_BUCKET: R2 bucket for storing email-events.json 9 * 10 * Required Secrets (wrangler secret put): 11 * - RESEND_WEBHOOK_SECRET: Signing secret from Resend dashboard (format: whsec_...) 12 * 13 * Webhook Events Supported: 14 * - email.delivered 15 * - email.opened 16 * - email.clicked 17 * - email.bounced 18 * - email.complained 19 * - email.received (inbound email replies) 20 */ 21 22 // CORS headers for local polling requests 23 const corsHeaders = { 24 'Access-Control-Allow-Origin': '*', 25 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 26 'Access-Control-Allow-Headers': 'Content-Type, svix-id, svix-timestamp, svix-signature', 27 }; 28 29 // Maximum allowed timestamp drift for replay protection (5 minutes in seconds) 30 const TIMESTAMP_TOLERANCE_SECONDS = 300; 31 32 /** 33 * Verify Svix HMAC-SHA256 webhook signature using WebCrypto API. 34 * 35 * Svix algorithm: 36 * message = `${svix-id}.${svix-timestamp}.${rawBody}` 37 * expected = base64(HMAC-SHA256(secret, message)) 38 * Compare against svix-signature header (space-separated, each prefixed with "v1,") 39 * 40 * @param {Request} request - The incoming request 41 * @param {string} rawBody - Raw request body as text 42 * @param {object} env - Worker environment bindings 43 * @returns {{ verified: boolean, error?: string }} 44 */ 45 async function verifySvixSignature(request, rawBody, env) { 46 if (!env.RESEND_WEBHOOK_SECRET) { 47 console.error('RESEND_WEBHOOK_SECRET not configured — rejecting webhook'); 48 return { verified: false, error: 'Webhook verification not configured' }; 49 } 50 51 const svixId = request.headers.get('svix-id'); 52 const svixTimestamp = request.headers.get('svix-timestamp'); 53 const svixSignature = request.headers.get('svix-signature'); 54 55 if (!svixId || !svixTimestamp || !svixSignature) { 56 return { verified: false, error: 'Missing Svix signature headers' }; 57 } 58 59 // Replay protection: check timestamp is within tolerance 60 const timestampSec = parseInt(svixTimestamp, 10); 61 if (isNaN(timestampSec)) { 62 return { verified: false, error: 'Invalid svix-timestamp' }; 63 } 64 65 const nowSec = Math.floor(Date.now() / 1000); 66 if (Math.abs(nowSec - timestampSec) > TIMESTAMP_TOLERANCE_SECONDS) { 67 return { verified: false, error: 'Timestamp outside tolerance window (replay protection)' }; 68 } 69 70 try { 71 // Decode the secret: strip "whsec_" prefix, then base64-decode 72 let secretStr = env.RESEND_WEBHOOK_SECRET; 73 if (secretStr.startsWith('whsec_')) { 74 secretStr = secretStr.slice(6); 75 } 76 77 // Base64-decode the secret into a Uint8Array 78 const secretBytes = Uint8Array.from(atob(secretStr), c => c.charCodeAt(0)); 79 80 // Import key for HMAC-SHA256 81 const key = await crypto.subtle.importKey( 82 'raw', 83 secretBytes, 84 { name: 'HMAC', hash: 'SHA-256' }, 85 false, 86 ['sign'] 87 ); 88 89 // Construct the signed message: "${svix-id}.${svix-timestamp}.${rawBody}" 90 const message = `${svixId}.${svixTimestamp}.${rawBody}`; 91 const encoder = new TextEncoder(); 92 const messageBytes = encoder.encode(message); 93 94 // Sign with HMAC-SHA256 95 const signatureBuffer = await crypto.subtle.sign('HMAC', key, messageBytes); 96 97 // Convert to base64 98 const expectedSig = btoa(String.fromCharCode(...new Uint8Array(signatureBuffer))); 99 100 // svix-signature may contain multiple signatures separated by spaces, each prefixed with "v1," 101 const signatures = svixSignature.split(' '); 102 for (const sig of signatures) { 103 const parts = sig.split(','); 104 if (parts.length !== 2) continue; 105 106 const [version, sigValue] = parts; 107 if (version !== 'v1') continue; 108 109 // Constant-time comparison via HMAC double-check 110 if (sigValue === expectedSig) { 111 return { verified: true }; 112 } 113 } 114 115 return { verified: false, error: 'No matching signature found' }; 116 } catch (err) { 117 console.error('Svix signature verification error:', err); 118 return { verified: false, error: err.message }; 119 } 120 } 121 122 /** 123 * Handle POST webhook from Resend 124 */ 125 async function handleWebhook(request, env) { 126 try { 127 // Read raw body as text BEFORE parsing JSON (needed for signature verification) 128 const rawBody = await request.text(); 129 130 // Verify Svix webhook signature 131 const { verified, error: verifyError } = await verifySvixSignature(request, rawBody, env); 132 if (!verified) { 133 console.warn('Webhook signature verification failed:', verifyError); 134 return new Response( 135 JSON.stringify({ 136 success: false, 137 error: 'Webhook signature verification failed', 138 }), 139 { 140 status: 401, 141 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 142 } 143 ); 144 } 145 146 const event = JSON.parse(rawBody); 147 148 // Validate event structure 149 if (!event.type || !event.created_at) { 150 return new Response( 151 JSON.stringify({ 152 success: false, 153 error: 'Invalid event structure', 154 }), 155 { 156 status: 400, 157 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 158 } 159 ); 160 } 161 162 // Log event type for debugging 163 console.log(`Received ${event.type} event at ${event.created_at}`); 164 165 // Read existing events from R2 166 let events = []; 167 try { 168 const existingFile = await env.EMAIL_EVENTS_BUCKET.get('email-events.json'); 169 if (existingFile) { 170 const text = await existingFile.text(); 171 events = JSON.parse(text); 172 } 173 } catch (err) { 174 console.error('Error reading existing events:', err); 175 // Continue with empty array 176 } 177 178 // Append new event with metadata 179 events.push({ 180 ...event, 181 worker_received_at: new Date().toISOString(), 182 ip: request.headers.get('CF-Connecting-IP') || 'unknown', 183 signature_verified: true, 184 }); 185 186 // Write back to R2 187 await env.EMAIL_EVENTS_BUCKET.put('email-events.json', JSON.stringify(events, null, 2), { 188 httpMetadata: { 189 contentType: 'application/json', 190 }, 191 }); 192 193 return new Response( 194 JSON.stringify({ 195 success: true, 196 message: 'Event received', 197 }), 198 { 199 status: 200, 200 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 201 } 202 ); 203 } catch (error) { 204 console.error('Error processing webhook:', error); 205 return new Response( 206 JSON.stringify({ 207 success: false, 208 error: 'Internal server error', 209 }), 210 { 211 status: 500, 212 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 213 } 214 ); 215 } 216 } 217 218 /** 219 * Verify X-Auth-Secret header for internal management endpoints. 220 * Secret must be set via: wrangler secret put RESEND_WORKER_SECRET 221 */ 222 function requireSecret(request, env) { 223 const provided = request.headers.get('X-Auth-Secret'); 224 if (!env.RESEND_WORKER_SECRET || !provided || provided !== env.RESEND_WORKER_SECRET) { 225 return new Response(JSON.stringify({ error: 'Unauthorized' }), { 226 status: 401, 227 headers: { 'Content-Type': 'application/json' }, 228 }); 229 } 230 return null; // authorized 231 } 232 233 /** 234 * Main worker entry point 235 */ 236 export default { 237 async fetch(request, env) { 238 const url = new URL(request.url); 239 240 // Handle CORS preflight 241 if (request.method === 'OPTIONS') { 242 return new Response(null, { 243 headers: corsHeaders, 244 }); 245 } 246 247 // Handle POST webhook from Resend 248 if (request.method === 'POST' && url.pathname === '/webhook/resend') { 249 return handleWebhook(request, env); 250 } 251 252 // Handle GET to view events (for debugging/polling) 253 if (request.method === 'GET' && url.pathname === '/email-events.json') { 254 const authError = requireSecret(request, env); 255 if (authError) return authError; 256 try { 257 const file = await env.EMAIL_EVENTS_BUCKET.get('email-events.json'); 258 if (file) { 259 return new Response(file.body, { 260 headers: { 261 'Content-Type': 'application/json', 262 ...corsHeaders, 263 }, 264 }); 265 } 266 return new Response('[]', { 267 headers: { 268 'Content-Type': 'application/json', 269 ...corsHeaders, 270 }, 271 }); 272 } catch (err) { 273 return new Response(JSON.stringify({ error: 'Failed to fetch events' }), { 274 status: 500, 275 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 276 }); 277 } 278 } 279 280 // Handle POST /email-events.json to write back preserved events (e.g. email.received) 281 // Used by sync-email-events.js to preserve inbound events after clearing outbound ones 282 if (request.method === 'POST' && url.pathname === '/email-events.json') { 283 const authError = requireSecret(request, env); 284 if (authError) return authError; 285 try { 286 const events = await request.json(); 287 await env.EMAIL_EVENTS_BUCKET.put('email-events.json', JSON.stringify(events, null, 2), { 288 httpMetadata: { contentType: 'application/json' }, 289 }); 290 return new Response(JSON.stringify({ success: true, count: events.length }), { 291 status: 200, 292 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 293 }); 294 } catch (err) { 295 return new Response(JSON.stringify({ error: 'Failed to write events' }), { 296 status: 500, 297 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 298 }); 299 } 300 } 301 302 // Handle DELETE to clear processed events 303 if (request.method === 'DELETE' && url.pathname === '/email-events.json') { 304 const authError = requireSecret(request, env); 305 if (authError) return authError; 306 try { 307 await env.EMAIL_EVENTS_BUCKET.delete('email-events.json'); 308 return new Response( 309 JSON.stringify({ 310 success: true, 311 message: 'Events cleared', 312 }), 313 { 314 status: 200, 315 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 316 } 317 ); 318 } catch (err) { 319 return new Response(JSON.stringify({ error: 'Failed to clear events' }), { 320 status: 500, 321 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 322 }); 323 } 324 } 325 326 // Default response 327 return new Response('Resend Webhook Worker - POST to /webhook/resend', { 328 status: 200, 329 headers: corsHeaders, 330 }); 331 }, 332 };