/ workers / unsubscribe / src / index.js
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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&amp;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  };