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 ? ( +