Files
acmcc/supabase/functions/inbound-bill-email/index.ts
T
admin 71cc71f89f Inbound invoices: recipient-alias routing + parser hardening
parse-invoice: guard oversized PDFs (>18MB → clear "too large, saved for manual
entry" message) and surface the AI gateway's actual error instead of a bare
status code.

inbound-bill-email: route to an association by the recipient alias
(<alias>@bills.avriamail.com, via associations.inbound_alias) in addition to the
sender's vendor mapping; fix extractEmail (bare addresses were mis-split, e.g.
invoices@x → s@x); surface parse-invoice's real error in the inbox. Deployed via
MCP; migration associations_inbound_alias adds + populates the aliases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:13:49 -04:00

379 lines
14 KiB
TypeScript

// 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*</)
return {
email: email ? email.trim().toLowerCase() : null,
name: nameM ? (nameM[1].trim() || null) : null,
}
}
async function arrayBufferToBase64(buf: ArrayBuffer): Promise<string> {
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<string, unknown> = {
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
// (<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<string, unknown> | 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<string, unknown> })?.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' },
})
})