import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; async function md5Lower(s: string) { const data = new TextEncoder().encode(s.toLowerCase()); // Mailchimp requires MD5 of lowercase email as the subscriber hash const hash = await crypto.subtle.digest('MD5', data).catch(() => null); if (hash) { return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); } // Fallback: use a JS MD5 implementation return jsMd5(s.toLowerCase()); } // Minimal JS MD5 fallback (Deno's WebCrypto may not support MD5 in some runtimes) function jsMd5(str: string): string { function rotateLeft(x: number, n: number) { return (x << n) | (x >>> (32 - n)); } function addUnsigned(x: number, y: number) { const x4 = (x & 0x40000000); const y4 = (y & 0x40000000); const x8 = (x & 0x80000000); const y8 = (y & 0x80000000); const result = (x & 0x3FFFFFFF) + (y & 0x3FFFFFFF); if (x4 & y4) return (result ^ 0x80000000 ^ x8 ^ y8); if (x4 | y4) { if (result & 0x40000000) return (result ^ 0xC0000000 ^ x8 ^ y8); else return (result ^ 0x40000000 ^ x8 ^ y8); } else return (result ^ x8 ^ y8); } function f(x:number,y:number,z:number){return (x&y)|((~x)&z);} function g(x:number,y:number,z:number){return (x&z)|(y&(~z));} function h(x:number,y:number,z:number){return x^y^z;} function i(x:number,y:number,z:number){return y^(x|(~z));} function ff(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(f(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);} function gg(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(g(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);} function hh(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(h(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);} function ii(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(i(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);} function convertToWordArray(str:string){ let lWordCount; const lMessageLength=str.length; const lNumberOfWordsTempOne=lMessageLength+8; const lNumberOfWordsTempTwo=(lNumberOfWordsTempOne-(lNumberOfWordsTempOne%64))/64; const lNumberOfWords=(lNumberOfWordsTempTwo+1)*16; const lWordArray=new Array(lNumberOfWords-1).fill(0); let lBytePosition=0; let lByteCount=0; while(lByteCount>>29; return lWordArray; } function wordToHex(lValue:number){let wordToHexValue="",wordToHexValueTemp="",lByte,lCount;for(lCount=0;lCount<=3;lCount++){lByte=(lValue>>>(lCount*8))&255;wordToHexValueTemp="0"+lByte.toString(16);wordToHexValue=wordToHexValue+wordToHexValueTemp.substr(wordToHexValueTemp.length-2,2);}return wordToHexValue;} function utf8Encode(str:string){return unescape(encodeURIComponent(str));} const x=convertToWordArray(utf8Encode(str)); let a=0x67452301,b=0xEFCDAB89,c=0x98BADCFE,d=0x10325476; const S11=7,S12=12,S13=17,S14=22,S21=5,S22=9,S23=14,S24=20,S31=4,S32=11,S33=16,S34=23,S41=6,S42=10,S43=15,S44=21; for(let k=0;k { if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } try { const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader } } } ); const admin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ); const { data: { user } } = await supabase.auth.getUser(); if (!user) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } const { association_id } = await req.json(); if (!association_id) { return new Response(JSON.stringify({ error: 'Missing association_id' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } // Load config const { data: config, error: cfgErr } = await admin .from('mailchimp_configs') .select('*') .eq('association_id', association_id) .maybeSingle(); if (cfgErr || !config) { return new Response(JSON.stringify({ error: 'Mailchimp not configured for this association' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } if (!config.audience_id) { return new Response(JSON.stringify({ error: 'No audience selected' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } // Load owners const { data: owners, error: ownErr } = await admin .from('owners') .select('first_name, last_name, email, property_address, electronic_consent') .eq('association_id', association_id) .eq('status', 'active') .not('email', 'is', null); if (ownErr) { return new Response(JSON.stringify({ error: ownErr.message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } const baseUrl = `https://${config.server_prefix}.api.mailchimp.com/3.0`; const authMc = `Basic ${btoa(`anystring:${config.api_key}`)}`; // Use Mailchimp batch operations endpoint for efficient PUT (upsert) of members const operations: any[] = []; const validOwners = (owners || []).filter(o => o.email && o.email.includes('@')); for (const o of validOwners) { const hash = await md5Lower(o.email); operations.push({ method: 'PUT', path: `/lists/${config.audience_id}/members/${hash}`, body: JSON.stringify({ email_address: o.email, status_if_new: 'subscribed', merge_fields: { FNAME: o.first_name || '', LNAME: o.last_name || '', ADDRESS: o.property_address || '', }, }), }); } let succeeded = 0; let failed = 0; let lastError: string | null = null; if (operations.length === 0) { await admin.from('mailchimp_configs').update({ last_sync_at: new Date().toISOString(), last_sync_status: 'success', last_sync_count: 0, last_sync_error: null, }).eq('id', config.id); return new Response(JSON.stringify({ success: true, total: 0, succeeded: 0, failed: 0 }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } // Send in chunks of 100 sequentially via individual PUT calls (simpler than batch endpoint) for (let i = 0; i < operations.length; i++) { const op = operations[i]; try { const r = await fetch(`${baseUrl}${op.path}`, { method: op.method, headers: { Authorization: authMc, 'Content-Type': 'application/json' }, body: op.body, }); if (r.ok) { succeeded++; } else { failed++; const txt = await r.text(); lastError = txt.slice(0, 300); } } catch (e) { failed++; lastError = (e as Error).message; } } const status = failed === 0 ? 'success' : (succeeded === 0 ? 'failed' : 'partial'); await admin.from('mailchimp_configs').update({ last_sync_at: new Date().toISOString(), last_sync_status: status, last_sync_count: succeeded, last_sync_error: lastError, }).eq('id', config.id); return new Response(JSON.stringify({ success: true, total: operations.length, succeeded, failed, error: lastError, }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } catch (err) { return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } });