Files
acmcc/supabase/functions/arc-inbound-email/index.ts
T
2026-06-01 20:19:26 -04:00

180 lines
7.0 KiB
TypeScript

// 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<>]+@[^\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' },
})
})