From 71cc71f89f6a415377743d083defac3a22615f98 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 17 Jun 2026 22:13:49 -0400 Subject: [PATCH] Inbound invoices: recipient-alias routing + parser hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (@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 --- .../functions/inbound-bill-email/index.ts | 50 ++++++++++++++++--- supabase/functions/parse-invoice/index.ts | 29 +++++++++-- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/supabase/functions/inbound-bill-email/index.ts b/supabase/functions/inbound-bill-email/index.ts index 6c1d661..dc96c05 100644 --- a/supabase/functions/inbound-bill-email/index.ts +++ b/supabase/functions/inbound-bill-email/index.ts @@ -14,10 +14,15 @@ const corsHeaders = { function extractEmail(raw: string | null): { email: string | null; name: string | null } { if (!raw) return { email: null, name: null } - // Match "Name " or just "email@x.com" - const m = raw.match(/(?:"?([^"<]*)"?\s*)?]+@[^\s<>]+)>?/) - if (!m) return { email: null, name: null } - return { email: m[2].trim().toLowerCase(), name: (m[1] || '').trim() || 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* { @@ -127,9 +132,31 @@ Deno.serve(async (req) => { } } - // Look up sender → association mapping + // 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') @@ -137,8 +164,8 @@ Deno.serve(async (req) => { .eq('email', fromEmail) .limit(1) if (mappings && mappings.length > 0) { - mappedAssociationId = mappings[0].association_id as string mappedVendorId = (mappings[0].vendor_id as string) ?? null + if (!mappedAssociationId) mappedAssociationId = mappings[0].association_id as string } } @@ -187,7 +214,16 @@ Deno.serve(async (req) => { const { data, error } = await supabase.functions.invoke('parse-invoice', { body: { pdf_base64: pdfBase64, filename: pdfFile?.name ?? 'invoice.pdf' }, }) - if (error) throw error + 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) diff --git a/supabase/functions/parse-invoice/index.ts b/supabase/functions/parse-invoice/index.ts index ff4c16d..5de1616 100644 --- a/supabase/functions/parse-invoice/index.ts +++ b/supabase/functions/parse-invoice/index.ts @@ -82,6 +82,19 @@ Deno.serve(async (req) => { }); } + // Guard against oversized/scanned PDFs that the AI gateway rejects with an + // opaque error. The caller (inbound-bill-email) still keeps the attachment + // in the inbox for manual entry when this returns an error. + const approxBytes = Math.floor((pdf_base64.length * 3) / 4); + const sizeMB = approxBytes / (1024 * 1024); + const MAX_MB = 18; + if (sizeMB > MAX_MB) { + return new Response( + JSON.stringify({ error: `PDF too large to auto-parse (${sizeMB.toFixed(1)} MB; limit ${MAX_MB} MB). Saved to the inbox for manual entry.` }), + { status: 413, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + const prompt = `You are a meticulous invoice data extraction AI. Analyze the provided PDF invoice carefully — examine EVERY page and EVERY line item, including continuation pages, sub-totals, and tables. Do not skip or summarize line items; capture each one individually exactly as it appears. CRITICAL RULES: @@ -162,11 +175,17 @@ Return ONLY valid JSON (no markdown, no code blocks, no commentary) with this ex if (!response.ok) { const errText = await response.text(); - console.error("AI Gateway error:", errText); - return new Response(JSON.stringify({ error: `AI processing failed: ${response.status}` }), { - status: 502, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + console.error("AI Gateway error:", response.status, errText); + const snippet = errText.replace(/\s+/g, " ").slice(0, 300); + const hint = response.status === 429 || response.status === 402 + ? " (AI gateway rate/credit limit)" + : response.status === 413 || /too large|payload|size/i.test(errText) + ? " (PDF too large for the AI gateway — saved to inbox for manual entry)" + : ""; + return new Response( + JSON.stringify({ error: `AI gateway error ${response.status}${hint}: ${snippet || "no detail"}` }), + { status: 502, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); } const aiResult = await response.json();