// SendGrid Inbound Parse webhook for ARC applications. // Receives multipart/form-data POST when an email arrives at the configured // inbound hostname (e.g. arc@parse.yourdomain.com). Uploads attachments to the // arc-files bucket, runs Lovable AI (Gemini) over the email body to extract // project type / title / description / property address, inserts a row in // arc_inbound_emails, and creates a form_inbox entry so staff get notified. import { createClient } from 'npm:@supabase/supabase-js@2' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', } function extractEmail(raw: string | null): { email: string | null; name: string | null } { if (!raw) return { email: null, name: null } const m = raw.match(/(?:"?([^"<]*)"?\s*)?]+@[^\s<>]+)>?/) if (!m) return { email: null, name: null } return { email: m[2].trim().toLowerCase(), name: (m[1] || '').trim() || null } } async function aiExtract(subject: string | null, body: string | null, fromName: string | null) { const apiKey = Deno.env.get('LOVABLE_API_KEY') if (!apiKey) return null const prompt = `You are extracting fields for an Architectural Review Committee (ARC) application from a homeowner email. Email subject: ${subject || '(none)'} From: ${fromName || '(unknown)'} Body: """ ${(body || '').slice(0, 8000)} """` try { const resp = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'google/gemini-2.5-flash', messages: [ { role: 'system', content: 'Extract structured ARC application info. Be concise and factual; never invent.' }, { role: 'user', content: prompt }, ], tools: [{ type: 'function', function: { name: 'extract_arc', description: 'Return structured ARC application fields', parameters: { type: 'object', properties: { title: { type: 'string', description: 'Short title for the request (e.g. "Fence replacement", "Exterior paint").' }, project_type: { type: 'string', description: 'Category like Paint, Fence, Roof, Landscaping, Addition, Other.' }, description: { type: 'string', description: 'Concise summary of the proposed work.' }, property_address: { type: 'string', description: 'Property street address if mentioned, else empty string.' }, }, required: ['title', 'project_type', 'description', 'property_address'], additionalProperties: false, }, }, }], tool_choice: { type: 'function', function: { name: 'extract_arc' } }, }), }) if (!resp.ok) { console.error('AI gateway error', resp.status, await resp.text()) return null } const data = await resp.json() const args = data?.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments if (!args) return null return JSON.parse(args) } catch (e) { console.error('AI extract failed', e) return null } } Deno.serve(async (req) => { if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }) if (req.method !== 'POST') return new Response('Method not allowed', { status: 405, headers: corsHeaders }) const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!) let formData: FormData try { formData = await req.formData() } catch (err) { console.error('Failed to parse form data', err) return new Response('Bad Request', { status: 400, headers: corsHeaders }) } const fromRaw = formData.get('from')?.toString() ?? null const toRaw = formData.get('to')?.toString() ?? null const subject = formData.get('subject')?.toString() ?? null const text = formData.get('text')?.toString() ?? null const html = formData.get('html')?.toString() ?? null const attachmentCount = parseInt(formData.get('attachments')?.toString() ?? '0', 10) const { email: fromEmail, name: fromName } = extractEmail(fromRaw) const { email: toEmail } = extractEmail(toRaw) console.log('Inbound ARC email', { fromEmail, toEmail, subject, attachmentCount }) // Upload attachments to arc-files bucket const attachmentUrls: Array<{ name: string; url: string; type: string; size: number }> = [] for (let i = 1; i <= attachmentCount; i++) { const file = formData.get(`attachment${i}`) as File | null if (!file) continue try { const buf = await file.arrayBuffer() const safeName = (file.name || `attachment-${i}`).replace(/[^a-zA-Z0-9._-]/g, '_') const path = `inbound/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}` const { error: upErr } = await supabase.storage.from('arc-files').upload(path, buf, { contentType: file.type || 'application/octet-stream', upsert: false, }) if (upErr) { console.error('Attachment upload failed', upErr) continue } const { data: pub } = supabase.storage.from('arc-files').getPublicUrl(path) attachmentUrls.push({ name: file.name || safeName, url: pub.publicUrl, type: file.type || '', size: file.size }) } catch (e) { console.error('Attachment processing error', e) } } // AI extract const ai = await aiExtract(subject, text || html, fromName) // Insert inbound record const { data: inbound, error: insErr } = await supabase .from('arc_inbound_emails') .insert({ from_email: fromEmail, from_name: fromName, to_email: toEmail, subject, body_text: text, body_html: html, attachment_urls: attachmentUrls, ai_project_type: ai?.project_type ?? null, ai_title: ai?.title ?? null, ai_description: ai?.description ?? null, ai_property_address: ai?.property_address ?? null, ai_raw: ai ?? null, }) .select('id') .single() if (insErr || !inbound) { console.error('Insert failed', insErr) return new Response(JSON.stringify({ ok: false, error: insErr?.message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } // Create form_inbox entry — triggers staff notification const summary = [ ai?.title ? `AI: ${ai.title}` : null, ai?.project_type ? `Type: ${ai.project_type}` : null, attachmentUrls.length ? `${attachmentUrls.length} attachment${attachmentUrls.length > 1 ? 's' : ''}` : null, (text || '').slice(0, 120), ].filter(Boolean).join(' | ').slice(0, 400) await supabase.from('form_inbox').insert({ source_type: 'arc_inbound_email', source_id: inbound.id, association_id: null, title: ai?.title || subject || 'ARC Email Submission', submitter_name: fromName, submitter_email: fromEmail, summary, }) return new Response(JSON.stringify({ ok: true, id: inbound.id }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) })