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();