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:
@@ -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`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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") && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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()));
|
||||
Reference in New Issue
Block a user