mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user