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.
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user