// SendGrid Inbound Parse webhook handler. // Receives multipart/form-data POST from SendGrid when an email arrives at // the configured inbound hostname (e.g. bills.stagelaw.com). // Looks up the sender in vendor_email_mappings; if matched, parses the PDF // attachment via parse-invoice and creates a draft bill. Otherwise quarantines // the email in inbound_bill_emails for manual review. 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 } // Prefer the address inside <...>; otherwise take the first bare email token. // (A greedy "Name" group mis-splits bare addresses like invoices@x.com -> s@x.com.) const angle = raw.match(/<([^<>\s]+@[^<>\s]+)>/) const email = angle?.[1] ?? raw.match(/[^\s<>,;]+@[^\s<>,;]+/)?.[0] ?? null const nameM = raw.match(/^\s*"?([^"<]+?)"?\s* { const bytes = new Uint8Array(buf) let binary = '' const chunk = 0x8000 for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) } return btoa(binary) } 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 supabaseUrl = Deno.env.get('SUPABASE_URL')! const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! const supabase = createClient(supabaseUrl, serviceKey) 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 attachmentCount = parseInt(formData.get('attachments')?.toString() ?? '0', 10) const { email: fromEmail, name: fromName } = extractEmail(fromRaw) const { email: toEmail } = extractEmail(toRaw) console.log('Inbound bill email', { fromEmail, toEmail, subject, attachmentCount }) // ===== Deduplication ===== // External mail loops (auto-forwarders, retry policies) can re-deliver the exact // same email repeatedly. If we already received a message with the same sender + // subject in the last 30 days, treat this delivery as a duplicate and short-circuit. if (fromEmail && subject) { const sinceIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() const { data: existing, error: dupErr } = await supabase .from('inbound_bill_emails') .select('id, status, bill_id, created_at') .eq('from_email', fromEmail) .eq('subject', subject) .gte('created_at', sinceIso) .order('created_at', { ascending: false }) .limit(1) if (dupErr) { console.error('Dedup lookup failed', dupErr) } else if (existing && existing.length > 0) { console.log('Duplicate inbound email ignored', { fromEmail, subject, existingId: existing[0].id, existingStatus: existing[0].status, }) return new Response( JSON.stringify({ ok: true, deduplicated: true, existingId: existing[0].id }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }, ) } } // Find first PDF attachment let pdfFile: File | null = null for (let i = 1; i <= attachmentCount; i++) { const file = formData.get(`attachment${i}`) as File | null if (file && (file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf'))) { pdfFile = file break } } // Build raw payload snapshot (small fields only) const rawPayload: Record = { from: fromRaw, to: toRaw, subject, attachments: attachmentCount, envelope: formData.get('envelope')?.toString() ?? null, spam_score: formData.get('spam_score')?.toString() ?? null, } // Upload attachment to storage if present let storagePath: string | null = null let attachmentUrl: string | null = null let pdfBase64: string | null = null if (pdfFile) { const buf = await pdfFile.arrayBuffer() pdfBase64 = await arrayBufferToBase64(buf) const safeName = (pdfFile.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_') storagePath = `${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}` const { error: uploadErr } = await supabase.storage .from('inbound-bill-attachments') .upload(storagePath, buf, { contentType: 'application/pdf', upsert: false }) if (uploadErr) { console.error('Failed to upload attachment', uploadErr) storagePath = null } else { const { data: signed } = await supabase.storage .from('inbound-bill-attachments') .createSignedUrl(storagePath, 60 * 60 * 24 * 30) // 30 days attachmentUrl = signed?.signedUrl ?? null } } // Determine the target association: first by the RECIPIENT alias // (@bills.avriamail.com), then by the SENDER's vendor mapping. let mappedAssociationId: string | null = null let mappedVendorId: string | null = null // Recipient local-part from the SMTP envelope (most reliable), falling back to // the To header. e.g. "ashleymanor@bills.avriamail.com" -> alias "ashleymanor". let recipientLocal: string | null = null try { const env = JSON.parse(formData.get('envelope')?.toString() ?? '{}') const envTo = Array.isArray(env?.to) ? env.to[0] : null recipientLocal = (envTo ?? toRaw ?? '').toString().match(/([^\s<>@]+)@/)?.[1]?.toLowerCase() ?? null if (recipientLocal) recipientLocal = recipientLocal.split('+')[0] } catch (_) { /* ignore malformed envelope */ } if (recipientLocal) { const { data: assoc } = await supabase .from('associations') .select('id') .ilike('inbound_alias', recipientLocal) .limit(1) if (assoc && assoc.length > 0) mappedAssociationId = assoc[0].id as string } // Sender → vendor/association mapping (sets the vendor, and the association too // if the recipient alias didn't resolve one). if (fromEmail) { const { data: mappings } = await supabase .from('vendor_email_mappings') .select('association_id, vendor_id') .eq('email', fromEmail) .limit(1) if (mappings && mappings.length > 0) { mappedVendorId = (mappings[0].vendor_id as string) ?? null if (!mappedAssociationId) mappedAssociationId = mappings[0].association_id as string } } // Always create the inbox row first so nothing is lost const { data: inboxRow, error: inboxErr } = await supabase .from('inbound_bill_emails') .insert({ from_email: fromEmail, from_name: fromName, to_email: toEmail, subject, body_text: text?.slice(0, 10000) ?? null, attachment_url: attachmentUrl, attachment_filename: pdfFile?.name ?? null, attachment_storage_path: storagePath, association_id: mappedAssociationId, raw_payload: rawPayload, status: 'unassigned', }) .select('id') .single() if (inboxErr || !inboxRow) { console.error('Failed to create inbox row', inboxErr) return new Response('Internal error', { status: 500, headers: corsHeaders }) } const inboxId = inboxRow.id as string // No PDF? Leave as unassigned with note if (!pdfBase64) { await supabase .from('inbound_bill_emails') .update({ status: 'unassigned', error_message: 'No PDF attachment found' }) .eq('id', inboxId) return new Response(JSON.stringify({ ok: true, inboxId, reason: 'no_pdf' }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } // Unknown sender → leave for manual review (with the PDF saved + parsed for convenience) // We still try to parse so staff can preview the extracted data let parsed: Record | null = null let parseError: string | null = null try { const { data, error } = await supabase.functions.invoke('parse-invoice', { body: { pdf_base64: pdfBase64, filename: pdfFile?.name ?? 'invoice.pdf' }, }) if (error) { // Surface the function's actual error body (e.g. "PDF too large…") instead // of the generic "non-2xx status code" wrapper. let detail = error.message try { const body = await (error as { context?: Response }).context?.json() if (body?.error) detail = body.error } catch (_) { /* keep generic message */ } throw new Error(detail) } parsed = (data as { data?: Record })?.data ?? null } catch (err) { parseError = err instanceof Error ? err.message : String(err) console.error('parse-invoice failed', parseError) } await supabase .from('inbound_bill_emails') .update({ parsed_data: parsed, error_message: parseError }) .eq('id', inboxId) // If we have a mapping AND we parsed successfully, auto-create a draft bill if (mappedAssociationId && parsed) { const totalAmount = Number(parsed.total_amount) || 0 const invoiceNumber = (parsed.invoice_number as string) || null const invoiceDate = (parsed.invoice_date as string) || new Date().toISOString().slice(0, 10) const dueDate = (parsed.due_date as string) || null const vendorName = (parsed.vendor_name as string) || fromName || fromEmail || 'Unknown Vendor' const lineItems = Array.isArray((parsed as any).line_items) ? (parsed as any).line_items : [] // Upload the PDF to the public `invoices` bucket so the URL is permanent // (the inbound-bill-attachments bucket is private and signed URLs expire). let publicPdfUrl: string | null = null try { const safeName = (pdfFile?.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_') const invPath = `inbound/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}` const buf = Uint8Array.from(atob(pdfBase64), (c) => c.charCodeAt(0)) const { error: invUploadErr } = await supabase.storage .from('invoices') .upload(invPath, buf, { contentType: 'application/pdf', upsert: false }) if (invUploadErr) { console.error('Failed to upload PDF to invoices bucket', invUploadErr) } else { const { data: pub } = supabase.storage.from('invoices').getPublicUrl(invPath) publicPdfUrl = pub?.publicUrl ?? null } } catch (err) { console.error('Public PDF upload error', err) } const billAttachmentUrl = publicPdfUrl ?? attachmentUrl // Create a linked invoices row (mirrors AI Bill Parser flow) so Bill Approvals // can read raw_pdf_url and display the attachment. let sourceInvoiceId: string | null = null try { const { data: invRow, error: invErr } = await supabase .from('invoices') .insert({ association_id: mappedAssociationId, vendor_name: vendorName, invoice_number: invoiceNumber, issue_date: invoiceDate, due_date: dueDate, amount: totalAmount, description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`, status: 'pending', raw_pdf_url: billAttachmentUrl, line_items: lineItems, }) .select('id') .single() if (invErr) { console.error('Failed to create invoice row', invErr) } else { sourceInvoiceId = invRow?.id ?? null } } catch (err) { console.error('Invoice insert error', err) } const { data: bill, error: billErr } = await supabase .from('bills') .insert({ association_id: mappedAssociationId, vendor_id: mappedVendorId, amount: totalAmount, invoice_number: invoiceNumber, bill_date: invoiceDate, due_date: dueDate, description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`, attachment_url: billAttachmentUrl, source_invoice_id: sourceInvoiceId, status: 'pending', }) .select('id') .single() if (billErr) { console.error('Failed to create bill', billErr) await supabase .from('inbound_bill_emails') .update({ status: 'error', error_message: `Bill creation failed: ${billErr.message}` }) .eq('id', inboxId) } else { await supabase .from('inbound_bill_emails') .update({ status: 'processed', bill_id: bill.id, processed_at: new Date().toISOString(), }) .eq('id', inboxId) console.log('Created bill from inbound email', { billId: bill.id, inboxId }) } } // Notify staff (admins + managers) of the inbound bill try { const { data: staffRoles } = await supabase .from('user_roles') .select('user_id') .in('role', ['admin', 'manager']) const uniqueIds = Array.from( new Set((staffRoles ?? []).map((r: any) => r.user_id).filter(Boolean)) ) if (uniqueIds.length > 0) { const senderLabel = fromName || fromEmail || 'Unknown sender' const subjectLabel = subject ? `: ${subject}` : '' const title = mappedAssociationId ? 'New inbound bill received' : 'Inbound bill needs review' const message = mappedAssociationId ? `Bill from ${senderLabel}${subjectLabel} was received and processed.` : `Email from ${senderLabel}${subjectLabel} could not be matched to a vendor and needs manual review.` const link = '/dashboard/inbound-bills' const notifications = uniqueIds.map((uid) => ({ user_id: uid, type: 'inbound_bill_received', title, message, related_item_id: inboxId, related_item_type: 'inbound_bill_email', link, })) const { error: notifErr } = await supabase .from('in_app_notifications') .insert(notifications) if (notifErr) console.error('Failed to insert notifications', notifErr) } } catch (err) { console.error('Notification dispatch failed', err) } return new Response(JSON.stringify({ ok: true, inboxId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) })