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'; 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]); 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({ queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder], queryFn: () => isSearching ? client.get<{ messages: EmailSummary[]; total: number }>( `/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`, ) : client.get<{ messages: EmailSummary[]; total: number }>( `/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`, ), // 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 (
{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}
); })}
)}
); };