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.
410 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|