import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY'); const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); const FROM = 'Fourge Branding '; const TEAM_EMAILS = [ 'krao@fourgebranding.com', 'smarsell@fourgebranding.com', 'swebb@fourgebranding.com', 'twebb@fourgebranding.com', ]; const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'] as const; type EmailType = typeof ALLOWED_TYPES[number]; // Types that only team members may trigger const TEAM_ONLY_TYPES: EmailType[] = ['sent_to_client', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent']; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; /** Escape a value for safe insertion into HTML. */ const esc = (s: unknown): string => { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; /** Escape and convert newlines to
for multi-line text fields. */ const escMultiline = (s: unknown): string => esc(s).replace(/\n/g, '
'); /** Validate a string field: must be a non-empty string within max length. */ const requireStr = (val: unknown, max = 500): string => { if (typeof val !== 'string' || !val.trim()) throw new Error('Missing required field'); if (val.length > max) throw new Error('Field exceeds maximum length'); return val.trim(); }; /** Validate an optional string field. Returns '' if absent. */ const optStr = (val: unknown, max = 500): string => { if (val == null || val === '') return ''; if (typeof val !== 'string') throw new Error('Invalid field type'); if (val.length > max) throw new Error('Field exceeds maximum length'); return val.trim(); }; /** Basic UUID format check. */ const isUuid = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); /** Basic email format check. */ const isEmail = (v: unknown): v is string => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && v.length <= 254; const parseJsonSafe = async (res: Response) => { const text = await res.text(); if (!text) return null; try { return JSON.parse(text); } catch { return { raw: text }; } }; const isBase64 = (value: unknown): value is string => typeof value === 'string' && value.length <= 10_000_000 && /^[A-Za-z0-9+/=]+$/.test(value); const safeFilename = (value: unknown): string => { const filename = requireStr(value, 180); if (!/^[a-zA-Z0-9._ -]+\.pdf$/i.test(filename)) throw new Error('Invalid attachment filename'); return filename; }; serve(async (req) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }); } const respond = (body: unknown, status: number) => new Response(JSON.stringify(body), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status, }); try { if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { return respond({ error: 'Missing Supabase environment variables' }, 500); } if (!RESEND_API_KEY) { return respond({ error: 'Missing RESEND_API_KEY' }, 500); } // ── 1. Authentication ──────────────────────────────────────────────────── const authHeader = req.headers.get('Authorization') ?? ''; const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; if (!accessToken) { return respond({ error: 'Unauthorized' }, 401); } // Use service role client to validate the token — works with ES256 JWTs const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken); if (authError || !userData?.user) { return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401); } // ── 2. Role lookup ─────────────────────────────────────────────────────── const { data: profile } = await adminClient .from('profiles') .select('role') .eq('id', userData.user.id) .single(); const role: string = profile?.role ?? ''; if (!['team', 'client', 'external'].includes(role)) { return respond({ error: 'Forbidden' }, 403); } // ── 3. Parse and validate body ─────────────────────────────────────────── const body = await req.json(); const { type, to, data, attachments } = body; if (!ALLOWED_TYPES.includes(type)) { return respond({ error: 'Invalid email type' }, 400); } if (TEAM_ONLY_TYPES.includes(type) && role !== 'team') { return respond({ error: 'Forbidden' }, 403); } const safeAttachments = Array.isArray(attachments) ? attachments.slice(0, 3).map((attachment) => { const filename = safeFilename(attachment?.filename); if (!isBase64(attachment?.content)) throw new Error('Invalid attachment content'); return { filename, content: attachment.content }; }) : []; // ── 4. Build email per type (with validated + escaped fields) ──────────── let subject = ''; let html = ''; if (type === 'new_request') { const serviceType = requireStr(data?.serviceType); const clientName = requireStr(data?.clientName); const clientEmail = requireStr(data?.clientEmail); const projectName = requireStr(data?.projectName); const description = requireStr(data?.description, 10000); const company = optStr(data?.company); const deadline = optStr(data?.deadline); const taskId = data?.taskId; if (!isUuid(taskId)) throw new Error('Invalid taskId'); subject = `New Request: ${esc(serviceType)} — ${esc(clientName)}`; html = `

New Request Received

From: ${esc(clientName)} (${esc(clientEmail)})

Company: ${esc(company) || '—'}

Service: ${esc(serviceType)}

Project: ${esc(projectName)}

${deadline ? `

Deadline: ${esc(deadline)}

` : ''}

Description:

${escMultiline(description)}


View Job `; } else if (type === 'sent_to_client') { const serviceType = requireStr(data?.serviceType); const clientFirstName = requireStr(data?.clientFirstName); const projectName = requireStr(data?.projectName); const message = optStr(data?.message, 5000); const taskId = data?.taskId; if (!isUuid(taskId)) throw new Error('Invalid taskId'); subject = `Your ${esc(serviceType)} is ready for review!`; html = `

Hi ${esc(clientFirstName)},

Your ${esc(serviceType)} for ${esc(projectName)} is ready for review.

${message ? `

${escMultiline(message)}

` : ''}

Please log in to the portal to review and approve or request changes.


Review Your Work

— The Fourge Branding Team

`; } else if (type === 'revision_submitted') { const serviceType = requireStr(data?.serviceType); const clientName = requireStr(data?.clientName); const projectName = requireStr(data?.projectName); const version = requireStr(data?.version); const description = requireStr(data?.description, 10000); const deadline = optStr(data?.deadline); const taskId = data?.taskId; if (!isUuid(taskId)) throw new Error('Invalid taskId'); subject = `Revision Request: ${esc(serviceType)} — ${esc(clientName)}`; html = `

Revision Requested

From: ${esc(clientName)}

Job: ${esc(serviceType)} — ${esc(projectName)}

New Version: ${esc(version)}

${deadline ? `

New Deadline: ${esc(deadline)}

` : ''}

Requested changes:

${escMultiline(description)}


View Job `; } else if (type === 'client_approved') { const serviceType = requireStr(data?.serviceType); const clientName = requireStr(data?.clientName); const projectName = requireStr(data?.projectName); const taskId = data?.taskId; if (!isUuid(taskId)) throw new Error('Invalid taskId'); subject = `Approved! ${esc(serviceType)} — ${esc(clientName)}`; html = `

Job Approved ✓

${esc(clientName)} has approved ${esc(serviceType)} for ${esc(projectName)}.

This job is now complete.


View Job `; } else if (type === 'invoice_sent') { const invoiceNumber = requireStr(data?.invoiceNumber); const billTo = requireStr(data?.billTo); const total = requireStr(data?.total); const dueDate = requireStr(data?.dueDate); const payUrl = requireStr(data?.payUrl); const notes = optStr(data?.notes, 2000); subject = `Invoice ${esc(invoiceNumber)} from Fourge Branding — ${esc(total)} due ${esc(dueDate)}`; html = `
Fourge Branding

Hi ${esc(billTo)},

Please find your invoice from Fourge Branding below.

Invoice # ${esc(invoiceNumber)}
Amount Due ${esc(total)}
Due Date ${esc(dueDate)}
${notes ? `

${escMultiline(notes)}

` : ''} Pay Now — ${esc(total)}

Secured by Stripe · Charged in USD
Questions? hello@fourgebranding.com

`; } else if (type === 'receipt_sent') { const invoiceNumber = requireStr(data?.invoiceNumber); const billTo = requireStr(data?.billTo); const total = requireStr(data?.total); const paidDate = requireStr(data?.paidDate); subject = `Payment Receipt — ${esc(invoiceNumber)} — Thank you!`; html = `
Fourge Branding
✓ PAYMENT RECEIVED

Hi ${esc(billTo)},

Thank you — your payment has been received. Here's your receipt.

Invoice # ${esc(invoiceNumber)}
Amount Paid ${esc(total)}
Payment Date ${esc(paidDate)}

Questions? hello@fourgebranding.com

`; } else if (type === 'subcontractor_po_sent') { const poNumber = requireStr(data?.poNumber); const subcontractorName = requireStr(data?.subcontractorName); const projectName = requireStr(data?.projectName); const companyName = requireStr(data?.companyName); const amount = requireStr(data?.amount); const dueDate = requireStr(data?.dueDate); const terms = requireStr(data?.terms); const scope = requireStr(data?.scope, 10000); const portalUrl = requireStr(data?.portalUrl); subject = `Purchase Order ${esc(poNumber)} from Fourge Branding — ${esc(amount)}`; html = `
Fourge Branding

Hi ${esc(subcontractorName)},

A subcontractor purchase order is ready for your review.

PO # ${esc(poNumber)}
Project ${esc(projectName)}
Company ${esc(companyName)}
Amount ${esc(amount)}
Due ${esc(dueDate)}
Terms ${esc(terms)}
Scope
${escMultiline(scope)}
Review and Approve PO

Questions? hello@fourgebranding.com

`; } // ── 5. Resolve recipients ──────────────────────────────────────────────── const teamTypes = ['new_request', 'revision_submitted', 'client_approved']; let recipients: string[]; let cc: string[] | undefined; if (teamTypes.includes(type)) { recipients = TEAM_EMAILS; } else { // Validate caller-supplied recipient list const toList = Array.isArray(to) ? to : [to]; recipients = [...new Set(toList.map(val => typeof val === 'string' ? val.trim() : val).filter(isEmail))]; if (recipients.length === 0) throw new Error('No valid recipient emails'); if (recipients.length > 20) throw new Error('Too many recipients'); const senderEmail = optStr(data?.senderEmail); if (senderEmail && isEmail(senderEmail)) cc = [senderEmail]; if (type === 'invoice_sent') { cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])]; } } // ── 6. Send via Resend ─────────────────────────────────────────────────── const payload: Record = { from: FROM, to: recipients, subject, html }; if (cc) payload.cc = cc; if (safeAttachments.length) payload.attachments = safeAttachments; const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${RESEND_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); const result = await parseJsonSafe(res); if (!res.ok) { const message = (result && typeof result === 'object' && 'message' in result && typeof result.message === 'string' && result.message) || (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) || `Resend request failed with status ${res.status}`; console.error('Resend API error:', { status: res.status, result }); return respond({ error: message, resend_status: res.status, resend_body: result }, 400); } return respond(result, 200); } catch (err) { console.error('send-email failed:', err); return respond({ error: (err as Error).message }, 500); } });