/ 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': '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  };