Files
mylegal-stage-law/src/routes/inbox.index.tsx
T
2026-04-18 01:00:08 +00:00

300 lines
12 KiB
TypeScript

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<IncomingEmail[]>([]);
const [loading, setLoading] = useState(true);
const [polling, setPolling] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(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 (
<ProtectedLayout>
<PageContainer>
<PageHeader
title="Inbox"
description={`Incoming emails received via IMAP. ${unreadCount} unread.`}
actions={
<Button onClick={pollNow} disabled={polling} variant="outline" size="sm">
{polling ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Check for new mail
</Button>
}
/>
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by subject, sender..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant={showArchived ? "default" : "outline"}
size="sm"
onClick={() => {
setShowArchived((v) => !v);
setSelectedId(null);
}}
>
{showArchived ? "Showing archived" : "Show archived"}
</Button>
</div>
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-sm text-muted-foreground">
<InboxIcon className="h-8 w-8 mx-auto mb-3 opacity-50" />
{showArchived ? "No archived emails." : "No emails yet. "}
{!showArchived && (
<>Configure IMAP in <a className="underline" href="/settings/imap">Settings → Email (IMAP)</a> and click "Check for new mail".</>
)}
</CardContent>
</Card>
) : (
<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">
{filtered.map((email) => (
<button
key={email.id}
type="button"
onClick={() => {
setSelectedId(email.id);
if (!email.is_read) markRead(email.id, true);
}}
className={`w-full text-left px-3 py-3 hover:bg-muted/50 transition-colors ${
selectedId === email.id ? "bg-muted" : ""
} ${!email.is_read ? "font-medium" : ""}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm">
{email.from_name || email.from_address || "Unknown sender"}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{new Date(email.received_at).toLocaleDateString()}
</span>
</div>
<div className="text-sm truncate mt-0.5">
{email.subject || "(no subject)"}
</div>
<div className="text-xs text-muted-foreground truncate mt-0.5">
{email.snippet || "(empty body)"}
</div>
<div className="flex items-center gap-1.5 mt-1">
{!email.is_read && (
<Badge variant="default" className="h-4 text-[9px] px-1.5">NEW</Badge>
)}
{email.has_attachments && (
<Badge variant="secondary" className="h-4 text-[9px] px-1.5">
📎 {email.attachment_count}
</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>
<div className="text-xs text-muted-foreground">
{new Date(selected.received_at).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
onClick={() => markRead(selected.id, !selected.is_read)}
title={selected.is_read ? "Mark unread" : "Mark read"}
>
{selected.is_read ? <Mail className="h-4 w-4" /> : <MailOpen className="h-4 w-4" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => toggleArchive(selected.id, !selected.is_archived)}
title={selected.is_archived ? "Unarchive" : "Archive"}
>
{selected.is_archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
</Button>
{isAdmin && (
<Button
size="sm"
variant="ghost"
onClick={() => deleteEmail(selected.id)}
title="Delete"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</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 an email to read.
</CardContent>
)}
</Card>
</div>
)}
</PageContainer>
</ProtectedLayout>
);
}