/ workers / resend-webhook / src / index.js
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  };