diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 65e7e89..da0c777 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,7 +1,7 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useAuth } from "@/lib/auth"; import { Button } from "@/components/ui/button"; -import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard, Calendar as CalendarIcon, CheckSquare, MessageSquare, Activity, DollarSign, FolderOpen, Files as FilesIcon, Settings as SettingsIcon, ClipboardList, UserPlus, User as UserIcon } from "lucide-react"; +import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard, Calendar as CalendarIcon, CheckSquare, MessageSquare, Activity, DollarSign, FolderOpen, Files as FilesIcon, Settings as SettingsIcon, ClipboardList, UserPlus, User as UserIcon, Inbox as InboxIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; import { HeaderTimer } from "@/components/timer/header-timer"; @@ -24,6 +24,7 @@ const NAV: NavItem[] = [ { to: "/cases", label: "Cases", icon: Briefcase }, { to: "/tasks", label: "Tasks", icon: CheckSquare }, { to: "/messages", label: "Messages", icon: MessageSquare }, + { to: "/inbox", label: "Inbox", icon: InboxIcon }, { to: "/status", label: "Status Updates", icon: Activity }, { to: "/collections", label: "Collections", icon: DollarSign }, { to: "/documents", label: "Documents", icon: FolderOpen }, diff --git a/src/routes/hooks/poll-imap.ts b/src/routes/hooks/poll-imap.ts new file mode 100644 index 0000000..3a99242 --- /dev/null +++ b/src/routes/hooks/poll-imap.ts @@ -0,0 +1,157 @@ +// Server route that connects to the configured IMAP mailbox, fetches new +// messages since the last polled UID, and stores them in incoming_emails. +// Triggered by pg_cron and by the "Poll now" button in settings. +import { createFileRoute } from "@tanstack/react-router"; +import { supabaseAdmin } from "@/integrations/supabase/client.server"; +import { ImapFlow } from "imapflow"; +import { simpleParser } from "mailparser"; + +const MAX_MESSAGES_PER_RUN = 50; + +export const Route = createFileRoute("/hooks/poll-imap")({ + server: { + handlers: { + POST: async () => { + try { + const { data: settings, error: setErr } = await supabaseAdmin + .from("imap_settings") + .select("*") + .eq("enabled", true) + .order("updated_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (setErr) return json({ error: setErr.message }, 500); + if (!settings) return json({ error: "No IMAP settings configured" }, 400); + + const password = process.env.IMAP_PASSWORD; + if (!password) { + return json({ error: "IMAP_PASSWORD secret is not set" }, 400); + } + + const client = new ImapFlow({ + host: settings.host, + port: settings.port, + secure: !!settings.secure, + auth: { user: settings.username, pass: password }, + logger: false, + }); + + let imported = 0; + let highestUid = settings.last_uid ?? 0; + let errorMessage: string | null = null; + + try { + await client.connect(); + const lock = await client.getMailboxLock(settings.folder ?? "INBOX"); + + try { + const sinceUid = (settings.last_uid ?? 0) + 1; + const range = `${sinceUid}:*`; + + for await (const msg of client.fetch( + range, + { uid: true, source: true, envelope: true, size: true }, + { uid: true }, + )) { + if (imported >= MAX_MESSAGES_PER_RUN) break; + if (msg.uid <= (settings.last_uid ?? 0)) continue; + + let parsed; + try { + parsed = await simpleParser(msg.source as Buffer); + } catch (e) { + console.error("Failed to parse message uid", msg.uid, e); + continue; + } + + const fromAddr = parsed.from?.value?.[0]?.address ?? null; + const fromName = parsed.from?.value?.[0]?.name ?? null; + const toAddrs = + Array.isArray(parsed.to) + ? parsed.to.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean)) + : (parsed.to?.value?.map((v: any) => v.address).filter(Boolean) ?? []); + const ccAddrs = + Array.isArray(parsed.cc) + ? parsed.cc.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean)) + : (parsed.cc?.value?.map((v: any) => v.address).filter(Boolean) ?? []); + + const text = parsed.text ?? null; + const snippet = text + ? text.replace(/\s+/g, " ").trim().slice(0, 280) + : null; + const attachments = parsed.attachments ?? []; + + const insertRow = { + message_id: parsed.messageId ?? null, + imap_uid: msg.uid, + received_at: (parsed.date ?? new Date()).toISOString(), + from_address: fromAddr, + from_name: fromName, + to_addresses: toAddrs, + cc_addresses: ccAddrs, + subject: parsed.subject ?? null, + body_text: text, + body_html: parsed.html || null, + snippet, + has_attachments: attachments.length > 0, + attachment_count: attachments.length, + raw_size_bytes: msg.size ?? null, + }; + + // Upsert by message_id to avoid duplicates if mailbox is re-polled + const { error: insErr } = await supabaseAdmin + .from("incoming_emails") + .upsert(insertRow, { onConflict: "message_id", ignoreDuplicates: true }); + + if (insErr && !insErr.message.includes("duplicate")) { + console.error("Insert failed for uid", msg.uid, insErr.message); + } else { + imported++; + } + + if (msg.uid > highestUid) highestUid = msg.uid; + } + } finally { + lock.release(); + } + await client.logout(); + } catch (e) { + errorMessage = e instanceof Error ? e.message : String(e); + console.error("IMAP poll error:", errorMessage); + try { + await client.close(); + } catch (_) { + /* ignore */ + } + } + + await supabaseAdmin + .from("imap_settings") + .update({ + last_uid: highestUid, + last_polled_at: new Date().toISOString(), + last_error: errorMessage, + }) + .eq("id", settings.id); + + if (errorMessage) { + return json({ ok: false, imported, error: errorMessage }, 500); + } + return json({ ok: true, imported, last_uid: highestUid }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error("poll-imap fatal", message); + return json({ error: message }, 500); + } + }, + }, + }, +}); + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/routes/inbox.index.tsx b/src/routes/inbox.index.tsx new file mode 100644 index 0000000..6baef28 --- /dev/null +++ b/src/routes/inbox.index.tsx @@ -0,0 +1,299 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { ProtectedLayout } from "@/components/protected-layout"; +import { PageContainer, PageHeader } from "@/components/app-shell"; +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 { toast } from "sonner"; +import { useAuth } from "@/lib/auth"; + +export const Route = createFileRoute("/inbox/")({ + component: InboxPage, +}); + +interface IncomingEmail { + id: string; + received_at: string; + from_address: string | null; + from_name: string | null; + to_addresses: string[]; + subject: string | null; + snippet: string | null; + body_text: string | null; + body_html: string | null; + is_read: boolean; + is_archived: boolean; + has_attachments: boolean; + attachment_count: number; +} + +function InboxPage() { + const { isAdmin } = useAuth(); + const [emails, setEmails] = useState([]); + const [loading, setLoading] = useState(true); + const [polling, setPolling] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + + const load = async () => { + 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") + .order("received_at", { ascending: false }) + .limit(200); + query = showArchived ? query.eq("is_archived", true) : query.eq("is_archived", false); + const { data, error } = await query; + if (error) { + toast.error("Failed to load inbox", { description: error.message }); + } else { + setEmails((data as IncomingEmail[]) ?? []); + } + setLoading(false); + }; + + useEffect(() => { + load(); + }, [showArchived]); + + const pollNow = async () => { + setPolling(true); + try { + const res = await fetch("/hooks/poll-imap", { method: "POST" }); + const body = await res.json(); + if (!res.ok || body.error) { + toast.error("Poll failed", { description: body.error }); + } else { + toast.success(`Imported ${body.imported ?? 0} new email(s)`); + load(); + } + } catch (e) { + toast.error("Poll failed", { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setPolling(false); + } + }; + + const markRead = async (id: string, isRead: boolean) => { + await supabase.from("incoming_emails").update({ is_read: isRead }).eq("id", id); + setEmails((prev) => + prev.map((e) => (e.id === id ? { ...e, is_read: isRead } : e)), + ); + }; + + const toggleArchive = async (id: string, archive: boolean) => { + await supabase.from("incoming_emails").update({ is_archived: archive }).eq("id", id); + setEmails((prev) => prev.filter((e) => e.id !== id)); + if (selectedId === id) setSelectedId(null); + toast.success(archive ? "Archived" : "Restored"); + }; + + const deleteEmail = async (id: string) => { + if (!confirm("Delete this email permanently?")) return; + const { error } = await supabase.from("incoming_emails").delete().eq("id", id); + if (error) { + toast.error("Delete failed", { description: error.message }); + return; + } + setEmails((prev) => prev.filter((e) => e.id !== id)); + if (selectedId === id) setSelectedId(null); + }; + + const filtered = emails.filter((e) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + (e.subject ?? "").toLowerCase().includes(q) || + (e.from_address ?? "").toLowerCase().includes(q) || + (e.from_name ?? "").toLowerCase().includes(q) || + (e.snippet ?? "").toLowerCase().includes(q) + ); + }); + + const selected = emails.find((e) => e.id === selectedId) ?? null; + const unreadCount = emails.filter((e) => !e.is_read).length; + + return ( + + + + {polling ? ( + + ) : ( + + )} + Check for new mail + + } + /> + +
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( + + + + {showArchived ? "No archived emails." : "No emails yet. "} + {!showArchived && ( + <>Configure IMAP in Settings → Email (IMAP) and click "Check for new mail". + )} + + + ) : ( +
+ +
+ {filtered.map((email) => ( + + ))} +
+
+ + + {selected ? ( + +
+
+

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

+
+ From: + {selected.from_name + ? `${selected.from_name} <${selected.from_address}>` + : selected.from_address} + +
+
+ To: {selected.to_addresses.join(", ")} +
+
+ {new Date(selected.received_at).toLocaleString()} +
+
+
+ + + {isAdmin && ( + + )} +
+
+
+ {selected.body_html ? ( +