import { useEffect, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, SquarePen, Trash2, X, Settings } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; import type { EmailSummary } from '../db/types'; import { useComposer, ComposeModal } from './Compose'; import { AccountsModal, useAccountsModal } from './AccountsModal'; import { emailPath, useSelectedEmailId } from './shared'; type EmailAccountRow = { id: number; provider: string; email: string; status: string; }; const LIMIT = 50; const FOLDERS = [ { key: 'inbox', label: 'Inbox', icon: Inbox }, { key: 'all', label: 'All', icon: Mail }, { key: 'sent', label: 'Sent', icon: Send }, { key: 'spam', label: 'Spam', icon: ShieldAlert }, { key: 'trash', label: 'Trash', icon: Trash2 }, ] as const; const formatDate = (iso: string) => { const date = new Date(iso); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const isThisYear = date.getFullYear() === now.getFullYear(); if (isThisYear) return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); }; export const EmailList = () => { const client = useClient(); const queryClient = useQueryClient(); const [, openCompose] = useComposer(); const [, openAccounts] = useAccountsModal(); const navigate = useNavigate(); const selectedId = useSelectedEmailId(); // Which folder you are reading is addressable state, so it is a query param rather than a global. // It stays a *param* and not a path segment because the path already spells which email is open: // `/email/:emailId` is the thing, the folder is the view you found it through, and switching folders // with a message open should not close it. const [searchParams, setSearchParams] = useSearchParams(); const folder = searchParams.get('folder') ?? 'inbox'; /* WHICH MAILBOX, on the same reasoning as the folder above: addressable state, so a query param rather than component state. Reload, share the URL or cmd-click a mailbox and you land where you meant to. Absent means `all` HERE and only here. The routes themselves treat absent as "first enabled", which `mail-scope.ts` keeps deliberately so the mobile app and the agent's email_db tool — neither of which knows accounts exist — carry on working. That default is right for a caller that cannot choose and wrong for a person looking at a screen: someone with three accounts opening their mail wants all of it, not whichever one happens to sort first. */ const account = searchParams.get('account') ?? 'all'; const qs = searchParams.toString() ? `?${searchParams}` : ''; const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const isSearching = debouncedSearch.length > 0; useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search.trim()), 300); return () => clearTimeout(t); }, [search]); // Page 1 on a new search *or* a new folder. The folder half used to live in the click handler; it // cannot any more, because the folder pills are links and nobody handles their click. useEffect(() => { setPage(1); }, [debouncedSearch, folder, account]); const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({ queryKey: ['email-accounts'], queryFn: () => client.get('/email/accounts'), }); const syncableAccount = emailAccounts.find((a) => a.status === 'connected' || a.status === 'synced'); const isSyncing = emailAccounts.some((a) => a.status === 'syncing' || a.status === 'queued'); const hasAccounts = emailAccounts.length > 0; const { data, isLoading } = useQuery({ // `account` is in the key: switching mailbox has to refetch, and without it react-query would serve // the previous mailbox's page from cache under the new selection. queryKey: isSearching ? ['email-search', debouncedSearch, page, account] : ['email-messages', page, folder, account], queryFn: () => isSearching ? client.get<{ messages: EmailSummary[]; total: number }>( `/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}&account=${account}`, ) : client.get<{ messages: EmailSummary[]; total: number }>( `/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}&account=${account}`, ), // Keep the previous results visible while the next query loads, so switching search terms/pages // never bails to the full-panel loading view (which would unmount the search input and drop focus). placeholderData: keepPreviousData, }); // Auto-switch to "all" if inbox filter returns nothing but emails exist (not while searching) const { data: allCount } = useQuery({ queryKey: ['email-messages-all-count'], queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'), enabled: !isSearching && folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0, }); useEffect(() => { if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) { // `replace`: this is the app correcting its own default, not a place you chose to be, so Back // should leave /email rather than bounce you between inbox and all. setSearchParams( (prev) => { const next = new URLSearchParams(prev); next.set('folder', 'all'); return next; }, { replace: true }, ); } }, [isSearching, folder, data?.total, allCount, setSearchParams]); // Live updates: while /email is open, listen for new-mail pushes (IMAP IDLE → SSE) and refetch. // The EventSource closes automatically when this component unmounts (i.e. when you leave /email). useEffect(() => { const token = localStorage.getItem('BEARER_TOKEN'); if (!token) return; const es = new EventSource(`/api/email/events?token=${encodeURIComponent(token)}`); es.onmessage = (ev) => { try { if ((JSON.parse(ev.data) as { type?: string }).type === 'new-mail') { queryClient.invalidateQueries({ queryKey: ['email-messages'] }); queryClient.invalidateQueries({ queryKey: ['email-messages-all-count'] }); queryClient.invalidateQueries({ queryKey: ['email-accounts'] }); } } catch { /* ignore non-JSON keepalives */ } }; return () => es.close(); }, [queryClient]); const [syncing, setSyncing] = useState(false); const handleSync = async () => { if (!syncableAccount) return; setSyncing(true); try { const result = await client.post<{ ok: boolean; saved?: number }>( `/email/accounts/${syncableAccount.id}/sync`, {}, ); if (result.saved !== undefined) { toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails'); queryClient.invalidateQueries({ queryKey: ['email-messages'] }); } else { toast.success('Sync started'); } refetchAccounts(); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to sync'); } finally { setSyncing(false); } }; // Poll accounts while first sync is running (job-based), refresh email list when done const prevSyncing = useRef(false); useEffect(() => { if (isSyncing) { const interval = setInterval(refetchAccounts, 5000); return () => clearInterval(interval); } if (prevSyncing.current && !isSyncing) { queryClient.invalidateQueries({ queryKey: ['email-messages'] }); } prevSyncing.current = isSyncing; }, [isSyncing, queryClient]); const messages = data?.messages ?? []; const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / LIMIT)); // Arrow key navigation useEffect(() => { const handleKeyDown = (ev: KeyboardEvent) => { if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') return; if (messages.length === 0) return; ev.preventDefault(); const currentIndex = selectedId ? messages.findIndex((m) => m.id === selectedId) : -1; let nextId: string | undefined; if (ev.key === 'ArrowDown') { const next = currentIndex + 1; if (next < messages.length) nextId = messages[next]!.id; } else { const prev = currentIndex - 1; if (prev >= 0) nextId = messages[prev]!.id; } if (nextId) { // `replace` on purpose: sweeping down a folder with the arrow keys would otherwise stack one // history entry per row, and Back would then walk the sweep instead of leaving the mailbox. // A click is a real link and does push. navigate(emailPath(nextId, qs), { replace: true }); document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' }); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [messages, selectedId, navigate, qs]); if (isLoading) { return
Loading emails...
; } // Show onboarding empty state only when no emails exist at all (never while searching) const hasNoEmails = !isSearching && total === 0 && folder === 'inbox' && !allCount?.total; if (hasNoEmails && !isLoading && page === 1) { return ( <> {/* The modal has to be mounted HERE too, not only in the main return below. It was only down there, and the result was a catch-22 you could not get out of: with no account the component takes this early return, "Add Account" flips the shared `EMAIL_ACCOUNTS_OPEN` global — and nothing is listening, because `` lives in the branch that renders when mail EXISTS. Clicking it did nothing at all, with no error anywhere. The only other trigger is in the toolbar, which is also in that branch, so a fresh install had no reachable way to add the first account. */}
{!hasAccounts ? (
Add an email account to get started
) : syncableAccount ? (
No emails synced yet
) : isSyncing ? (
Syncing emails...
) : ( No emails synced yet )}
); } return (
{/* THE MAILBOX PICKER, and only when there is a choice to make. One account and this is a control that can say exactly one thing, taking space in a toolbar that is already tight on a phone. It appears when a second account arrives and disappears if one is removed. Real links, like the folder pills beside it: built from the LIVE params so the folder and any open email survive the switch, and so cmd-click, middle-click and Back all behave. See platform docs/navigation-audit.md — selection state is a URL, never a channel. */} {emailAccounts.length > 1 ? (
{[ { id: 'all', label: 'All mail', title: 'Every account' }, ...emailAccounts.map((a) => ({ id: String(a.id), // The address distinguishes two mailboxes; a display name often does not. label: a.email.split('@')[0] ?? a.email, title: a.email, })), ].map((choice) => { const isActive = account === choice.id; const next = new URLSearchParams(searchParams); next.set('account', choice.id); return ( {choice.label} ); })}
) : null} {FOLDERS.map((f) => { const Icon = f.icon; const isActive = folder === f.key; // Search-only `to`, so react-router keeps the current pathname — the open email survives a // folder switch. Built from the live params rather than written flat, so anything else in // the query string comes along. const next = new URLSearchParams(searchParams); next.set('folder', f.key); return ( {f.label} ); })}
{total} {syncing || isSyncing ? ( ) : syncableAccount ? ( ) : null} {totalPages > 1 && (
{page}/{totalPages}
)}
setSearch(ev.target.value)} placeholder="Search — try from:, subject:, has:attachment…" className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40" /> {isSearching && ( {total} result{total === 1 ? '' : 's'} )} {search && ( )}
{messages.length === 0 ? (
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
) : (
{messages.map((msg: EmailSummary) => { const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read; return (
{msg.from} {!!msg.threadCount && ( {msg.threadCount} )}
{formatDate(msg.date)}
{msg.subject}
{!!msg.attachmentCount && ( {msg.attachmentCount} )} {msg.snippet}
); })}
)}
); };