From 4d87143beb061499b682b6e0246221f574115a54 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:31:45 +0000 Subject: [PATCH 01/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 18 ++++++++++++++++++ ...42_d222b217-176a-43cf-9282-83790e13c205.sql | 10 ++++++++++ 2 files changed, 28 insertions(+) create mode 100644 supabase/migrations/20260424033142_d222b217-176a-43cf-9282-83790e13c205.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index de641cc..c772f27 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2042,13 +2042,19 @@ export type Database = { case_id: string | null cc_addresses: string[] created_at: string + email_references: string[] from_address: string | null from_name: string | null has_attachments: boolean id: string imap_uid: number | null + in_reply_to: string | null is_archived: boolean is_read: boolean + match_reason: string | null + match_status: string + match_suggestions: Json + matched_at: string | null message_id: string | null notes: string | null raw_size_bytes: number | null @@ -2064,13 +2070,19 @@ export type Database = { case_id?: string | null cc_addresses?: string[] created_at?: string + email_references?: string[] from_address?: string | null from_name?: string | null has_attachments?: boolean id?: string imap_uid?: number | null + in_reply_to?: string | null is_archived?: boolean is_read?: boolean + match_reason?: string | null + match_status?: string + match_suggestions?: Json + matched_at?: string | null message_id?: string | null notes?: string | null raw_size_bytes?: number | null @@ -2086,13 +2098,19 @@ export type Database = { case_id?: string | null cc_addresses?: string[] created_at?: string + email_references?: string[] from_address?: string | null from_name?: string | null has_attachments?: boolean id?: string imap_uid?: number | null + in_reply_to?: string | null is_archived?: boolean is_read?: boolean + match_reason?: string | null + match_status?: string + match_suggestions?: Json + matched_at?: string | null message_id?: string | null notes?: string | null raw_size_bytes?: number | null diff --git a/supabase/migrations/20260424033142_d222b217-176a-43cf-9282-83790e13c205.sql b/supabase/migrations/20260424033142_d222b217-176a-43cf-9282-83790e13c205.sql new file mode 100644 index 0000000..59e13c4 --- /dev/null +++ b/supabase/migrations/20260424033142_d222b217-176a-43cf-9282-83790e13c205.sql @@ -0,0 +1,10 @@ +ALTER TABLE public.incoming_emails + ADD COLUMN IF NOT EXISTS in_reply_to text, + ADD COLUMN IF NOT EXISTS email_references text[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS match_status text NOT NULL DEFAULT 'unmatched', + ADD COLUMN IF NOT EXISTS match_suggestions jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS match_reason text, + ADD COLUMN IF NOT EXISTS matched_at timestamptz; + +CREATE INDEX IF NOT EXISTS incoming_emails_match_status_idx ON public.incoming_emails(match_status); +CREATE INDEX IF NOT EXISTS incoming_emails_in_reply_to_idx ON public.incoming_emails(in_reply_to); \ No newline at end of file From 5924f3f4b78512a30f6b2bfe50ba64263e403294 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:32:21 +0000 Subject: [PATCH 02/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 3 +++ ...6_acdc534c-c381-4445-9bf4-99cfe5f1c8a3.sql | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 supabase/migrations/20260424033216_acdc534c-c381-4445-9bf4-99cfe5f1c8a3.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index c772f27..c8784fe 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2037,6 +2037,7 @@ export type Database = { incoming_emails: { Row: { attachment_count: number + attachments: Json body_html: string | null body_text: string | null case_id: string | null @@ -2065,6 +2066,7 @@ export type Database = { } Insert: { attachment_count?: number + attachments?: Json body_html?: string | null body_text?: string | null case_id?: string | null @@ -2093,6 +2095,7 @@ export type Database = { } Update: { attachment_count?: number + attachments?: Json body_html?: string | null body_text?: string | null case_id?: string | null diff --git a/supabase/migrations/20260424033216_acdc534c-c381-4445-9bf4-99cfe5f1c8a3.sql b/supabase/migrations/20260424033216_acdc534c-c381-4445-9bf4-99cfe5f1c8a3.sql new file mode 100644 index 0000000..70a0fe9 --- /dev/null +++ b/supabase/migrations/20260424033216_acdc534c-c381-4445-9bf4-99cfe5f1c8a3.sql @@ -0,0 +1,21 @@ +ALTER TABLE public.incoming_emails + ADD COLUMN IF NOT EXISTS attachments jsonb NOT NULL DEFAULT '[]'::jsonb; + +INSERT INTO storage.buckets (id, name, public) +VALUES ('email-attachments', 'email-attachments', false) +ON CONFLICT (id) DO NOTHING; + +CREATE POLICY "email_attachments_select_auth" + ON storage.objects FOR SELECT + TO authenticated + USING (bucket_id = 'email-attachments'); + +CREATE POLICY "email_attachments_insert_auth" + ON storage.objects FOR INSERT + TO authenticated + WITH CHECK (bucket_id = 'email-attachments'); + +CREATE POLICY "email_attachments_delete_admin" + ON storage.objects FOR DELETE + TO authenticated + USING (bucket_id = 'email-attachments' AND public.is_admin(auth.uid())); \ No newline at end of file From 66194febdf21d2f3d06788b5ab26cdd478d99a45 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:33:02 +0000 Subject: [PATCH 03/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/email-matching.ts | 246 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/lib/email-matching.ts 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 From aff994bd058f22592972090eb6571dec79d83657 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:33:29 +0000 Subject: [PATCH 04/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/email-matching.server.ts | 147 +++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/lib/email-matching.server.ts diff --git a/src/lib/email-matching.server.ts b/src/lib/email-matching.server.ts new file mode 100644 index 0000000..804343e --- /dev/null +++ b/src/lib/email-matching.server.ts @@ -0,0 +1,147 @@ +// Server-side helper that loads all the lookup data needed for matching and +// runs `matchEmailToCases` against an array of incoming_emails rows. Updates +// each row in place with the resulting status, case_id, suggestions, etc. + +import { supabaseAdmin } from "@/integrations/supabase/client.server"; +import { + matchEmailToCases, + type CaseLookup, + type ContactCaseLink, + type ClientCaseLink, + type HomeownerCaseLink, + type EmailLike, + type PriorEmail, +} from "@/lib/email-matching"; + +interface IncomingEmailRow extends EmailLike { + id: string; +} + +async function loadLookups() { + const [casesRes, contactsRes, clientsRes, homeownersRes, priorRes] = + await Promise.all([ + supabaseAdmin + .from("cases") + .select( + "id, case_number, court_case_number, case_caption, title, client_id, opposing_counsel_email, status, updated_at", + ) + .is("archived_at", null), + supabaseAdmin + .from("case_contacts") + .select("case_id, contact_id, contact:contacts(email)"), + supabaseAdmin + .from("clients") + .select("id, primary_contact_email") + .is("archived_at", null), + supabaseAdmin + .from("homeowners") + .select("id, email, client_id") + .is("archived_at", null), + supabaseAdmin + .from("incoming_emails") + .select("message_id, case_id") + .not("case_id", "is", null) + .not("message_id", "is", null) + .limit(5000), + ]); + + const cases: CaseLookup[] = (casesRes.data ?? []) as any; + + const contactLinks: ContactCaseLink[] = ((contactsRes.data ?? []) as any[]) + .map((row: any) => ({ + case_id: row.case_id, + contact_id: row.contact_id, + email: row.contact?.email ?? null, + })) + .filter((r) => r.email); + + // Build case_ids per client/homeowner + const casesByClient = new Map(); + const clientByCases = new Map(); + for (const c of cases) { + clientByCases.set(c.id, c.client_id); + if (c.client_id) { + const arr = casesByClient.get(c.client_id) ?? []; + arr.push(c.id); + casesByClient.set(c.client_id, arr); + } + } + + const clientLinks: ClientCaseLink[] = ((clientsRes.data ?? []) as any[]) + .filter((r) => r.primary_contact_email) + .map((r) => ({ + client_id: r.id, + primary_contact_email: r.primary_contact_email, + case_ids: casesByClient.get(r.id) ?? [], + })) + .filter((r) => r.case_ids.length > 0); + + const homeownerLinks: HomeownerCaseLink[] = ((homeownersRes.data ?? []) as any[]) + .filter((r) => r.email && r.client_id) + .map((r) => ({ + homeowner_id: r.id, + email: r.email, + case_ids: casesByClient.get(r.client_id) ?? [], + })) + .filter((r) => r.case_ids.length > 0); + + const priorEmails: PriorEmail[] = (priorRes.data ?? []) as any; + + return { cases, contactLinks, clientLinks, homeownerLinks, priorEmails }; +} + +export async function matchAndUpdateEmails(emailIds: string[]): Promise<{ + matched: number; + suggested: number; + unmatched: number; +}> { + if (emailIds.length === 0) return { matched: 0, suggested: 0, unmatched: 0 }; + + const { data: rowsRaw, error } = await supabaseAdmin + .from("incoming_emails") + .select( + "id, message_id, in_reply_to, email_references, from_address, to_addresses, cc_addresses, subject, body_text", + ) + .in("id", emailIds); + + if (error) throw new Error(`Failed to load emails for matching: ${error.message}`); + + const rows = (rowsRaw ?? []) as IncomingEmailRow[]; + if (rows.length === 0) return { matched: 0, suggested: 0, unmatched: 0 }; + + const lookups = await loadLookups(); + + let matched = 0, + suggested = 0, + unmatched = 0; + + for (const row of rows) { + const result = matchEmailToCases( + row, + lookups.cases, + lookups.contactLinks, + lookups.clientLinks, + lookups.homeownerLinks, + lookups.priorEmails, + ); + + const update: Record = { + match_status: result.status, + match_suggestions: result.suggestions, + match_reason: result.reason, + }; + if (result.status === "matched" && result.case_id) { + update.case_id = result.case_id; + update.matched_at = new Date().toISOString(); + matched++; + } else if (result.status === "suggested") { + suggested++; + } else { + unmatched++; + } + + await supabaseAdmin.from("incoming_emails").update(update).eq("id", row.id); + } + + return { matched, suggested, unmatched }; +} \ No newline at end of file From 0be9d7a5958992fba81bfdf9e7bb6b4d9f067d6e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:33:39 +0000 Subject: [PATCH 05/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/email-matching.server.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/lib/email-matching.server.ts b/src/lib/email-matching.server.ts index 804343e..2d3f9e9 100644 --- a/src/lib/email-matching.server.ts +++ b/src/lib/email-matching.server.ts @@ -125,22 +125,29 @@ export async function matchAndUpdateEmails(emailIds: string[]): Promise<{ lookups.priorEmails, ); - const update: Record = { + const baseUpdate = { match_status: result.status, - match_suggestions: result.suggestions, + match_suggestions: result.suggestions as unknown as never, match_reason: result.reason, }; if (result.status === "matched" && result.case_id) { - update.case_id = result.case_id; - update.matched_at = new Date().toISOString(); matched++; - } else if (result.status === "suggested") { - suggested++; + await supabaseAdmin + .from("incoming_emails") + .update({ + ...baseUpdate, + case_id: result.case_id, + matched_at: new Date().toISOString(), + }) + .eq("id", row.id); } else { - unmatched++; + if (result.status === "suggested") suggested++; + else unmatched++; + await supabaseAdmin + .from("incoming_emails") + .update(baseUpdate) + .eq("id", row.id); } - - await supabaseAdmin.from("incoming_emails").update(update).eq("id", row.id); } return { matched, suggested, unmatched }; From 7af969617e6ade9e07a41452fdca02a9cabdc47d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:34:02 +0000 Subject: [PATCH 06/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/hooks/poll-imap.ts | 61 ++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/src/routes/hooks/poll-imap.ts b/src/routes/hooks/poll-imap.ts index 3a99242..a51733c 100644 --- a/src/routes/hooks/poll-imap.ts +++ b/src/routes/hooks/poll-imap.ts @@ -5,6 +5,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { supabaseAdmin } from "@/integrations/supabase/client.server"; import { ImapFlow } from "imapflow"; import { simpleParser } from "mailparser"; +import { matchAndUpdateEmails } from "@/lib/email-matching.server"; const MAX_MESSAGES_PER_RUN = 50; @@ -82,6 +83,51 @@ export const Route = createFileRoute("/hooks/poll-imap")({ : null; const attachments = parsed.attachments ?? []; + // Upload attachments to storage; collect metadata. + const attachmentMeta: Array<{ + filename: string; + mime_type: string; + size_bytes: number; + storage_path: string; + }> = []; + for (let i = 0; i < attachments.length; i++) { + const att = attachments[i]; + const filename = + att.filename || `attachment-${i + 1}.bin`; + const safeName = filename.replace(/[^\w.\-]+/g, "_"); + const path = `${msg.uid}/${Date.now()}-${i}-${safeName}`; + try { + const { error: upErr } = await supabaseAdmin.storage + .from("email-attachments") + .upload(path, att.content as Buffer, { + contentType: att.contentType || "application/octet-stream", + upsert: true, + }); + if (upErr) { + console.error("Attachment upload failed", filename, upErr.message); + continue; + } + attachmentMeta.push({ + filename, + mime_type: att.contentType || "application/octet-stream", + size_bytes: att.size ?? (att.content as Buffer)?.length ?? 0, + storage_path: path, + }); + } catch (e) { + console.error("Attachment store error", filename, e); + } + } + + // Threading headers + const inReplyTo = + (parsed.inReplyTo as string | undefined) ?? null; + const refsRaw = (parsed.references as string | string[] | undefined) ?? []; + const references = Array.isArray(refsRaw) + ? refsRaw + : refsRaw + ? [refsRaw] + : []; + const insertRow = { message_id: parsed.messageId ?? null, imap_uid: msg.uid, @@ -94,20 +140,27 @@ export const Route = createFileRoute("/hooks/poll-imap")({ body_text: text, body_html: parsed.html || null, snippet, - has_attachments: attachments.length > 0, - attachment_count: attachments.length, + has_attachments: attachmentMeta.length > 0, + attachment_count: attachmentMeta.length, + attachments: attachmentMeta, + in_reply_to: inReplyTo, + email_references: references, raw_size_bytes: msg.size ?? null, }; // Upsert by message_id to avoid duplicates if mailbox is re-polled - const { error: insErr } = await supabaseAdmin + const { data: insertedRows, error: insErr } = await supabaseAdmin .from("incoming_emails") - .upsert(insertRow, { onConflict: "message_id", ignoreDuplicates: true }); + .upsert(insertRow, { onConflict: "message_id", ignoreDuplicates: true }) + .select("id"); if (insErr && !insErr.message.includes("duplicate")) { console.error("Insert failed for uid", msg.uid, insErr.message); } else { imported++; + if (insertedRows && insertedRows.length > 0) { + newEmailIds.push(insertedRows[0].id); + } } if (msg.uid > highestUid) highestUid = msg.uid; From f905abb05b088c454108ae540fb5056be9622a19 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:34:12 +0000 Subject: [PATCH 07/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/hooks/poll-imap.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/routes/hooks/poll-imap.ts b/src/routes/hooks/poll-imap.ts index a51733c..fbbde7f 100644 --- a/src/routes/hooks/poll-imap.ts +++ b/src/routes/hooks/poll-imap.ts @@ -41,6 +41,7 @@ export const Route = createFileRoute("/hooks/poll-imap")({ let imported = 0; let highestUid = settings.last_uid ?? 0; let errorMessage: string | null = null; + const newEmailIds: string[] = []; try { await client.connect(); @@ -191,7 +192,24 @@ export const Route = createFileRoute("/hooks/poll-imap")({ if (errorMessage) { return json({ ok: false, imported, error: errorMessage }, 500); } - return json({ ok: true, imported, last_uid: highestUid }); + + // Run case-matching on freshly imported emails. + let matchStats = { matched: 0, suggested: 0, unmatched: 0 }; + if (newEmailIds.length > 0) { + try { + matchStats = await matchAndUpdateEmails(newEmailIds); + } catch (e) { + console.error("Case matching failed", e); + } + } + + return json({ + ok: true, + imported, + last_uid: highestUid, + matched: matchStats.matched, + suggested: matchStats.suggested, + }); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error("poll-imap fatal", message); From c2ebdf2fe7f1dd3d0a4fac32979b5b2d685bf806 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:35:05 +0000 Subject: [PATCH 08/12] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/messages-tab.tsx | 313 ++++++++++++++++++++++++++ src/routes/hooks/rematch-emails.ts | 53 +++++ 2 files changed, 366 insertions(+) create mode 100644 src/components/cases/messages-tab.tsx create mode 100644 src/routes/hooks/rematch-emails.ts diff --git a/src/components/cases/messages-tab.tsx b/src/components/cases/messages-tab.tsx new file mode 100644 index 0000000..1e6cd21 --- /dev/null +++ b/src/components/cases/messages-tab.tsx @@ -0,0 +1,313 @@ +// Messages tab on the case detail page. Lists incoming emails attached to +// this case, with body preview and "save attachment to case" action. +import { useEffect, useState, useCallback } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, Mail, Paperclip, Save, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { formatDate } from "@/lib/format"; + +interface AttachmentMeta { + filename: string; + mime_type: string; + size_bytes: number; + storage_path: string; +} + +interface MessageRow { + id: string; + received_at: string; + from_address: string | null; + from_name: string | null; + to_addresses: string[]; + cc_addresses: string[]; + subject: string | null; + body_text: string | null; + body_html: string | null; + match_status: string; + match_reason: string | null; + attachments: AttachmentMeta[]; +} + +export function CaseMessagesTab({ caseId }: { caseId: string }) { + const [emails, setEmails] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [savingPath, setSavingPath] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + const { data, error } = await supabase + .from("incoming_emails") + .select( + "id, received_at, from_address, from_name, to_addresses, cc_addresses, subject, body_text, body_html, match_status, match_reason, attachments", + ) + .eq("case_id", caseId) + .order("received_at", { ascending: false }); + if (error) { + toast.error("Failed to load messages", { description: error.message }); + } else { + const rows = ((data ?? []) as any[]).map((r) => ({ + ...r, + attachments: Array.isArray(r.attachments) ? r.attachments : [], + })) as MessageRow[]; + setEmails(rows); + if (rows.length > 0 && !selectedId) setSelectedId(rows[0].id); + } + setLoading(false); + }, [caseId, selectedId]); + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [caseId]); + + const detach = async (id: string) => { + if (!confirm("Detach this email from the case? It will go back to the inbox.")) return; + const { error } = await supabase + .from("incoming_emails") + .update({ case_id: null, match_status: "unmatched", matched_at: null }) + .eq("id", id); + if (error) { + toast.error("Could not detach", { description: error.message }); + } else { + toast.success("Detached from case"); + setEmails((prev) => prev.filter((e) => e.id !== id)); + if (selectedId === id) setSelectedId(null); + } + }; + + const saveAttachment = async (att: AttachmentMeta) => { + setSavingPath(att.storage_path); + try { + // Download the bytes from the email-attachments bucket + const { data: blob, error: dlErr } = await supabase.storage + .from("email-attachments") + .download(att.storage_path); + if (dlErr || !blob) { + toast.error("Could not load attachment", { description: dlErr?.message }); + return; + } + // Upload into case-documents under this case + const targetPath = `${caseId}/email/${Date.now()}-${att.filename.replace(/[^\w.\-]+/g, "_")}`; + const { error: upErr } = await supabase.storage + .from("case-documents") + .upload(targetPath, blob, { contentType: att.mime_type, upsert: false }); + if (upErr) { + toast.error("Upload failed", { description: upErr.message }); + return; + } + const { error: insErr } = await supabase.from("documents").insert({ + case_id: caseId, + name: att.filename, + storage_path: targetPath, + mime_type: att.mime_type, + size_bytes: att.size_bytes, + folder: "email", + description: "Saved from email", + }); + if (insErr) { + toast.error("Saved file but couldn't index it", { description: insErr.message }); + return; + } + toast.success(`Saved ${att.filename} to Documents`); + } finally { + setSavingPath(null); + } + }; + + const openAttachment = async (att: AttachmentMeta) => { + const { data, error } = await supabase.storage + .from("email-attachments") + .createSignedUrl(att.storage_path, 60 * 5); + if (error || !data) { + toast.error("Could not open", { description: error?.message }); + return; + } + window.open(data.signedUrl, "_blank"); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (emails.length === 0) { + return ( + + + + No emails attached to this case yet. Incoming emails matching this + case's contacts, case number, or thread will appear here automatically. + + + ); + } + + const selected = emails.find((e) => e.id === selectedId) ?? null; + + return ( +
+ +
+ {emails.map((e) => ( + + ))} +
+
+ + + {selected ? ( + +
+
+

+ {selected.subject || "(no subject)"} +

+
+ From:{" "} + + {selected.from_name + ? `${selected.from_name} <${selected.from_address}>` + : selected.from_address} + +
+
+ To: {selected.to_addresses.join(", ")} +
+ {selected.cc_addresses.length > 0 && ( +
+ Cc: {selected.cc_addresses.join(", ")} +
+ )} +
+ {new Date(selected.received_at).toLocaleString()} +
+ {selected.match_reason && ( +
+ Matched: {selected.match_reason} +
+ )} +
+ +
+ + {selected.attachments.length > 0 && ( +
+
+ Attachments +
+ {selected.attachments.map((att) => ( +
+
+ + {att.filename} + + {formatBytes(att.size_bytes)} + +
+
+ + +
+
+ ))} +
+ )} + +
+ {selected.body_html ? ( +