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>
This commit is contained in:
2026-06-17 22:13:49 -04:00
parent 386ee26a6a
commit 71cc71f89f
2 changed files with 67 additions and 12 deletions
+43 -7
View File
@@ -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 <email@x.com>" or just "email@x.com"
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 }
// 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> {
@@ -127,9 +132,31 @@ Deno.serve(async (req) => {
}
}
// Look up sender → association mapping
// 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')
@@ -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<string, unknown> })?.data ?? null
} catch (err) {
parseError = err instanceof Error ? err.message : String(err)
+24 -5
View File
@@ -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();