import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "npm:@supabase/supabase-js@2"; import nodemailer from "npm:nodemailer@6"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Client-Info, Apikey", }; function getSupabaseAdmin() { return createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); } function jsonResponse(data: Record, status = 200) { return new Response(JSON.stringify(data), { status, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } function getRecipientName(email: string): string { return email.split("@")[0]; } function personalizeSubjectText(subject: string, email: string): string { return subject + " -" + getRecipientName(email) + "-"; } function personalizeBodyText(body: string, email: string, prefix: string): string { return prefix + " " + email + "\n\n" + body; } function personalizeHtmlText(html: string, email: string, prefix: string): string { const greeting = '

' + prefix + " " + email + "

"; if (html.toLowerCase().includes("]*>)/i, "$1" + greeting); } return greeting + html; } function addUnsubscribeLink(html: string | undefined, text: string | undefined, url: string): { html?: string; text?: string } { const result: { html?: string; text?: string } = {}; if (html) { const unsubHtml = '
Unsubscribe
'; if (html.toLowerCase().includes("")) { result.html = html.replace(/(<\/body>)/i, unsubHtml + "$1"); } else { result.html = html + unsubHtml; } } if (text) { result.text = text + "\n\n---\nUnsubscribe: " + url; } return result; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } interface SmtpConfig { host: string; port: string | number; user: string; pass: string; senderName?: string; senderEmail?: string; } interface PersonalizeConfig { subject?: boolean; body?: boolean; greetingPrefix?: string; randomDelay?: boolean; unsubscribe?: boolean; unsubscribeUrl?: string; } interface EnqueuePayload { recipients: string[]; subject: string; body?: string; html?: string; sendType?: string; smtpConfig: SmtpConfig; clientToken: string; batchSize?: number; personalize?: PersonalizeConfig; } interface ProcessPayload { jobId: string; clientToken: string; } interface StatusPayload { jobId: string; clientToken: string; } interface CancelPayload { jobId: string; clientToken: string; } async function handleEnqueue(data: EnqueuePayload) { const { recipients, subject, body, html, sendType, smtpConfig, clientToken, batchSize = 50, personalize, } = data; if ( !smtpConfig || !smtpConfig.host || !smtpConfig.port || !smtpConfig.user || !smtpConfig.pass ) { return jsonResponse( { success: false, error: "SMTP settings missing" }, 400 ); } if (!recipients || !recipients.length || !subject) { return jsonResponse( { success: false, error: "Missing recipients or subject" }, 400 ); } if (!clientToken) { return jsonResponse( { success: false, error: "Missing client token" }, 400 ); } const supabase = getSupabaseAdmin(); const jobId = crypto.randomUUID(); const batches: string[][] = []; for (let i = 0; i < recipients.length; i += batchSize) { batches.push(recipients.slice(i, i + batchSize)); } const rows = batches.map((batch, index) => ({ job_id: jobId, client_token: clientToken, recipients: batch, subject, body: body || null, html: html || null, send_type: sendType || "bcc", smtp_config: smtpConfig, personalize: personalize || {}, status: "pending", batch_index: index, total_batches: batches.length, })); const { error } = await supabase.from("email_queue").insert(rows); if (error) { return jsonResponse({ success: false, error: error.message }, 500); } return jsonResponse({ success: true, jobId, totalBatches: batches.length, totalRecipients: recipients.length, batchSize, }); } async function handleProcess(data: ProcessPayload) { const { jobId, clientToken } = data; if (!jobId || !clientToken) { return jsonResponse( { success: false, error: "Missing jobId or clientToken" }, 400 ); } const supabase = getSupabaseAdmin(); const { data: batch, error: fetchError } = await supabase .from("email_queue") .select("*") .eq("job_id", jobId) .eq("client_token", clientToken) .eq("status", "pending") .order("batch_index", { ascending: true }) .limit(1) .maybeSingle(); if (fetchError) { return jsonResponse({ success: false, error: fetchError.message }, 500); } if (!batch) { const { data: allBatches } = await supabase .from("email_queue") .select("status") .eq("job_id", jobId) .eq("client_token", clientToken); const completed = allBatches?.filter((b) => b.status === "completed").length || 0; const failed = allBatches?.filter((b) => b.status === "failed").length || 0; const total = allBatches?.length || 0; await supabase .from("email_queue") .update({ smtp_config: {} }) .eq("job_id", jobId) .eq("client_token", clientToken) .in("status", ["completed", "failed"]); return jsonResponse({ success: true, done: true, completed, failed, total, }); } await supabase .from("email_queue") .update({ status: "processing", updated_at: new Date().toISOString() }) .eq("id", batch.id); try { const smtp: SmtpConfig = batch.smtp_config; const transporter = nodemailer.createTransport({ host: smtp.host, port: Number(smtp.port), secure: Number(smtp.port) === 465, auth: { user: smtp.user, pass: smtp.pass }, }); const senderName = smtp.senderName || smtp.user; const senderEmail = smtp.senderEmail || smtp.user; const sender = `"${senderName}" <${senderEmail}>`; const p: PersonalizeConfig | undefined = batch.personalize && Object.keys(batch.personalize).length > 0 ? batch.personalize : undefined; const needsIndividual = p && (p.subject || p.body || p.unsubscribe); if (needsIndividual) { let sentCount = 0; const errors: string[] = []; for (let i = 0; i < batch.recipients.length; i++) { const email = batch.recipients[i]; let mailSubject = batch.subject; let mailText = batch.body; let mailHtml = batch.html; if (p.subject) { mailSubject = personalizeSubjectText(mailSubject, email); } if (p.body && p.greetingPrefix) { if (mailHtml) { mailHtml = personalizeHtmlText(mailHtml, email, p.greetingPrefix); } if (mailText) { mailText = personalizeBodyText(mailText, email, p.greetingPrefix); } } if (p.unsubscribe && p.unsubscribeUrl) { const unsub = addUnsubscribeLink(mailHtml, mailText, p.unsubscribeUrl); if (unsub.html) mailHtml = unsub.html; if (unsub.text) mailText = unsub.text; } const mailOptions: Record = { from: sender, to: email, subject: mailSubject, }; if (p.unsubscribe && p.unsubscribeUrl) { mailOptions.headers = { "List-Unsubscribe": "<" + p.unsubscribeUrl + ">", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", }; } if (mailHtml) { mailOptions.html = mailHtml; if (mailText) mailOptions.text = mailText; } else { mailOptions.text = mailText; } try { await transporter.sendMail(mailOptions); sentCount++; } catch (err) { errors.push(email + ": " + (err as Error).message); } if (p.randomDelay && i < batch.recipients.length - 1) { await sleep(1000 + Math.random() * 2000); } } await supabase .from("email_queue") .update({ status: errors.length === 0 ? "completed" : (sentCount > 0 ? "completed" : "failed"), error_message: errors.length > 0 ? errors.join("; ") : null, smtp_config: {}, personalize: {}, processed_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq("id", batch.id); return jsonResponse({ success: true, done: false, batchIndex: batch.batch_index, totalBatches: batch.total_batches, sentCount: sentCount, personalized: true, }); } else { const mailOptions: Record = { from: sender, subject: batch.subject, }; if (batch.html) { mailOptions.html = batch.html; if (batch.body) mailOptions.text = batch.body; } else { mailOptions.text = batch.body; } if (batch.send_type === "bcc") { mailOptions.bcc = batch.recipients.join(", "); } else { mailOptions.to = batch.recipients.join(", "); } await transporter.sendMail(mailOptions); await supabase .from("email_queue") .update({ status: "completed", smtp_config: {}, personalize: {}, processed_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq("id", batch.id); return jsonResponse({ success: true, done: false, batchIndex: batch.batch_index, totalBatches: batch.total_batches, sentCount: batch.recipients.length, }); } } catch (error) { await supabase .from("email_queue") .update({ status: "failed", error_message: (error as Error).message, smtp_config: {}, personalize: {}, updated_at: new Date().toISOString(), }) .eq("id", batch.id); return jsonResponse({ success: false, done: false, batchIndex: batch.batch_index, error: (error as Error).message, }); } } async function handleStatus(data: StatusPayload) { const { jobId, clientToken } = data; if (!jobId || !clientToken) { return jsonResponse( { success: false, error: "Missing jobId or clientToken" }, 400 ); } const supabase = getSupabaseAdmin(); const { data: batches, error } = await supabase .from("email_queue") .select( "status, batch_index, total_batches, recipients, error_message, processed_at" ) .eq("job_id", jobId) .eq("client_token", clientToken) .order("batch_index", { ascending: true }); if (error) { return jsonResponse({ success: false, error: error.message }, 500); } if (!batches || batches.length === 0) { return jsonResponse({ success: false, error: "Job not found" }, 404); } const total = batches.length; const completed = batches.filter((b) => b.status === "completed").length; const failed = batches.filter((b) => b.status === "failed").length; const pending = batches.filter((b) => b.status === "pending").length; const processing = batches.filter((b) => b.status === "processing").length; const totalRecipients = batches.reduce( (sum, b) => sum + (b.recipients?.length || 0), 0 ); const sentRecipients = batches .filter((b) => b.status === "completed") .reduce((sum, b) => sum + (b.recipients?.length || 0), 0); const errors = batches .filter((b) => b.status === "failed" && b.error_message) .map((b) => ({ batch: b.batch_index, error: b.error_message })); return jsonResponse({ success: true, jobId, totalBatches: total, completed, failed, pending, processing, totalRecipients, sentRecipients, isDone: pending === 0 && processing === 0, errors, }); } async function handleCancel(data: CancelPayload) { const { jobId, clientToken } = data; if (!jobId || !clientToken) { return jsonResponse( { success: false, error: "Missing jobId or clientToken" }, 400 ); } const supabase = getSupabaseAdmin(); const { error } = await supabase .from("email_queue") .update({ status: "cancelled", smtp_config: {}, updated_at: new Date().toISOString(), }) .eq("job_id", jobId) .eq("client_token", clientToken) .eq("status", "pending"); if (error) { return jsonResponse({ success: false, error: error.message }, 500); } await supabase .from("email_queue") .update({ smtp_config: {} }) .eq("job_id", jobId) .eq("client_token", clientToken); return jsonResponse({ success: true }); } Deno.serve(async (req: Request) => { if (req.method === "OPTIONS") { return new Response(null, { status: 200, headers: corsHeaders }); } try { const { action, ...data } = await req.json(); switch (action) { case "enqueue": return await handleEnqueue(data as unknown as EnqueuePayload); case "process": return await handleProcess(data as unknown as ProcessPayload); case "status": return await handleStatus(data as unknown as StatusPayload); case "cancel": return await handleCancel(data as unknown as CancelPayload); default: return jsonResponse({ success: false, error: "Unknown action" }, 400); } } catch (error) { return jsonResponse( { success: false, error: (error as Error).message }, 500 ); } });