Files
email/web/EmailList.tsx
T
Claude Opus 5 980a9d3c38 email, extracted from the officer platform
IMAP and Gmail sync into a per-account SQLite store, with the mail read and
composed in the browser.

moved whole from src/servers/sidecar/email (14 files), src/servers/api/email,
officer_db/src/email, and the Screens/Dashboard/Email tree. what changed on the
way out:

- credentials are encrypted. the column was plaintext jsonb holding an IMAP app
  password that the SMTP transport read straight out of the row; it is now
  AES-256-GCM under this plugin's own 'email' secret-store purpose, sealed and
  opened in queries.ts alone so nothing above it can hold ciphertext by
  accident. no plaintext fallback on read - a value that does not verify is an
  error.
- the router derives its prefix from mountPrefix() instead of the literal
  '/api/email', which was only correct for a first-party publisher.
- the frontend became panels.ts + layout.ts. the two panel appTypes that were
  null are now declared, because the platform screen used to override them by
  panel id and a plugin has no such hook.
- account management became a modal. it was a section in the platform's
  Settings, which a plugin cannot contribute to, and a panel does not work
  either: PluginScreen renders the workspace locked, so a panel outside the
  default layout is unreachable. it follows Compose, which had already solved
  this.
- 17 palette classes replaced with semantic tokens.

not verified: this has never run. it was switched off in the platform on
2026-08-13 and its table was never created, so nothing has exercised sync,
sending or the UI since. the first install is the first test.
2026-08-18 00:29:50 +00:00

410 lines
16 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';
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<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({
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 <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 (
<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">
{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>
);
};