c60e3d6 made every route able to name its mailbox and left the UI unable to.
`?account=<id>` names one and `?account=all` spans them; nothing on screen
passed either, so the list showed whichever account sorted first enabled.
The selection is a QUERY PARAM, the same as the folder beside it and for the
same reason — it is addressable state, so a reload, a shared URL and a
cmd-click all land where they were meant to. The pills are real links built from
the live params, so the folder and any open email survive a mailbox switch.
`account` is in the react-query key. Without it, switching mailbox would serve
the previous one's page from cache under the new selection, which reads as the
filter not working rather than as a caching bug.
Absent means `all` in the UI and only in the UI. The routes keep treating absent
as "first enabled" — mail-scope.ts does that deliberately, because the mobile app
and the agent's email_db tool call them and know nothing about accounts. That
default is right for a caller that cannot choose and wrong for a person looking
at a screen: three accounts and you want all of it, not whichever sorts first.
Hidden with fewer than two accounts. A control that can say one thing is noise in
a toolbar that is already tight on a phone.
NOT visually verified: this machine has no email accounts configured, so the
picker cannot render here. tsgo clean, prettier clean, the 16 mail-scope tests
pass. It needs a look on a host with two accounts before it is trusted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
479 lines
20 KiB
TypeScript
479 lines
20 KiB
TypeScript
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<EmailAccountRow[]>('/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 <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
|
|
}
|
|
|
|
// 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 `<AccountsModal />` 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.
|
|
*/}
|
|
<AccountsModal />
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
|
|
<Mail className="h-8 w-8" />
|
|
{!hasAccounts ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<span>Add an email account to get started</span>
|
|
<Button variant="outline" size="sm" onClick={() => openAccounts(true)}>
|
|
Add Account
|
|
</Button>
|
|
</div>
|
|
) : syncableAccount ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<span>No emails synced yet</span>
|
|
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing || isSyncing}>
|
|
{syncing || isSyncing ? (
|
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
|
)}
|
|
Sync Now
|
|
</Button>
|
|
</div>
|
|
) : isSyncing ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
<span>Syncing emails...</span>
|
|
</div>
|
|
) : (
|
|
<span>No emails synced yet</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<ComposeModal />
|
|
<AccountsModal />
|
|
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
|
<div className="flex items-center gap-1">
|
|
{/*
|
|
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 ? (
|
|
<div className="flex items-center gap-1 border-r border-border pr-1 mr-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 (
|
|
<Link
|
|
key={choice.id}
|
|
to={{ search: `?${next}` }}
|
|
className={`max-w-[7rem] truncate rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
|
|
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
|
}`}
|
|
title={choice.title}
|
|
aria-current={isActive ? 'true' : undefined}
|
|
>
|
|
{choice.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
) : 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 (
|
|
<Link
|
|
key={f.key}
|
|
to={{ search: `?${next}` }}
|
|
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
|
|
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
|
}`}
|
|
title={f.label}
|
|
aria-current={isActive ? 'true' : undefined}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">{f.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
<span className="text-xs opacity-50">{total}</span>
|
|
{syncing || isSyncing ? (
|
|
<span title="Syncing..." className="flex shrink-0">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin opacity-50" />
|
|
</span>
|
|
) : syncableAccount ? (
|
|
<button
|
|
onClick={handleSync}
|
|
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
|
title="Sync emails"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : null}
|
|
<button
|
|
onClick={() => openAccounts(true)}
|
|
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
|
title="Email accounts"
|
|
>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => openCompose({})}
|
|
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
|
title="Compose"
|
|
>
|
|
<SquarePen className="h-3.5 w-3.5" />
|
|
</button>
|
|
{totalPages > 1 && (
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page <= 1}
|
|
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
|
|
>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</button>
|
|
<span className="text-xs opacity-60">
|
|
{page}/{totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
|
|
>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5">
|
|
<Search className="h-3.5 w-3.5 shrink-0 opacity-40" />
|
|
<input
|
|
value={search}
|
|
onChange={(ev) => setSearch(ev.target.value)}
|
|
placeholder="Search — try from:, subject:, has:attachment…"
|
|
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
|
|
/>
|
|
{isSearching && (
|
|
<span className="text-xs opacity-50 shrink-0">
|
|
{total} result{total === 1 ? '' : 's'}
|
|
</span>
|
|
)}
|
|
{search && (
|
|
<button
|
|
onClick={() => setSearch('')}
|
|
className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer"
|
|
title="Clear search"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{messages.length === 0 ? (
|
|
<div className="flex flex-1 items-center justify-center text-sm opacity-40">
|
|
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
|
{messages.map((msg: EmailSummary) => {
|
|
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
|
|
return (
|
|
<Link
|
|
key={msg.id}
|
|
to={emailPath(msg.id, qs)}
|
|
data-email-id={msg.id}
|
|
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
|
|
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>
|
|
{msg.from}
|
|
</span>
|
|
{!!msg.threadCount && (
|
|
<span
|
|
className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60"
|
|
title={`${msg.threadCount} messages`}
|
|
>
|
|
{msg.threadCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
|
|
</div>
|
|
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
{!!msg.attachmentCount && (
|
|
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
|
|
<Paperclip className="h-3 w-3" />
|
|
{msg.attachmentCount}
|
|
</span>
|
|
)}
|
|
<span className="truncate opacity-50">{msg.snippet}</span>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|