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': 'GET, 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 * Append an email opt-out to R2 unsubscribes.json. 53 * Used by both the GET (one-click link) and POST (HMAC) handlers. 54 */ 55 function escapeHtml(s) { 56 return String(s).replace(/[&<>"]/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[m])); 57 } 58 59 async function appendOptOut(env, email, source) { 60 let unsubscribes = []; 61 try { 62 const existing = await env.UNSUBSCRIBE_BUCKET.get('unsubscribes.json'); 63 if (existing) { 64 const text = await existing.text(); 65 unsubscribes = JSON.parse(text); 66 } 67 } catch (err) { 68 // "start fresh" for missing file; log real errors 69 if (!String(err).includes('NotFound') && !String(err).includes('not found')) { 70 console.error('Failed to read unsubscribes.json:', err); 71 } 72 } 73 74 const alreadyPresent = unsubscribes.some(u => u.email === email); 75 if (!alreadyPresent) { 76 unsubscribes.push({ email, source, timestamp: new Date().toISOString() }); 77 await env.UNSUBSCRIBE_BUCKET.put( 78 'unsubscribes.json', 79 JSON.stringify(unsubscribes, null, 2), 80 { httpMetadata: { contentType: 'application/json' } } 81 ); 82 } 83 } 84 85 /** 86 * Handle GET ?email= one-click unsubscribe links. 87 * Used in 2Step outreach emails (List-Unsubscribe header and footer link). 88 * No token required — email is the only identifier needed. 89 */ 90 async function handleGetUnsubscribe(request, env) { 91 const url = new URL(request.url); 92 const email = url.searchParams.get('email') || ''; 93 94 if (!email || !email.includes('@')) { 95 return new Response('Invalid email address', { status: 400, headers: corsHeaders }); 96 } 97 98 try { 99 await appendOptOut(env, email.toLowerCase(), 'get_link'); 100 return new Response( 101 `<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:60px">` + 102 `<h2>You've been unsubscribed</h2>` + 103 `<p>${escapeHtml(email)} will no longer receive emails from Audit&Fix.</p>` + 104 `</body></html>`, 105 { status: 200, headers: { 'Content-Type': 'text/html', ...corsHeaders } } 106 ); 107 } catch (err) { 108 return new Response('Failed to unsubscribe. Please try again.', { status: 500, headers: corsHeaders }); 109 } 110 } 111 112 /** 113 * Handle POST request to unsubscribe 114 */ 115 async function handleUnsubscribe(request, env) { 116 try { 117 const body = await request.json(); 118 const { id, token } = body; 119 120 // Validate inputs 121 if (!id || !token) { 122 return new Response( 123 JSON.stringify({ 124 success: false, 125 error: 'Missing required parameters: id and token', 126 }), 127 { 128 status: 400, 129 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 130 } 131 ); 132 } 133 134 const outreachId = parseInt(id, 10); 135 if (isNaN(outreachId)) { 136 return new Response( 137 JSON.stringify({ 138 success: false, 139 error: 'Invalid outreach ID', 140 }), 141 { 142 status: 400, 143 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 144 } 145 ); 146 } 147 148 // Validate token 149 const secret = env.UNSUBSCRIBE_SECRET; 150 if (!secret) { 151 console.error('UNSUBSCRIBE_SECRET not configured'); 152 return new Response( 153 JSON.stringify({ 154 success: false, 155 error: 'Server configuration error', 156 }), 157 { 158 status: 500, 159 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 160 } 161 ); 162 } 163 164 const isValid = await validateToken(outreachId, token, secret); 165 if (!isValid) { 166 return new Response( 167 JSON.stringify({ 168 success: false, 169 error: 'Invalid or expired unsubscribe token', 170 }), 171 { 172 status: 403, 173 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 174 } 175 ); 176 } 177 178 // Read existing unsubscribes from R2 179 let unsubscribes = []; 180 try { 181 const existingFile = await env.UNSUBSCRIBE_BUCKET.get('unsubscribes.json'); 182 if (existingFile) { 183 const text = await existingFile.text(); 184 unsubscribes = JSON.parse(text); 185 } 186 } catch (err) { 187 console.error('Error reading existing unsubscribes:', err); 188 // Continue with empty array 189 } 190 191 // Check if already unsubscribed 192 const alreadyUnsubscribed = unsubscribes.some(u => u.outreachId === outreachId); 193 if (!alreadyUnsubscribed) { 194 // Append new unsubscribe 195 unsubscribes.push({ 196 outreachId, 197 timestamp: new Date().toISOString(), 198 userAgent: request.headers.get('User-Agent') || 'unknown', 199 ip: request.headers.get('CF-Connecting-IP') || 'unknown', 200 }); 201 202 // Write back to R2 203 await env.UNSUBSCRIBE_BUCKET.put('unsubscribes.json', JSON.stringify(unsubscribes, null, 2), { 204 httpMetadata: { 205 contentType: 'application/json', 206 }, 207 }); 208 } 209 210 return new Response( 211 JSON.stringify({ 212 success: true, 213 message: 'Successfully unsubscribed', 214 }), 215 { 216 status: 200, 217 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 218 } 219 ); 220 } catch (error) { 221 console.error('Error processing unsubscribe:', error); 222 return new Response( 223 JSON.stringify({ 224 success: false, 225 error: 'Internal server error', 226 }), 227 { 228 status: 500, 229 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 230 } 231 ); 232 } 233 } 234 235 /** 236 * Main worker entry point 237 */ 238 export default { 239 async fetch(request, env) { 240 const url = new URL(request.url); 241 242 // Handle CORS preflight 243 if (request.method === 'OPTIONS') { 244 return new Response(null, { 245 headers: corsHeaders, 246 }); 247 } 248 249 // Handle POST to unsubscribe 250 if (request.method === 'POST') { 251 return handleUnsubscribe(request, env); 252 } 253 254 // Handle GET ?email= one-click unsubscribe (from 2Step email footer links) 255 if (request.method === 'GET' && url.searchParams.has('email')) { 256 return handleGetUnsubscribe(request, env); 257 } 258 259 // Handle GET to view unsubscribes (for debugging/polling) 260 if (request.method === 'GET' && url.pathname === '/unsubscribes.json') { 261 try { 262 const file = await env.UNSUBSCRIBE_BUCKET.get('unsubscribes.json'); 263 if (file) { 264 return new Response(file.body, { 265 headers: { 266 'Content-Type': 'application/json', 267 ...corsHeaders, 268 }, 269 }); 270 } 271 return new Response('[]', { 272 headers: { 273 'Content-Type': 'application/json', 274 ...corsHeaders, 275 }, 276 }); 277 } catch (err) { 278 return new Response(JSON.stringify({ error: 'Failed to fetch unsubscribes' }), { 279 status: 500, 280 headers: { 'Content-Type': 'application/json', ...corsHeaders }, 281 }); 282 } 283 } 284 285 // Default response 286 return new Response('Unsubscribe Worker - Use POST to unsubscribe', { 287 status: 200, 288 headers: corsHeaders, 289 }); 290 }, 291 };