// Pure email-to-case matching logic. // // Given an incoming email and lookup tables (cases, contacts, etc.), this // returns scored case suggestions and a final match decision: // - "matched" → high-confidence single match, attach automatically // - "suggested" → one or more plausible matches, needs user confirmation // - "unmatched" → no signal, leave for manual triage in the inbox // // Signal weights (higher = stronger): // subject tag match 100 // reply-thread parent match 90 // single contact email match 70 // client contact email match 60 // homeowner email match 55 // opposing counsel email match 55 // body case-number match 45 // // Decision: // - Top score >= 90 and clear leader → matched // - Otherwise, top results >= 30 → suggested (top 3) // - Otherwise → unmatched export interface CaseLookup { id: string; case_number: string; court_case_number: string | null; case_caption: string | null; title: string; client_id: string | null; opposing_counsel_email: string | null; status: string; updated_at: string; } export interface ContactCaseLink { contact_id: string; email: string | null; case_id: string; } export interface ClientCaseLink { client_id: string; primary_contact_email: string | null; case_ids: string[]; } export interface HomeownerCaseLink { homeowner_id: string; email: string | null; case_ids: string[]; } export interface EmailLike { message_id: string | null; in_reply_to: string | null; email_references: string[]; from_address: string | null; to_addresses: string[]; cc_addresses: string[]; subject: string | null; body_text: string | null; } export interface PriorEmail { message_id: string | null; case_id: string | null; } export interface ScoredMatch { case_id: string; score: number; reasons: string[]; } export interface MatchResult { status: "matched" | "suggested" | "unmatched"; case_id: string | null; reason: string | null; suggestions: ScoredMatch[]; } const SUBJECT_TAG_RE = /\[\s*(?:case[\s#:-]*)?([A-Za-z0-9._/-]{2,40})\s*\]/i; function norm(s: string | null | undefined): string { return (s ?? "").trim().toLowerCase(); } function uniq(arr: T[]): T[] { return Array.from(new Set(arr)); } export function extractSubjectTag(subject: string | null): string | null { if (!subject) return null; const m = subject.match(SUBJECT_TAG_RE); return m ? m[1].trim() : null; } export function matchEmailToCases( email: EmailLike, cases: CaseLookup[], contactLinks: ContactCaseLink[], clientLinks: ClientCaseLink[], homeownerLinks: HomeownerCaseLink[], priorThreadEmails: PriorEmail[], ): MatchResult { const scores = new Map(); const bump = (caseId: string, points: number, reason: string) => { const cur = scores.get(caseId); if (cur) { cur.score += points; if (!cur.reasons.includes(reason)) cur.reasons.push(reason); } else { scores.set(caseId, { case_id: caseId, score: points, reasons: [reason] }); } }; // 1. Subject tag → match against case_number / court_case_number const tag = extractSubjectTag(email.subject); if (tag) { const tagN = norm(tag); for (const c of cases) { if (norm(c.case_number) === tagN || norm(c.court_case_number) === tagN) { bump(c.id, 100, `Subject tag matches case number ${c.case_number}`); } } } // 2. Reply threading via In-Reply-To / References const threadIds = uniq( [email.in_reply_to, ...(email.email_references ?? [])].filter( (s): s is string => !!s, ), ); if (threadIds.length > 0) { const threadMap = new Map(); for (const p of priorThreadEmails) { if (p.message_id && p.case_id) threadMap.set(p.message_id, p.case_id); } for (const id of threadIds) { const cid = threadMap.get(id); if (cid) bump(cid, 90, "Reply to a previously matched email"); } } // 3. Contact email lookup (sender + any recipient) const allAddrs = uniq( [email.from_address, ...email.to_addresses, ...email.cc_addresses] .map(norm) .filter(Boolean), ); if (allAddrs.length > 0) { // Direct contact → case_contacts for (const link of contactLinks) { const e = norm(link.email); if (e && allAddrs.includes(e)) { bump(link.case_id, 70, `Contact ${link.email} is linked to this case`); } } // Client primary contact → all cases for that client for (const link of clientLinks) { const e = norm(link.primary_contact_email); if (e && allAddrs.includes(e)) { for (const cid of link.case_ids) { bump(cid, 60, `Client primary contact ${link.primary_contact_email}`); } } } // Homeowner → cases for that homeowner for (const link of homeownerLinks) { const e = norm(link.email); if (e && allAddrs.includes(e)) { for (const cid of link.case_ids) { bump(cid, 55, `Homeowner ${link.email}`); } } } // Opposing counsel for (const c of cases) { const e = norm(c.opposing_counsel_email); if (e && allAddrs.includes(e)) { bump(c.id, 55, `Opposing counsel ${c.opposing_counsel_email}`); } } } // 4. Body keyword scan: case numbers const body = norm(email.body_text); if (body) { for (const c of cases) { const cn = norm(c.case_number); const ccn = norm(c.court_case_number); if (cn && cn.length >= 3 && body.includes(cn)) { bump(c.id, 45, `Case number ${c.case_number} mentioned in body`); } if (ccn && ccn !== cn && ccn.length >= 3 && body.includes(ccn)) { bump(c.id, 45, `Court case number ${c.court_case_number} in body`); } } } // Decide const ranked = Array.from(scores.values()).sort((a, b) => b.score - a.score); if (ranked.length === 0) { return { status: "unmatched", case_id: null, reason: null, suggestions: [] }; } const top = ranked[0]; const second = ranked[1]; const clearLead = !second || top.score - second.score >= 30; if (top.score >= 90 && clearLead) { return { status: "matched", case_id: top.case_id, reason: top.reasons.join("; "), suggestions: ranked.slice(0, 3), }; } // Single high-confidence contact match with no competition → matched if (top.score >= 70 && !second) { return { status: "matched", case_id: top.case_id, reason: top.reasons.join("; "), suggestions: ranked.slice(0, 3), }; } // Below auto-match threshold but above noise → suggest const meaningful = ranked.filter((r) => r.score >= 30).slice(0, 3); if (meaningful.length > 0) { return { status: "suggested", case_id: null, reason: null, suggestions: meaningful, }; } return { status: "unmatched", case_id: null, reason: null, suggestions: [] }; }