agentic.email/frontend/src/components/MailboxView.jsx
2026-10-05 21:34:12 +05:30

750 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Bell,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleHelp,
Filter,
LayoutGrid,
List,
Mail,
RefreshCw,
Search,
Settings,
Sun,
Tag,
Users,
Wallet,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getEmail,
listEmails,
permanentlyDeleteEmail,
restoreEmail,
setEmailRead,
setEmailStar,
trashEmail,
} from "../api";
import AgAgentPanel from "./AgAgentPanel";
import AgentSpark from "./AgentSpark";
import CommandPalette from "./CommandPalette";
import { useAgentInsight } from "../hooks/useAgentInsight";
import { useMailboxMaturity } from "../hooks/useMailboxMaturity";
import MailDetail from "./MailDetail";
import MailRow from "./MailRow";
import MailboxEmptyState from "./MailboxEmptyState";
import MailboxSelectionBar from "./MailboxSelectionBar";
import StickyNotesPanel from "./StickyNotesPanel";
import {
categoryCounts,
emailCategory,
inboxIntelligence,
labelCounts,
matchesLens,
} from "../utils/inboxSignals";
const PAGE_SIZE = 25;
const CATEGORY_TABS = [
{ name: "Primary", icon: LayoutGrid },
{ name: "Updates", icon: Bell },
{ name: "Promotions", icon: Tag },
{ name: "Social", icon: Users },
{ name: "Finance", icon: Wallet },
{ name: "All Mail", icon: Mail },
];
function resultPage(result) {
if (Array.isArray(result)) return { items: result, nextCursor: null };
return {
items: result.items || result.emails || result.results || [],
nextCursor: result.next_cursor || result.nextCursor || null,
};
}
export default function MailboxView({
mailbox,
title,
refreshKey = 0,
account,
lens = "all",
agentNav = null,
onLogout,
onOpenSettings,
onCompose,
onStats,
}) {
const token = sessionStorage.getItem("agmail_access_token");
const [emails, setEmails] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [cursor, setCursor] = useState(null);
const [cursorHistory, setCursorHistory] = useState([]);
const [selected, setSelected] = useState(null);
const [checked, setChecked] = useState({});
const [hidden, setHidden] = useState({});
const [detail, setDetail] = useState(null);
const [status, setStatus] = useState({ loading: true, error: "" });
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
const [filtersOpen, setFiltersOpen] = useState(false);
const [category, setCategory] = useState("Primary");
const [agentFocus, setAgentFocus] = useState("");
const { insight, runInsight } = useAgentInsight();
const [commandOpen, setCommandOpen] = useState(false);
const [filters, setFilters] = useState({
query: "",
read: "all",
sender: "",
dateFrom: "",
dateTo: "",
});
const load = useCallback(async () => {
setStatus({ loading: true, error: "" });
try {
const result = await listEmails(
{
mailbox,
limit: PAGE_SIZE,
cursor,
q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read",
sender: filters.sender.trim(),
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
},
token,
);
const page = resultPage(result);
setEmails(page.items);
setNextCursor(page.nextCursor);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}, [cursor, filters, mailbox, token]);
useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
return () => window.clearTimeout(timeout);
}, [load, refreshKey]);
useEffect(() => {
function openPalette(event) {
const target = event.target;
const typing =
target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setCommandOpen(true);
return;
}
if (event.key === "/" && !typing && !commandOpen) {
event.preventDefault();
setCommandOpen(true);
}
}
window.addEventListener("keydown", openPalette);
return () => window.removeEventListener("keydown", openPalette);
}, [commandOpen]);
const visibleEmails = useMemo(
() => emails.filter((email) => !hidden[email.id] && matchesLens(email, lens)),
[emails, hidden, lens],
);
const listedEmails = useMemo(() => {
if (mailbox !== "inbox" || category === "All Mail") return visibleEmails;
return visibleEmails.filter((email) => emailCategory(email) === category);
}, [category, mailbox, visibleEmails]);
const counts = useMemo(() => categoryCounts(visibleEmails), [visibleEmails]);
const selectedIds = useMemo(
() => Object.keys(checked).filter((id) => checked[id]),
[checked],
);
const selectedCount = selectedIds.length;
const hasFilters = filters.read !== "all" || Boolean(filters.sender.trim())
|| Boolean(filters.dateFrom) || Boolean(filters.dateTo);
const hasSearch = Boolean(filters.query.trim());
const { isBrandNew } = useMailboxMaturity(account, emails, status.loading);
const isEmpty = !status.loading && !status.error && listedEmails.length === 0;
useEffect(() => {
if (!onStats) return;
const intel = inboxIntelligence(emails);
onStats({
unread: emails.filter((email) => email.is_read === false).length,
starred: emails.filter((email) => email.is_starred).length,
important: intel.important,
drafts: 0,
actionItems: intel.actionItems,
labels: labelCounts(emails),
});
}, [emails, onStats]);
function changeFilter(name, value) {
setFilters((current) => ({ ...current, [name]: value }));
setCursor(null);
setCursorHistory([]);
}
async function openEmail(email) {
setSelected(email.id);
setDetail(email);
setDetailStatus({ loading: true, error: "", busy: false });
if (email.is_read === false) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
);
setDetail((current) => ({ ...current, is_read: true }));
setEmailRead(email.id, true, token).catch(() => {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
);
setDetail((current) =>
current?.id === email.id ? { ...current, is_read: false } : current,
);
});
}
try {
const completeEmail = await getEmail(email.id, token);
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus({ loading: false, error: error.message, busy: false });
}
}
async function toggleStar(email, isStarred) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: isStarred }));
}
try {
await setEmailStar(email.id, isStarred, token);
} catch {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: !isStarred }));
}
}
}
async function markUnread(email) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
);
try {
await setEmailRead(email.id, false, token);
} catch {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: email.is_read } : item),
);
}
}
async function removeEmail(email) {
setEmails((current) => current.filter((item) => item.id !== email.id));
if (detail?.id === email.id) {
setSelected(null);
setDetail(null);
}
try {
await trashEmail(email.id, token);
} catch (error) {
setAgentFocus(error.message);
load();
}
}
function hideForSession(email, message) {
setHidden((current) => ({ ...current, [email.id]: true }));
if (detail?.id === email.id) {
setSelected(null);
setDetail(null);
}
setAgentFocus(message);
}
function summarize(email) {
const subject = email.subject?.trim() || "(No subject)";
const snippet = email.snippet || email.preview || email.body_text || "No preview available.";
setAgentFocus("");
runInsight({
title: subject,
summary: snippet,
details: [
`From ${email.sender?.name || email.sender?.email || "unknown sender"}`,
email.is_read === false ? "Marked unread — likely needs attention" : "Already read",
],
actions: ["Draft a reply", "Extract action items", "Snooze for later"],
});
}
function summarizeSelection() {
const items = listedEmails.filter((email) => checked[email.id]);
if (!items.length) return;
setAgentFocus("");
runInsight({
title: `${items.length} selected messages`,
summary: items.length === 1
? (items[0].snippet || items[0].preview || "No preview available.")
: `${items.length} conversations span ${items.filter((email) => email.is_read === false).length} unread threads.`,
details: items.slice(0, 3).map((email) => email.subject || "(No subject)"),
actions: ["Draft batch replies", "Archive completed threads", "Flag for follow-up"],
});
}
function runBulkAction(action) {
const items = listedEmails.filter((email) => checked[email.id]);
if (!items.length) return;
if (action === "archive" || action === "snooze") {
items.forEach((email) => hideForSession(
email,
action === "archive"
? "Archived in this view until you refresh."
: "Snoozed in this view until you refresh.",
));
}
if (action === "delete" || action === "spam") {
items.forEach((email) => removeEmail(email));
}
if (action === "unread") {
items.forEach((email) => markUnread(email));
}
if (action === "move" || action === "label" || action === "more") {
setAgentFocus(`${action === "move" ? "Move" : action === "label" ? "Label" : "More"} for ${items.length} selected messages is coming soon.`);
}
setChecked({});
}
function toggleSelectAll() {
if (selectedCount === listedEmails.length) {
setChecked({});
return;
}
setChecked(listedEmails.reduce((map, email) => ({ ...map, [email.id]: true }), {}));
}
function extractActions(email) {
const text = email.body_text || email.snippet || email.preview || "";
setAgentFocus("");
runInsight({
title: email.subject || "Message",
summary: text
? "Scanned the message for commitments, requests, and deadlines."
: "No plain-text content to extract actions from.",
details: text
? ["Reply requested or implied", "Review attachments if any", "Check calendar mentions"]
: ["Open the full message to extract richer actions"],
actions: ["Draft a reply", "Add to action items", "Set a reminder"],
});
}
function summarizeThread(email) {
setAgentFocus("");
runInsight({
title: email.subject || "Thread",
summary: email.snippet || email.preview || "Thread context loaded from the latest message.",
details: ["Prior messages in thread considered", "Open questions highlighted"],
actions: ["Draft a reply", "Summarize for your team", "Extract next steps"],
});
}
function analyzeAttachment(email) {
setAgentFocus("");
runInsight({
title: email.subject || "Attachments",
summary: "Reviewing files attached to this conversation.",
details: ["File types identified", "Key entities extracted from filenames"],
actions: ["Summarize document", "Find related messages", "Save to notes"],
});
}
function runQuickAction(action) {
const intel = inboxIntelligence(listedEmails);
if (action === "summarize") {
setAgentFocus("");
runInsight({
title: `${category} inbox`,
summary: listedEmails.length
? `${listedEmails.length} messages loaded. ${intel.actionItems} look like they need a reply.`
: "Nothing in this view to summarize yet.",
details: [
`${intel.meetings} mention meetings`,
`${intel.invoices} look like bills`,
`${intel.unread} unread overall`,
],
actions: ["Show action items", "Draft replies", "Find attachments"],
});
return;
}
if (action === "draft") {
const first = listedEmails.find((email) => email.is_read === false) || listedEmails[0];
setAgentFocus("");
runInsight({
title: first?.subject || "Draft replies",
summary: first
? `Ready to draft a reply to "${first.subject || "the latest message"}".`
: "No message is waiting for a reply.",
details: first ? ["Tone: professional and concise", "Context pulled from inbox signals"] : [],
actions: ["Open compose", "Suggest three reply options", "Improve existing draft"],
});
return;
}
if (action === "unread") {
changeFilter("read", "unread");
setFiltersOpen(true);
setAgentFocus(`${intel.unread} unread messages in this mailbox.`);
return;
}
if (action === "attachments" || action === "actions" || action === "important" || action === "actionItems" || action === "meetings" || action === "invoices") {
const labels = {
attachments: "attachments",
actions: "action items",
actionItems: "action items",
important: "important mail",
meetings: "meetings",
invoices: "invoices",
};
setCategory(action === "invoices" ? "Finance" : action === "important" ? "Primary" : category);
setAgentFocus(`Showing signals for ${labels[action] || action} from the messages already loaded.`);
}
}
function handleCommand(action, query = "") {
if (action === "search") {
if (query.trim()) changeFilter("query", query.trim());
window.setTimeout(() => document.getElementById("workspace-search")?.focus(), 0);
return;
}
if (action === "from") {
setFiltersOpen(true);
if (query.trim()) changeFilter("sender", query.trim());
setAgentFocus("Find emails from a specific person — use the sender filter.");
return;
}
runQuickAction(action);
}
useEffect(() => {
if (!agentNav || status.loading) return;
if (agentNav === "integrations") {
setAgentFocus("Connect CRM, calendar, and billing tools from Integrations.");
return;
}
if (agentNav === "ask" || agentNav === "summaries") {
runQuickAction("summarize");
return;
}
if (agentNav === "actions") {
runQuickAction("actions");
}
}, [agentNav, status.loading, listedEmails.length, category]);
async function toggleRead(isRead) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await setEmailRead(detail.id, isRead, token);
setDetail((current) => ({ ...current, is_read: isRead }));
setEmails((current) =>
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
);
setDetailStatus((current) => ({ ...current, busy: false }));
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
async function mutateSelected(action) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await action(detail.id, token);
setEmails((current) => current.filter((item) => item.id !== detail.id));
setSelected(null);
setDetail(null);
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
function deletePermanently() {
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
mutateSelected(permanentlyDeleteEmail);
}
}
function nextPage() {
if (!nextCursor) return;
setCursorHistory((history) => [...history, cursor]);
setCursor(nextCursor);
setSelected(null);
setDetail(null);
}
function previousPage() {
if (!cursorHistory.length) return;
const previous = cursorHistory[cursorHistory.length - 1];
setCursorHistory((history) => history.slice(0, -1));
setCursor(previous);
setSelected(null);
setDetail(null);
}
const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0;
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length;
return (
<section className="mailbox-workspace">
<header className="workspace-header">
<div className="workspace-zone workspace-zone-command">
<label className="workspace-search">
<Search size={16} />
<input
id="workspace-search"
type="search"
value={filters.query}
onChange={(event) => changeFilter("query", event.target.value)}
placeholder="Search emails, people, or ask your agent…"
/>
<button
type="button"
className="workspace-search-kbd"
aria-label="Open command palette"
onClick={() => setCommandOpen(true)}
>
<kbd>⌘K</kbd>
</button>
</label>
</div>
<div className="workspace-zone workspace-zone-actions">
<button
className={`workspace-filter${filtersOpen || hasFilters ? " active" : ""}`}
type="button"
onClick={() => setFiltersOpen((open) => !open)}
aria-expanded={filtersOpen}
aria-label="Advanced filters"
>
<Filter size={16} />
</button>
<button className="workspace-ask agent-action" type="button" onClick={() => runQuickAction("summarize")}>
<AgentSpark /> Ask Agent
</button>
</div>
<div className="workspace-zone workspace-zone-account">
<button type="button" className="workspace-icon" aria-label="Refresh" onClick={load}><RefreshCw size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Theme"><Sun size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Help"><CircleHelp size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Settings" onClick={onOpenSettings}><Settings size={16} /></button>
<button type="button" className="workspace-account" onClick={onLogout} title="Log out">
<span>{initials || "AG"}</span>
<div>
<strong>{account?.first_name} {account?.last_name}</strong>
<small>{account?.email}</small>
</div>
<ChevronDown size={14} />
</button>
</div>
</header>
<div className="mailbox-stage">
<div className="mailbox-main-column">
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
<div className="mailbox-list-pane">
{selectedCount > 0 && (
<MailboxSelectionBar
count={selectedCount}
total={listedEmails.length}
onToggleAll={toggleSelectAll}
onArchive={() => runBulkAction("archive")}
onSpam={() => runBulkAction("spam")}
onDelete={() => runBulkAction("delete")}
onMarkUnread={() => runBulkAction("unread")}
onSnooze={() => runBulkAction("snooze")}
onMove={() => runBulkAction("move")}
onLabel={() => runBulkAction("label")}
onMore={() => runBulkAction("more")}
onSummarize={summarizeSelection}
/>
)}
{mailbox === "inbox" && selectedCount === 0 && (
<div className="mailbox-categories" role="tablist" aria-label="Inbox categories">
{CATEGORY_TABS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
role="tab"
aria-selected={category === name}
className={category === name ? "active" : ""}
onClick={() => setCategory(name)}
>
<Icon size={16} strokeWidth={1.75} />
<span className="mailbox-category-label">{name}</span>
{name !== "All Mail" && counts[name] > 0 && (
<span className="mailbox-category-count">{counts[name]}</span>
)}
</button>
))}
</div>
)}
{mailbox !== "inbox" && (
<div className="mailbox-categories">
<button type="button" className="active">{title}</button>
</div>
)}
{filtersOpen && (
<div className="mailbox-filters">
<label>Read status
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
<option value="all">All messages</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
</select>
</label>
<label>Sender
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
</label>
<label>From
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
</label>
<label>To
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
</label>
</div>
)}
<div className={`mailbox-rows${isEmpty ? " is-empty" : ""}`} aria-busy={status.loading}>
{status.loading && <div className="mailbox-state loading">Loading messages…</div>}
{status.error && (
<div className="mailbox-state error" role="alert">
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
</div>
)}
{isEmpty && (
<MailboxEmptyState
mailbox={mailbox}
title={title}
category={mailbox === "inbox" ? category : null}
isBrandNew={isBrandNew}
hasFilters={hasFilters}
hasSearch={hasSearch}
lens={lens}
onCompose={onCompose}
onRefresh={load}
/>
)}
{!status.loading && !status.error && listedEmails.map((email) => (
<MailRow
key={email.id}
email={email}
selected={selected === email.id}
checked={Boolean(checked[email.id])}
onOpen={openEmail}
onToggleStar={toggleStar}
onToggleCheck={(item) => setChecked((current) => ({ ...current, [item.id]: !current[item.id] }))}
onTrash={removeEmail}
onMarkUnread={markUnread}
onArchive={(item) => hideForSession(item, "Archived in this view until you refresh.")}
onSnooze={(item) => hideForSession(item, "Snoozed in this view until you refresh.")}
onSummarize={summarize}
/>
))}
</div>
{!status.loading && (
<footer className="mailbox-pagination">
<span className="mailbox-pagination-range">
{listedEmails.length === 0
? "0 messages"
: `${rangeStart}–${rangeEnd}${nextCursor ? "" : ` · ${rangeEnd} loaded`}`}
</span>
<div className="mailbox-pagination-controls">
{listedEmails.length > 0 && (
<>
<button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}>
<ChevronLeft size={16} strokeWidth={1.75} />
</button>
<button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}>
<ChevronRight size={16} strokeWidth={1.75} />
</button>
</>
)}
</div>
<div className="mailbox-pagination-density" aria-label="List density">
<button type="button" className="active" title="Comfortable density" aria-label="Comfortable density"><List size={15} strokeWidth={1.75} /></button>
<button type="button" title="Compact density" aria-label="Compact density"><LayoutGrid size={15} strokeWidth={1.75} /></button>
</div>
</footer>
)}
<MailDetail
email={detail}
loading={detailStatus.loading}
error={detailStatus.error}
busy={detailStatus.busy}
mailbox={mailbox}
onClose={() => { setSelected(null); setDetail(null); }}
onToggleRead={toggleRead}
onTrash={() => mutateSelected(trashEmail)}
onRestore={() => mutateSelected(restoreEmail)}
onDelete={deletePermanently}
onExtractActions={() => detail && extractActions(detail)}
onSummarizeThread={() => detail && summarizeThread(detail)}
onAnalyzeAttachment={() => detail && analyzeAttachment(detail)}
/>
</div>
</div>
</div>
<aside className="mailbox-rail">
<AgAgentPanel
account={account}
emails={listedEmails}
focus={agentFocus}
insight={insight}
loading={status.loading}
mailboxEmpty={isEmpty}
isBrandNewMailbox={isBrandNew && mailbox === "inbox"}
onCompose={onCompose}
onQuickAction={runQuickAction}
onOpenEmail={(emailId) => {
const email = listedEmails.find((item) => item.id === emailId);
if (email) openEmail(email);
}}
onAgentAsk={(query) => {
setAgentFocus("");
runInsight({
title: "Your question",
summary: query,
details: ["Scanning loaded messages for relevant context"],
actions: ["Summarize matching threads", "Draft a reply", "Find related emails"],
});
}}
/>
<StickyNotesPanel compact contextEmail={detail} />
</aside>
</div>
<CommandPalette
open={commandOpen}
onClose={() => setCommandOpen(false)}
onCommand={handleCommand}
/>
</section>
);
}