email leaves the platform for plugins/email

the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.

three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (fbb6d6c7).

both survivors now read the DISK instead. findEmailAccountName() in
data-path.ts picks the newest emails.db under an account's email_accounts/
directory - no import, no query, and nothing to throw when the plugin is not
installed. the layout was already the platform's, so this costs no coupling.
the agent keeps its email_db tool and an email-context chat still opens in the
right mailbox.

the google oauth callback no longer appends /email to the dock: a plugin's
route is the plugin's business.

app-store/catalogue.ts entry deleted, and that is the whole point rather than
tidying. permissionAvailability derives from sidecar_installs and a plugin only
ever gets a plugin_installs row, so a leftover entry makes the permission
permanently unavailable - denied route, no dock tile - on a machine where the
plugin is installed and healthy. it has bitten headscale and nearly music.

two app-store tests used email as their config-only fixture; repointed at
notify, which is the same shape.

google-auth stays in core and the plugin imports it via @@/. that direction is
allowed and google-auth knows nothing about mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 00:28:13 +00:00
co-authored by Claude Opus 5
parent 4655d374ec
commit 98c37c4ce9
44 changed files with 58 additions and 5583 deletions
-2
View File
@@ -135,8 +135,6 @@ export function App() {
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
<Route path="/email" element={<Dashboard.EmailScreen />} />
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
<Route path="/browser/:tabId" element={<Dashboard.BrowserScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
@@ -1,423 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { X, Loader2, Send, Paperclip } from 'lucide-react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
export type ComposeDraft = { to?: string; cc?: string; subject?: string; body?: string; inReplyTo?: string };
// Shared open/close handle. Set a draft to open the composer; null closes it.
export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE', null);
type Contact = { address: string; name: string };
// A recipient field with contact autocomplete on the last comma-separated segment.
const RecipientInput = ({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
autoFocus?: boolean;
}) => {
const client = useClient();
const [suggestions, setSuggestions] = useState<Contact[]>([]);
const [open, setOpen] = useState(false);
const seg = value.split(',').pop()?.trim() ?? '';
useEffect(() => {
if (seg.length < 1) {
setSuggestions([]);
return;
}
const t = setTimeout(() => {
client
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
.then(setSuggestions)
.catch(() => {});
}, 180);
return () => clearTimeout(t);
}, [seg]);
const pick = (address: string) => {
const segs = value.split(',');
segs[segs.length - 1] = ` ${address}`;
onChange(segs.join(',').replace(/^\s+/, '') + ', ');
setOpen(false);
};
return (
<div className="relative">
<input
autoFocus={autoFocus}
value={value}
onChange={(ev) => {
onChange(ev.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={placeholder}
className="w-full bg-transparent px-3 py-2 text-sm outline-none placeholder:opacity-40"
/>
{open && suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full z-10 mt-0.5 max-h-56 overflow-y-auto rounded-md border bg-background shadow-lg">
{suggestions.map((s) => (
<button
key={s.address}
onMouseDown={(ev) => {
ev.preventDefault();
pick(s.address);
}}
className="flex w-full flex-col items-start px-3 py-1.5 text-left text-sm hover:bg-accent cursor-pointer"
>
{s.name && <span className="font-medium">{s.name}</span>}
<span className={s.name ? 'text-xs opacity-60' : ''}>{s.address}</span>
</button>
))}
</div>
)}
</div>
);
};
const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
export const ComposeModal = () => {
const client = useClient();
const queryClient = useQueryClient();
const [draft, setDraft] = useComposer();
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [showCc, setShowCc] = useState(false);
const [bcc, setBcc] = useState('');
const [showBcc, setShowBcc] = useState(false);
const [subject, setSubject] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [dragging, setDragging] = useState(false);
const [bodyEmpty, setBodyEmpty] = useState(true);
const [sending, setSending] = useState(false);
const seeded = useRef<ComposeDraft | null>(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<HTMLDivElement | null>(null);
const inlineMap = useRef(new Map<string, File>()); // data-inline-id → File, for the images embedded in the body
const nextImgId = useRef(0);
const refreshEmpty = () => {
const el = editorRef.current;
setBodyEmpty(!el || (!el.textContent?.trim() && !el.querySelector('img')));
};
// Free object URLs for any images still in the editor, and clear the id→File map.
const revokeInline = () => {
editorRef.current?.querySelectorAll('img[data-inline-id]').forEach((img) => {
const src = (img as HTMLImageElement).src;
if (src.startsWith('blob:')) URL.revokeObjectURL(src);
});
inlineMap.current.clear();
};
useEffect(() => {
if (draft && draft !== seeded.current) {
seeded.current = draft;
setTo(draft.to ?? '');
setCc(draft.cc ?? '');
setShowCc(!!draft.cc);
setBcc('');
setShowBcc(false);
setSubject(draft.subject ?? '');
setFiles([]);
inlineMap.current.clear();
nextImgId.current = 0;
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
if (editorRef.current)
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
refreshEmpty();
}
if (!draft) seeded.current = null;
}, [draft]);
// Clipboard images often come nameless — give them a sensible filename.
const named = (f: File) =>
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
const addAttachments = (incoming: FileList | File[]) => {
const list = Array.from(incoming).map(named);
if (list.length) setFiles((prev) => [...prev, ...list]);
};
// Insert an image into the body at the given range (or the current caret, or the end).
const insertImage = (file: File, at?: Range | null) => {
const el = editorRef.current;
if (!el) return;
const id = `img-${nextImgId.current++}`;
inlineMap.current.set(id, file);
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.setAttribute('data-inline-id', id);
img.style.maxWidth = '100%';
img.style.height = 'auto';
const sel = window.getSelection();
const caret = at ?? (sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.getRangeAt(0) : null);
if (caret && el.contains(caret.startContainer)) {
caret.deleteContents();
caret.insertNode(img);
caret.setStartAfter(img);
caret.collapse(true);
sel?.removeAllRanges();
sel?.addRange(caret);
} else {
el.appendChild(img);
}
refreshEmpty();
};
// Paste inside the body: images insert at the caret, other files attach, text pastes as plain text.
const onEditorPaste = (ev: React.ClipboardEvent) => {
const fileItems = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file');
if (fileItems.length) {
ev.preventDefault();
fileItems
.map((it) => it.getAsFile())
.filter((f): f is File => !!f)
.map(named)
.forEach((f) => (f.type.startsWith('image/') ? insertImage(f) : addAttachments([f])));
return;
}
const text = ev.clipboardData?.getData('text/plain');
if (text) {
ev.preventDefault();
document.execCommand('insertText', false, text);
}
};
const onDrop = (ev: React.DragEvent) => {
ev.preventDefault();
setDragging(false);
const dropped = Array.from(ev.dataTransfer?.files ?? []).map(named);
if (!dropped.length) return;
// Place images at the drop point if it lands inside the body, otherwise append at the end.
const el = editorRef.current;
const point = document.caretRangeFromPoint?.(ev.clientX, ev.clientY) ?? null;
const range = point && el?.contains(point.startContainer) ? point : null;
dropped.forEach((f) => (f.type.startsWith('image/') ? insertImage(f, range) : addAttachments([f])));
};
const close = () => {
revokeInline();
setDraft(null);
};
const send = async () => {
if (!to.trim()) {
toast.error('Add at least one recipient');
return;
}
setSending(true);
try {
const fd = new FormData();
fd.append('to', to);
if (cc.trim()) fd.append('cc', cc);
if (bcc.trim()) fd.append('bcc', bcc);
fd.append('subject', subject);
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
files.forEach((f) => fd.append('files', f));
// Serialize the body. Clone it, rewrite each inline <img> to a `cid:inline-N` ref (backend
// matches by order), and collect those files. Plain text goes as the fallback part.
const el = editorRef.current;
const inlineFiles: File[] = [];
if (el) {
const clone = el.cloneNode(true) as HTMLElement;
clone.querySelectorAll('img[data-inline-id]').forEach((node) => {
const img = node as HTMLImageElement;
const file = inlineMap.current.get(img.getAttribute('data-inline-id') ?? '');
if (!file) return img.remove();
img.setAttribute('src', `cid:inline-${inlineFiles.length}`);
img.removeAttribute('data-inline-id');
inlineFiles.push(file);
});
fd.append('body', el.innerText);
if (inlineFiles.length) {
fd.append('html', clone.innerHTML);
inlineFiles.forEach((f) => fd.append('inline', f));
}
}
await client.post('/email/send', fd);
toast.success('Email sent');
// Gmail files the sent copy in Sent, so refresh the list to surface it.
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
close();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
} finally {
setSending(false);
}
};
const fmtSize = (n: number) =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
if (!draft) return null;
return (
<Dialog open onOpenChange={(o) => !o && close()}>
<DialogContent
className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
onDrop={onDrop}
onDragOver={(ev) => {
ev.preventDefault();
setDragging(true);
}}
onDragLeave={(ev) => {
if (ev.currentTarget === ev.target) setDragging(false);
}}
>
{dragging && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md border-2 border-dashed border-duck-teal bg-background/80 text-sm font-medium">
Drop images to inline · other files attach
</div>
)}
<div className="flex items-center border-b px-4 py-2.5 pr-10">
<DialogTitle className="text-sm font-semibold">{/^re:/i.test(subject) ? 'Reply' : 'New message'}</DialogTitle>
</div>
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">To</span>
<div className="flex-1">
<RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
</div>
<div className="flex items-center gap-2 px-3">
{!showCc && (
<button onClick={() => setShowCc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
Cc
</button>
)}
{!showBcc && (
<button onClick={() => setShowBcc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
Bcc
</button>
)}
</div>
</div>
{showCc && (
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">Cc</span>
<div className="flex-1">
<RecipientInput value={cc} onChange={setCc} placeholder="Cc recipients" />
</div>
</div>
)}
{showBcc && (
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">Bcc</span>
<div className="flex-1">
<RecipientInput value={bcc} onChange={setBcc} placeholder="Bcc recipients" />
</div>
</div>
)}
<input
value={subject}
onChange={(ev) => setSubject(ev.target.value)}
placeholder="Subject"
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
/>
<div className="relative flex-1 overflow-hidden">
{bodyEmpty && (
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
)}
<div
ref={editorRef}
contentEditable
onInput={refreshEmpty}
onPaste={onEditorPaste}
className="h-full overflow-y-auto px-4 py-3 text-sm outline-none [&_img]:my-1 [&_img]:rounded"
/>
</div>
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
{files.map((f, i) => (
<span key={`${f.name}-${i}`} className="flex items-center gap-1.5 rounded bg-accent px-2 py-1 text-xs">
<Paperclip className="h-3 w-3 opacity-60" />
<span className="max-w-[16rem] truncate">{f.name}</span>
<span className="opacity-50">{fmtSize(f.size)}</span>
<button
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
className="opacity-50 hover:opacity-100 cursor-pointer"
title="Remove"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5">
<input
ref={fileInput}
type="file"
multiple
className="hidden"
onChange={(ev) => {
if (ev.target.files?.length) addAttachments(ev.target.files);
ev.target.value = '';
}}
/>
<button
onClick={() => fileInput.current?.click()}
className="mr-auto flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
title="Attach files"
>
<Paperclip className="h-4 w-4" />
</button>
<button
onClick={close}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel
</button>
<button
onClick={send}
disabled={sending}
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-4 py-1.5 text-sm font-medium text-white hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
>
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Send
</button>
</div>
</DialogContent>
</Dialog>
);
};
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
// is a follow-up: store the Message-Id header on ingest.)
export const replyDraft = (m: {
from: string;
subject: string;
date: string;
text?: string;
snippet?: string;
}): ComposeDraft => {
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
const original = (m.text || m.snippet || '').trim();
const quoted = original
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
.split('\n')
.map((l) => `> ${l}`)
.join('\n')}`
: '';
return { to: addr, subject, body: quoted };
};
@@ -1,399 +0,0 @@
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,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import type { EmailSummary } from 'types';
import { useComposer } from './Compose';
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 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" asChild>
<Link to="/settings/integrations">Add Account</Link>
</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">
<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={() => 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>
);
};
@@ -1,238 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail, Reply, Paperclip } from 'lucide-react';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import type { EmailMessage, EmailThread } from 'types';
import { useComposer, replyDraft } from './Compose';
import { useSelectedEmailId } from './shared';
type OpenAttachment = {
filePath: string;
fileName: string;
root: string;
};
const senderName = (from: string) => from.replace(/\s*<[^>]+>$/, '').trim() || from;
const HtmlBody = ({ html }: { html: string }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #1a1a1a; background: #ffffff; }
a { color: #1a73e8; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${html}</body>
</html>
`);
doc.close();
// Auto-size to content so each message sits at its natural height inside the stacked thread.
const resize = () => {
if (doc.body) iframe.style.height = `${doc.body.scrollHeight + 8}px`;
};
resize();
doc.querySelectorAll('img').forEach((img) => img.addEventListener('load', resize));
const t = setTimeout(resize, 300);
return () => clearTimeout(t);
}, [html]);
return <iframe ref={iframeRef} className="w-full border-0" sandbox="allow-same-origin" title="Email body" />;
};
type MessagePanelProps = {
message: EmailMessage;
open: boolean;
onToggle: () => void;
onReply: () => void;
onOpenAttachment: (index: number) => void;
};
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
if (!open) {
return (
<button
onClick={onToggle}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer"
>
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>
{senderName(message.from)}
</span>
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
</button>
);
}
return (
<div className="flex flex-col">
<div className="flex items-start justify-between gap-3 px-4 py-2.5">
<button onClick={onToggle} className="min-w-0 flex-1 text-left cursor-pointer">
<div className="flex flex-col gap-0.5 text-sm opacity-80">
<div>
<span className="font-medium">{message.from}</span>
</div>
<div className="text-xs opacity-70">
<span className="font-medium">To:</span> {message.to}
</div>
{message.cc && (
<div className="text-xs opacity-70">
<span className="font-medium">Cc:</span> {message.cc}
</div>
)}
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
</div>
</button>
<button
onClick={onReply}
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
title="Reply"
>
<Reply className="h-3.5 w-3.5" /> Reply
</button>
</div>
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pb-2">
{message.attachments.map((att, i) => (
<button
key={i}
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
onClick={() => onOpenAttachment(i)}
>
{att.filename} ({Math.round(att.size / 1024)}KB)
</button>
))}
</div>
)}
{message.html ? (
<HtmlBody html={message.html} />
) : (
<pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>
)}
</div>
);
};
export const EmailReader = () => {
const client = useClient();
const queryClient = useQueryClient();
const [, openCompose] = useComposer();
// Reads the URL itself rather than being told by the list — the two panels never talk.
const selectedId = useSelectedEmailId();
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const { data: thread, isLoading } = useQuery({
queryKey: ['email-thread', selectedId],
queryFn: () => client.get<EmailThread>(`/email/thread/${selectedId}`),
enabled: !!selectedId,
});
// Whenever the thread changes, expand just the latest message (Gmail-style).
useEffect(() => {
const messages = thread?.messages;
if (messages?.length) setExpanded(new Set([messages[messages.length - 1]!.id]));
}, [thread?.id, thread?.messages.length]);
// Mark the whole conversation as read when opened.
useEffect(() => {
if (!thread || !selectedId || !thread.messages.some((m: EmailMessage) => !m.read)) return;
client
.patch(`/email/thread/${selectedId}/read`)
.then(() => queryClient.invalidateQueries({ queryKey: ['email-messages'] }))
.catch(() => {});
}, [thread?.id]);
const extractAttachment = async (messageId: string, index: number) => {
const result = await client.post<OpenAttachment>(`/email/messages/${messageId}/attachments/${index}/extract`);
setOpenAttachment(result);
};
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
if (!selectedId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Mail className="h-10 w-10" />
Select an email to read
</div>
);
}
if (isLoading) {
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading...</div>;
}
if (!thread || thread.messages.length === 0) return null;
const latest = thread.messages[thread.messages.length - 1]!;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<h2 className="text-lg font-semibold">{thread.subject}</h2>
<button
onClick={() => openCompose(replyDraft(latest))}
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
title="Reply"
>
<Reply className="h-3.5 w-3.5" /> Reply
</button>
</div>
<div className="flex-1 divide-y divide-white/10 overflow-auto">
{thread.messages.map((m: EmailMessage) => (
<MessagePanel
key={m.id}
message={m}
open={expanded.has(m.id)}
onToggle={() => toggle(m.id)}
onReply={() => openCompose(replyDraft(m))}
onOpenAttachment={(index) => extractAttachment(m.id, index)}
/>
))}
</div>
<Dialog open={!!openAttachment} onOpenChange={(open) => !open && setOpenAttachment(null)}>
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
{openAttachment && (
<FileViewerProvider
filePath={openAttachment.filePath}
fileName={openAttachment.fileName}
root={openAttachment.root}
>
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
<FileViewerHeader />
</div>
<div className="min-h-0 flex-1">
<FileViewerBody />
</div>
</FileViewerProvider>
)}
</DialogContent>
</Dialog>
</div>
);
};
@@ -1,54 +0,0 @@
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { useIsMobile } from 'hooks/useIsMobile';
import { defaultLayout } from './defaultLayout';
import { EmailList } from './EmailList';
import { EmailReader } from './EmailReader';
import { ComposeModal } from './Compose';
import { useSelectedEmailId } from './shared';
const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite email database available via the "email_db" tool — use it for all email queries (search, count, stats, aggregations, deletions) unless the user explicitly asks you to use Gmail. Do not use the Gmail integration for questions about existing emails.`;
// The prefix is this screen's, so this screen mounts the chat panel that gets it — rather than handing
// the string to the workspace to courier down to whichever app happens to read it. The panel id is the
// one in `defaultLayout`, and the layout is locked, so it is the same panel either way.
const EmailChatPanel = () => <ChatPanelWrapper panelId="email-chat" promptPrefix={PROMPT_PREFIX} />;
export const EmailScreen = () => {
const isMobile = useIsMobile();
const navigate = useNavigate();
// No `<Navigate>` guard: unlike a section route, the bare /email is a real state — the list with
// nothing open — exactly as /chat and /jobs are. An id that doesn't resolve is the reader's problem,
// not a reason to rewrite the address the user is looking at.
const selectedId = useSelectedEmailId();
const workspace = useDashboardState<LayoutNode>('screens/email', defaultLayout);
const components: PanelComponents = useMemo(
() => ({
'email-list': EmailList,
'email-reader': EmailReader,
'email-chat': EmailChatPanel,
}),
[],
);
const mobilePanelId = isMobile && selectedId ? 'email-reader' : undefined;
// Back on mobile closes the email, which is now just navigating to the bare route.
const onMobileBack = useCallback(() => navigate('/email'), [navigate]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
components={components}
mobilePanelId={mobilePanelId}
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/>
<ComposeModal />
</div>
);
};
@@ -1,22 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'email-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'email-list', appType: null }, size: 25 },
{
node: {
type: 'group',
id: 'email-right',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'email-reader', appType: null }, size: 60 },
{ node: { type: 'panel', id: 'email-chat', appType: 'officerdev/chat' }, size: 40 },
],
},
size: 75,
},
],
};
@@ -1 +0,0 @@
export { EmailScreen } from './EmailScreen';
@@ -1,13 +0,0 @@
import { useParams } from 'react-router';
// Which email is open is `/email/:emailId`. The route pair already existed, but the URL was a mirror
// rather than the state: the selection lived in an `EMAIL_SELECTED` global and two effects copied it
// back and forth with the route. That round-trip is what kept a row a `<button>` — nothing to link to,
// no cmd-click, no middle-click — and it meant Back raced the effect that had just written the URL
// with `replace`. The param is the state now; there is nothing to keep in sync.
/** `search` carries the folder you are reading in (`?folder=sent`), which a bare pathname would drop. */
export const emailPath = (id: string, search = '') => `/email/${encodeURIComponent(id)}${search}`;
/** The open email, or null on the bare `/email` route — which is a real state, not one to redirect away. */
export const useSelectedEmailId = (): string | null => useParams<{ emailId?: string }>().emailId ?? null;
@@ -1,509 +0,0 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2, Mail, Server, KeyRound, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useJobs } from 'hooks/useJobs';
type EmailAccountRow = {
id: number;
provider: string;
email: string;
displayName: string | null;
enabled: boolean;
status: string;
createdAt: string;
};
type ProviderChoice = 'gmail-oauth' | 'gmail-password' | 'imap';
type FormState = {
email: string;
displayName: string;
password: string;
imapHost: string;
imapPort: string;
imapSecure: boolean;
};
const INITIAL_FORM: FormState = {
email: '',
displayName: '',
password: '',
imapHost: '',
imapPort: '993',
imapSecure: true,
};
export const EmailAccounts = () => {
const client = useClient();
const { user } = useAuth();
const { jobs } = useJobs({ type: 'email-sync' });
const [accounts, setAccounts] = useState<EmailAccountRow[]>([]);
const [loading, setLoading] = useState(true);
const [showPicker, setShowPicker] = useState(false);
const [activeForm, setActiveForm] = useState<ProviderChoice | null>(null);
const [form, setForm] = useState<FormState>(INITIAL_FORM);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null);
const [syncing, setSyncing] = useState<number | null>(null);
const fetchAccounts = () => {
client
.get<EmailAccountRow[]>('/email/accounts')
.then(setAccounts)
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => {
fetchAccounts();
}, []);
// Poll accounts while any are queued/syncing to pick up status changes
const hasSyncingAccount = accounts.some((a) => a.status === 'syncing' || a.status === 'queued');
useEffect(() => {
if (!hasSyncingAccount) return;
const interval = setInterval(fetchAccounts, 5000);
return () => clearInterval(interval);
}, [hasSyncingAccount]);
const resetForm = () => {
setActiveForm(null);
setShowPicker(false);
setForm(INITIAL_FORM);
};
const handlePickProvider = (choice: ProviderChoice) => {
setShowPicker(false);
setActiveForm(choice);
if (choice === 'gmail-oauth' || choice === 'gmail-password') {
setForm({ ...INITIAL_FORM, imapHost: 'imap.gmail.com', imapPort: '993', imapSecure: true });
} else {
setForm(INITIAL_FORM);
}
};
const handleGmailOAuth = async () => {
setSaving(true);
try {
const status = await client.get<{ connected: boolean; email: string | null }>('/integrations/google/status');
if (!status.connected) {
const params = new URLSearchParams({ token: client.token ?? '', origin: window.location.origin });
window.location.href = `/api/integrations/google/authorize?${params.toString()}`;
return;
}
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
provider: 'gmail',
email: status.email,
displayName: form.displayName || undefined,
imapHost: 'imap.gmail.com',
imapPort: 993,
imapSecure: true,
authType: 'oauth',
credentials: { userIntegrationId: true },
});
toast.success(`Added ${result.email}`);
resetForm();
fetchAccounts();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add Gmail account');
} finally {
setSaving(false);
}
};
const handlePasswordSubmit = async () => {
if (!form.email.trim() || !form.password.trim()) return;
setSaving(true);
try {
const provider = form.imapHost === 'imap.gmail.com' ? 'gmail' : 'imap';
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
provider,
email: form.email.trim(),
displayName: form.displayName.trim() || undefined,
imapHost: form.imapHost,
imapPort: Number(form.imapPort),
imapSecure: form.imapSecure,
authType: 'password',
credentials: { password: form.password },
});
toast.success(`Added ${result.email}`);
resetForm();
fetchAccounts();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add account');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
setDeleting(id);
try {
await client.delete(`/email/accounts/${id}`);
setAccounts((prev) => prev.filter((a) => a.id !== id));
toast.success('Account removed');
} catch {
toast.error('Failed to remove account');
} finally {
setDeleting(null);
}
};
const handleSync = async (id: number) => {
setSyncing(id);
try {
const result = await client.post<{ ok: boolean; saved?: number }>(`/email/accounts/${id}/sync`, {});
if (result.saved !== undefined) {
toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails');
} else {
toast.success('Sync started');
setAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'queued' } : a)));
}
fetchAccounts();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to sync');
} finally {
setSyncing(null);
}
};
if (loading) return null;
return (
<div className="grid gap-4">
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Email Accounts</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
Connect email accounts for syncing your inbox. Supports Gmail and any IMAP provider.
</p>
</div>
{/* Account list */}
{accounts.length > 0 && (
<div className="grid gap-2">
{accounts.map((account) => {
const accountJob = jobs.find(
(j) =>
(j.status === 'queued' || j.status === 'running') &&
(j.meta as Record<string, unknown> | undefined)?.emailAccountId === account.id,
);
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
return (
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="flex items-center gap-3">
<ProviderIcon provider={account.provider} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground truncate">
{account.displayName ?? account.email}
</p>
{account.displayName && (
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{account.email}</p>
)}
</div>
<StatusBadge status={account.status} />
{account.status === 'queued' && (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">Waiting...</span>
)}
{(account.status === 'connected' || account.status === 'synced') && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleSync(account.id)}
disabled={syncing === account.id}
className="h-7 text-xs cursor-pointer gap-1.5"
>
{syncing === account.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Sync
</Button>
)}
<button
onClick={() => handleDelete(account.id)}
disabled={deleting === account.id || account.status === 'syncing' || account.status === 'queued'}
className="text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
{deleting === account.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
{/* Sync progress */}
{account.status === 'syncing' && progress && (
<div className="mt-2 flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
<p className="text-xs text-duck-dark/60 dark:text-foreground/60 truncate">
{progress.label ?? 'Syncing...'}
</p>
</div>
)}
{account.status === 'syncing' && !progress && (
<div className="mt-2 flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">Syncing...</p>
</div>
)}
</div>
);
})}
</div>
)}
{/* Provider picker */}
{showPicker && !activeForm && (
<div className="grid gap-2">
<button
onClick={() => handlePickProvider('gmail-oauth')}
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
>
<Mail className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (OAuth)</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Uses your existing Google connection</p>
</div>
</button>
<button
onClick={() => handlePickProvider('gmail-password')}
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
>
<KeyRound className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (App Password)</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Direct IMAP with a Google App Password
</p>
</div>
</button>
<button
onClick={() => handlePickProvider('imap')}
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
>
<Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">IMAP Account</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Any email provider with IMAP access</p>
</div>
</button>
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
</div>
)}
{/* Gmail OAuth form */}
{activeForm === 'gmail-oauth' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (OAuth)</p>
<input
type="text"
value={form.displayName}
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
placeholder="Display name (optional)"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
<Button type="button" onClick={handleGmailOAuth} disabled={saving} className="h-9 flex-1 cursor-pointer">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Connect & Add'}
</Button>
</div>
</div>
)}
{/* Gmail App Password form */}
{activeForm === 'gmail-password' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (App Password)</p>
<div className="text-xs text-duck-dark/50 dark:text-foreground/50 grid gap-1.5">
<p>To create an App Password:</p>
<ol className="list-decimal ml-4 grid gap-0.5">
<li>
Go to{' '}
<a
href="https://myaccount.google.com/apppasswords"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
>
Google App Passwords
</a>
</li>
<li>You may need to enable 2-Step Verification first</li>
<li>Enter a name (e.g. "Officer") and click Create</li>
<li>Copy the 16-character password and paste it below</li>
</ol>
</div>
<input
type="email"
value={form.email}
onChange={(ev) => setForm({ ...form, email: ev.target.value })}
placeholder="your@gmail.com"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="text"
value={form.displayName}
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
placeholder="Display name (optional)"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="password"
value={form.password}
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
placeholder="xxxx xxxx xxxx xxxx"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
<Button
type="button"
onClick={handlePasswordSubmit}
disabled={!form.email.trim() || !form.password.trim() || saving}
className="h-9 flex-1 cursor-pointer"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
</Button>
</div>
</div>
)}
{/* Generic IMAP form */}
{activeForm === 'imap' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add IMAP Account</p>
<input
type="email"
value={form.email}
onChange={(ev) => setForm({ ...form, email: ev.target.value })}
placeholder="you@example.com"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="text"
value={form.displayName}
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
placeholder="Display name (optional)"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="grid grid-cols-[1fr_auto_auto] gap-2">
<input
type="text"
value={form.imapHost}
onChange={(ev) => setForm({ ...form, imapHost: ev.target.value })}
placeholder="imap.example.com"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="number"
value={form.imapPort}
onChange={(ev) => setForm({ ...form, imapPort: ev.target.value })}
placeholder="993"
className="h-9 w-20 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<label className="flex items-center gap-1.5 text-xs text-duck-dark/60 dark:text-foreground/60 cursor-pointer">
<input
type="checkbox"
checked={form.imapSecure}
onChange={(ev) => setForm({ ...form, imapSecure: ev.target.checked })}
className="cursor-pointer"
/>
SSL
</label>
</div>
<input
type="password"
value={form.password}
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
placeholder="Password"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
<Button
type="button"
onClick={handlePasswordSubmit}
disabled={!form.email.trim() || !form.password.trim() || !form.imapHost.trim() || saving}
className="h-9 flex-1 cursor-pointer"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
</Button>
</div>
</div>
)}
{/* Add account button */}
{!showPicker && !activeForm && (
<Button
type="button"
variant="outline"
onClick={() => setShowPicker(true)}
className="w-full h-11 cursor-pointer gap-2"
>
<Plus className="h-4 w-4" />
Add Account
</Button>
)}
</div>
);
};
const StatusBadge = ({ status }: { status: string }) => {
switch (status) {
case 'connected':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
Connected
</span>
);
case 'queued':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600 dark:text-orange-400">
Queued
</span>
);
case 'syncing':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
Syncing
</span>
);
case 'synced':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
Synced
</span>
);
default:
return null;
}
};
const ProviderIcon = ({ provider }: { provider: string }) => {
switch (provider) {
case 'gmail':
return <Mail className="h-4 w-4 text-red-500 shrink-0" />;
case 'outlook':
return <Mail className="h-4 w-4 text-blue-500 shrink-0" />;
default:
return <Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60 shrink-0" />;
}
};
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, Mail, CalendarDays } from 'lucide-react';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, CalendarDays } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
@@ -11,7 +11,6 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { BrowserRelay } from './BrowserRelay';
import { ApifyConfig } from './ApifyConfig';
import { EmailAccounts } from './EmailAccounts';
import { DavAppPasswords } from './DavAppPasswords';
import { ApiKeys } from './ApiKeys';
@@ -35,13 +34,8 @@ const enterpriseSections: SettingsSection[] = [
];
const personalSections: SettingsSection[] = [
{
key: 'email-accounts',
icon: Mail,
title: 'Email',
description: 'Connect email accounts for inbox sync',
content: <EmailAccounts />,
},
// Email account management left with the feature on 2026-08-18 (`plugins/email/`). A plugin contributes
// panels, not settings sections, so it now lives inside the email workspace itself.
// Google OAuth is left in place (code + DB) but hidden — email now uses an app password over IMAP,
// so there's no reason to reconnect Google here. Re-enable this section to bring it back.
// {
@@ -26,7 +26,6 @@ export * from './CodeEditor';
export * from './ChatHistory';
export * from './Dashboards';
export * from './Terminal';
export * from './Email';
export * from './Browser';
export * from './Desktop';
export * from './Jobs';
@@ -17,7 +17,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/settings/system'), title: 'System' },
{ match: (p) => p.startsWith('/settings'), title: 'Settings' },
{ match: (p) => p.startsWith('/chat'), title: 'Chat' },
{ match: (p) => p.startsWith('/email'), title: 'Email' },
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },