index.js
1 /** 2 * Cloudflare Worker: Unsubscribe Handler 3 * 4 * This worker handles email unsubscribe requests from the static HTML page. 5 * It validates HMAC tokens and appends unsubscribes to a JSON file in R2. 6 * 7 * Required Environment Variables: 8 * - UNSUBSCRIBE_SECRET: Secret key for HMAC validation (must match local .env) 9 * 10 * Required R2 Binding: 11 * - UNSUBSCRIBE_BUCKET: R2 bucket for storing unsubscribes.json 12 */ 13 14 // CORS headers for static site requests 15 const corsHeaders = { 16 'Access-Control-Allow-Origin': '*', // Change to your domain in production 17 'Access-Control-Allow-Methods': 'POST, OPTIONS', 18 'Access-Control-Allow-Headers': 'Content-Type', 19 }; 20 21 /** 22 * Validate HMAC token (matches logic in src/outreach/email.js) 23 */ 24 async function validateToken(outreachId, token, secret) { 25 if (!token) return false; 26 27 const encoder = new TextEncoder(); 28 const data = encoder.encode(String(outreachId)); 29 const key = await crypto.subtle.importKey( 30 'raw', 31 encoder.encode(secret), 32 { name: 'HMAC', hash: 'SHA-256' }, 33 false, 34 ['sign'] 35 ); 36 37 const signature = await crypto.subtle.sign('HMAC', key, data); 38 const expected = Array.from(new Uint8Array(signature)) 39 .map(b => b.toString(16).padStart(2, '0')) 40 .join('') 41 .substring(0, 16); 42 43 // Timing-safe comparison 44 try { 45 return token === expected; 46 } catch { 47 return false; 48 } 49 } 50 51 /** 52 * Handle POST request to unsubscribe 53 */ 54 async function handleUnsubscribe(request, env) { 55 try { 56 const body = await request.json(); 57 const { id, token } = body; 58 59 // Validate inputs 60 if (!id || !token) { 61 return new Response( 62 JSON.stringify({ 63 success: false, 64 error: 'Missing required parameters: id and token', 65 }), 66 { 67 status: 400, 68 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 69 } 70 ); 71 } 72 73 const outreachId = parseInt(id, 10); 74 if (isNaN(outreachId)) { 75 return new Response( 76 JSON.stringify({ 77 success: false, 78 error: 'Invalid outreach ID', 79 }), 80 { 81 status: 400, 82 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 83 } 84 ); 85 } 86 87 // Validate token 88 const secret = env.UNSUBSCRIBE_SECRET; 89 if (!secret) { 90 console.error('UNSUBSCRIBE_SECRET not configured'); 91 return new Response( 92 JSON.stringify({ 93 success: false, 94 error: 'Server configuration error', 95 }), 96 { 97 status: 500, 98 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 99 } 100 ); 101 } 102 103 const isValid = await validateToken(outreachId, token, secret); 104 if (!isValid) { 105 return new Response( 106 JSON.stringify({ 107 success: false, 108 error: 'Invalid or expired unsubscribe token', 109 }), 110 { 111 status: 403, 112 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 113 } 114 ); 115 } 116 117 // Read existing unsubscribes from R2 118 let unsubscribes = []; 119 try { 120 const existingFile = await env.UNSUBSCRIBE_BUCKET.get('unsubscribes.json'); 121 if (existingFile) { 122 const text = await existingFile.text(); 123 unsubscribes = JSON.parse(text); 124 } 125 } catch (err) { 126 console.error('Error reading existing unsubscribes:', err); 127 // Continue with empty array 128 } 129 130 // Check if already unsubscribed 131 const alreadyUnsubscribed = unsubscribes.some(u => u.outreachId === outreachId); 132 if (!alreadyUnsubscribed) { 133 // Append new unsubscribe 134 unsubscribes.push({ 135 outreachId, 136 timestamp: new Date().toISOString(), 137 userAgent: request.headers.get('User-Agent') || 'unknown', 138 ip: request.headers.get('CF-Connecting-IP') || 'unknown', 139 }); 140 141 // Write back to R2 142 await env.UNSUBSCRIBE_BUCKET.put('unsubscribes.json', JSON.stringify(unsubscribes, null, 2), { 143 httpMetadata: { 144 contentType: 'application/json', 145 }, 146 }); 147 } 148 149 return new Response( 150 JSON.stringify({ 151 success: true, 152 message: 'Successfully unsubscribed', 153 }), 154 { 155 status: 200, 156 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 157 } 158 ); 159 } catch (error) { 160 console.error('Error processing unsubscribe:', error); 161 return new Response( 162 JSON.stringify({ 163 success: false, 164 error: 'Internal server error', 165 }), 166 { 167 status: 500, 168 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 169 } 170 ); 171 } 172 } 173 174 /** 175 * Main worker entry point 176 */ 177 export default { 178 async fetch(request, env) { 179 const url = new URL(request.url); 180 181 // Handle CORS preflight 182 if (request.method === 'OPTIONS') { 183 return new Response(null, { 184 headers: corsHeaders, 185 }); 186 } 187 188 // Handle POST to unsubscribe 189 if (request.method === 'POST') { 190 return handleUnsubscribe(request, env); 191 } 192 193 // Handle GET to view unsubscribes (for debugging/polling) 194 if (request.method === 'GET' && url.pathname === '/unsubscribes.json') { 195 try { 196 const file = await env.UNSUBSCRIBE_BUCKET.get('unsubscribes.json'); 197 if (file) { 198 return new Response(file.body, { 199 headers: { 200 'Content-Type': 'application/json', 201 ...corsHeaders, 202 }, 203 }); 204 } 205 return new Response('[]', { 206 headers: { 207 'Content-Type': 'application/json', 208 ...corsHeaders, 209 }, 210 }); 211 } catch (err) { 212 return new Response(JSON.stringify({ error: 'Failed to fetch unsubscribes' }), { 213 status: 500, 214 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 215 }); 216 } 217 } 218 219 // Default response 220 return new Response('Unsubscribe Worker - Use POST to unsubscribe', { 221 status: 200, 222 headers: corsHeaders, 223 }); 224 }, 225 };