diff --git a/src/lib/email-matching.ts b/src/lib/email-matching.ts new file mode 100644 index 0000000..89a3537 --- /dev/null +++ b/src/lib/email-matching.ts @@ -0,0 +1,246 @@ +// 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: [] }; +} \ No newline at end of file