Added auto email-case matching

X-Lovable-Edit-ID: edt-b2cc736c-d54c-4da7-8574-cda312e6c3f1
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-24 03:36:10 +00:00
co-authored by renee-png
11 changed files with 926 additions and 8 deletions
+313
View File
@@ -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<MessageRow[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [savingPath, setSavingPath] = useState<string | null>(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 (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (emails.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground">
<Mail className="h-8 w-8 mx-auto mb-3 opacity-50" />
No emails attached to this case yet. Incoming emails matching this
case's contacts, case number, or thread will appear here automatically.
</CardContent>
</Card>
);
}
const selected = emails.find((e) => e.id === selectedId) ?? null;
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<Card className="border-border/60 overflow-hidden">
<div className="divide-y max-h-[70vh] overflow-y-auto">
{emails.map((e) => (
<button
key={e.id}
type="button"
onClick={() => setSelectedId(e.id)}
className={`w-full text-left px-3 py-3 hover:bg-muted/50 transition-colors ${
selectedId === e.id ? "bg-muted" : ""
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm">
{e.from_name || e.from_address || "Unknown sender"}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatDate(e.received_at)}
</span>
</div>
<div className="text-sm truncate mt-0.5">
{e.subject || "(no subject)"}
</div>
<div className="flex items-center gap-1.5 mt-1">
{e.attachments.length > 0 && (
<Badge variant="secondary" className="h-4 text-[9px] px-1.5">
<Paperclip className="h-2.5 w-2.5 mr-0.5" />
{e.attachments.length}
</Badge>
)}
{e.match_status === "manual" && (
<Badge variant="outline" className="h-4 text-[9px] px-1.5">
Manual
</Badge>
)}
</div>
</button>
))}
</div>
</Card>
<Card className="border-border/60">
{selected ? (
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="font-serif text-lg leading-tight">
{selected.subject || "(no subject)"}
</h2>
<div className="text-sm text-muted-foreground mt-1">
From:{" "}
<span className="text-foreground">
{selected.from_name
? `${selected.from_name} <${selected.from_address}>`
: selected.from_address}
</span>
</div>
<div className="text-xs text-muted-foreground">
To: {selected.to_addresses.join(", ")}
</div>
{selected.cc_addresses.length > 0 && (
<div className="text-xs text-muted-foreground">
Cc: {selected.cc_addresses.join(", ")}
</div>
)}
<div className="text-xs text-muted-foreground">
{new Date(selected.received_at).toLocaleString()}
</div>
{selected.match_reason && (
<div className="text-xs text-muted-foreground italic mt-1">
Matched: {selected.match_reason}
</div>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => detach(selected.id)}
>
Detach
</Button>
</div>
{selected.attachments.length > 0 && (
<div className="border rounded-md p-2 space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Attachments
</div>
{selected.attachments.map((att) => (
<div
key={att.storage_path}
className="flex items-center justify-between gap-2 text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{att.filename}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(att.size_bytes)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
onClick={() => openAttachment(att)}
title="Open"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={savingPath === att.storage_path}
onClick={() => saveAttachment(att)}
title="Save to case Documents"
>
{savingPath === att.storage_path ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
))}
</div>
)}
<div className="border-t pt-3">
{selected.body_html ? (
<iframe
title="Email body"
srcDoc={selected.body_html}
sandbox=""
className="w-full min-h-[400px] border rounded bg-white"
/>
) : (
<pre className="text-sm whitespace-pre-wrap font-sans">
{selected.body_text || "(empty)"}
</pre>
)}
</div>
</CardContent>
) : (
<CardContent className="py-16 text-center text-sm text-muted-foreground">
Select a message to read.
</CardContent>
)}
</Card>
</div>
);
}
function formatBytes(b: number): string {
if (!b) return "—";
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
+21
View File
@@ -2037,18 +2037,25 @@ export type Database = {
incoming_emails: {
Row: {
attachment_count: number
attachments: Json
body_html: string | null
body_text: string | null
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
@@ -2059,18 +2066,25 @@ export type Database = {
}
Insert: {
attachment_count?: number
attachments?: Json
body_html?: string | null
body_text?: string | null
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
@@ -2081,18 +2095,25 @@ export type Database = {
}
Update: {
attachment_count?: number
attachments?: Json
body_html?: string | null
body_text?: string | null
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
+154
View File
@@ -0,0 +1,154 @@
// 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<string, string[]>();
const clientByCases = new Map<string, string | null>();
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 baseUpdate = {
match_status: result.status,
match_suggestions: result.suggestions as unknown as never,
match_reason: result.reason,
};
if (result.status === "matched" && result.case_id) {
matched++;
await supabaseAdmin
.from("incoming_emails")
.update({
...baseUpdate,
case_id: result.case_id,
matched_at: new Date().toISOString(),
})
.eq("id", row.id);
} else {
if (result.status === "suggested") suggested++;
else unmatched++;
await supabaseAdmin
.from("incoming_emails")
.update(baseUpdate)
.eq("id", row.id);
}
}
return { matched, suggested, unmatched };
}
+246
View File
@@ -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<T>(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<string, ScoredMatch>();
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<string, string>();
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: [] };
}
+21
View File
@@ -44,6 +44,7 @@ import { Route as PayIdRouteImport } from './routes/pay.$id'
import { Route as PSlugRouteImport } from './routes/p.$slug'
import { Route as InvoicesNewRouteImport } from './routes/invoices.new'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
import { Route as HooksRematchEmailsRouteImport } from './routes/hooks/rematch-emails'
import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap'
import { Route as EmailUnsubscribeRouteImport } from './routes/email/unsubscribe'
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
@@ -242,6 +243,11 @@ const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({
path: '/invoices/$invoiceId',
getParentRoute: () => rootRouteImport,
} as any)
const HooksRematchEmailsRoute = HooksRematchEmailsRouteImport.update({
id: '/hooks/rematch-emails',
path: '/hooks/rematch-emails',
getParentRoute: () => rootRouteImport,
} as any)
const HooksPollImapRoute = HooksPollImapRouteImport.update({
id: '/hooks/poll-imap',
path: '/hooks/poll-imap',
@@ -374,6 +380,7 @@ export interface FileRoutesByFullPath {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/email/unsubscribe': typeof EmailUnsubscribeRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/hooks/rematch-emails': typeof HooksRematchEmailsRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/p/$slug': typeof PSlugRoute
@@ -432,6 +439,7 @@ export interface FileRoutesByTo {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/email/unsubscribe': typeof EmailUnsubscribeRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/hooks/rematch-emails': typeof HooksRematchEmailsRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/p/$slug': typeof PSlugRoute
@@ -492,6 +500,7 @@ export interface FileRoutesById {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/email/unsubscribe': typeof EmailUnsubscribeRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/hooks/rematch-emails': typeof HooksRematchEmailsRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/p/$slug': typeof PSlugRoute
@@ -553,6 +562,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/email/unsubscribe'
| '/hooks/poll-imap'
| '/hooks/rematch-emails'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/p/$slug'
@@ -611,6 +621,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/email/unsubscribe'
| '/hooks/poll-imap'
| '/hooks/rematch-emails'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/p/$slug'
@@ -670,6 +681,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/email/unsubscribe'
| '/hooks/poll-imap'
| '/hooks/rematch-emails'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/p/$slug'
@@ -730,6 +742,7 @@ export interface RootRouteChildren {
ContactsContactIdRoute: typeof ContactsContactIdRoute
EmailUnsubscribeRoute: typeof EmailUnsubscribeRoute
HooksPollImapRoute: typeof HooksPollImapRoute
HooksRematchEmailsRoute: typeof HooksRematchEmailsRoute
InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute
InvoicesNewRoute: typeof InvoicesNewRouteWithChildren
PSlugRoute: typeof PSlugRoute
@@ -1008,6 +1021,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InvoicesInvoiceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/hooks/rematch-emails': {
id: '/hooks/rematch-emails'
path: '/hooks/rematch-emails'
fullPath: '/hooks/rematch-emails'
preLoaderRoute: typeof HooksRematchEmailsRouteImport
parentRoute: typeof rootRouteImport
}
'/hooks/poll-imap': {
id: '/hooks/poll-imap'
path: '/hooks/poll-imap'
@@ -1239,6 +1259,7 @@ const rootRouteChildren: RootRouteChildren = {
ContactsContactIdRoute: ContactsContactIdRoute,
EmailUnsubscribeRoute: EmailUnsubscribeRoute,
HooksPollImapRoute: HooksPollImapRoute,
HooksRematchEmailsRoute: HooksRematchEmailsRoute,
InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute,
InvoicesNewRoute: InvoicesNewRouteWithChildren,
PSlugRoute: PSlugRoute,
+4 -1
View File
@@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork, Wallet, CheckSquare } from "lucide-react";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork, Wallet, CheckSquare, Mail } from "lucide-react";
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
import { ConvertToCollectionsDialog } from "@/components/cases/convert-to-collections-dialog";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
@@ -25,6 +25,7 @@ import { CaseLitigationTab } from "@/components/cases/litigation-tab";
import { CaseCollectionsTab } from "@/components/cases/collections-tab";
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
import { CaseTasksTab } from "@/components/cases/tasks-tab";
import { CaseMessagesTab } from "@/components/cases/messages-tab";
import { setArchived } from "@/lib/archive";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
@@ -321,6 +322,7 @@ function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: a
<TabsTrigger value="trust"><Wallet className="h-3.5 w-3.5 mr-1.5" />Trust</TabsTrigger>
)}
<TabsTrigger value="calls"><Phone className="h-3.5 w-3.5 mr-1.5" />Calls</TabsTrigger>
<TabsTrigger value="messages"><Mail className="h-3.5 w-3.5 mr-1.5" />Messages</TabsTrigger>
<TabsTrigger value="tasks"><CheckSquare className="h-3.5 w-3.5 mr-1.5" />Tasks</TabsTrigger>
<TabsTrigger value="custom"><Tag className="h-3.5 w-3.5 mr-1.5" />Custom fields</TabsTrigger>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
@@ -338,6 +340,7 @@ function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: a
<TabsContent value="trust"><TrustAccountPanel clientId={data.client_id} caseId={caseId} /></TabsContent>
)}
<TabsContent value="calls"><CaseCallLogsTab caseId={caseId} /></TabsContent>
<TabsContent value="messages"><CaseMessagesTab caseId={caseId} /></TabsContent>
<TabsContent value="tasks"><CaseTasksTab caseId={caseId} /></TabsContent>
<TabsContent value="custom"><CaseCustomFieldsTab caseId={caseId} /></TabsContent>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
+76 -5
View File
@@ -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;
@@ -40,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();
@@ -82,6 +84,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 +141,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;
@@ -138,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);
+53
View File
@@ -0,0 +1,53 @@
// Re-runs case matching against incoming emails.
// POST { ids?: string[] } -> rematch the listed email ids
// POST {} -> rematch all currently unmatched, non-archived emails
import { createFileRoute } from "@tanstack/react-router";
import { supabaseAdmin } from "@/integrations/supabase/client.server";
import { matchAndUpdateEmails } from "@/lib/email-matching.server";
export const Route = createFileRoute("/hooks/rematch-emails")({
server: {
handlers: {
POST: async ({ request }) => {
let ids: string[] = [];
try {
const body = (await request.json()) as { ids?: string[] };
if (Array.isArray(body?.ids)) ids = body.ids.filter((x) => typeof x === "string");
} catch {
/* no body is fine */
}
if (ids.length === 0) {
const { data, error } = await supabaseAdmin
.from("incoming_emails")
.select("id")
.is("case_id", null)
.eq("is_archived", false)
.order("received_at", { ascending: false })
.limit(500);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
ids = (data ?? []).map((r) => r.id);
}
try {
const stats = await matchAndUpdateEmails(ids);
return new Response(
JSON.stringify({ ok: true, scanned: ids.length, ...stats }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return new Response(JSON.stringify({ error: msg }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
},
});
+7 -2
View File
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Loader2, Inbox as InboxIcon, RefreshCcw, Search, Mail, MailOpen, Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { Loader2, Inbox as InboxIcon, RefreshCcw, Search, Mail, MailOpen, Archive, ArchiveRestore, Trash2, Link2 } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
@@ -29,6 +30,10 @@ interface IncomingEmail {
is_archived: boolean;
has_attachments: boolean;
attachment_count: number;
case_id: string | null;
match_status: string;
match_suggestions: Array<{ case_id: string; score: number; reasons: string[] }>;
matched_case?: { id: string; case_number: string; title: string } | null;
}
function InboxPage() {
@@ -44,7 +49,7 @@ function InboxPage() {
setLoading(true);
let query = supabase
.from("incoming_emails")
.select("id, received_at, from_address, from_name, to_addresses, subject, snippet, body_text, body_html, is_read, is_archived, has_attachments, attachment_count")
.select("id, received_at, from_address, from_name, to_addresses, subject, snippet, body_text, body_html, is_read, is_archived, has_attachments, attachment_count, case_id, match_status, match_suggestions, matched_case:cases(id, case_number, title)")
.order("received_at", { ascending: false })
.limit(200);
query = showArchived ? query.eq("is_archived", true) : query.eq("is_archived", false);
@@ -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);
@@ -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()));