- );
-};
-
-const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>');
-
-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([]);
- const [dragging, setDragging] = useState(false);
- const [bodyEmpty, setBodyEmpty] = useState(true);
- const [sending, setSending] = useState(false);
- const seeded = useRef(null);
- const fileInput = useRef(null);
- const editorRef = useRef(null);
- const inlineMap = useRef(new Map()); // 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, ' ') : '';
- 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 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 (
-
- );
-};
-
-// 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 };
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx
deleted file mode 100644
index faf30bc5..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx
+++ /dev/null
@@ -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('/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
Loading emails...
;
- }
-
- // 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 (
-
-
- {!hasAccounts ? (
-
- Add an email account to get started
-
-
- ) : syncableAccount ? (
-
- No emails synced yet
-
-
- ) : isSyncing ? (
-
-
- Syncing emails...
-
- ) : (
- No emails synced yet
- )}
-
- );
- }
-
- return (
-
-
-
- {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 (
-
-
- {f.label}
-
- );
- })}
-
- );
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx
deleted file mode 100644
index 274992a7..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx
+++ /dev/null
@@ -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 = () => ;
-
-export const EmailScreen = () => {
- const isMobile = useIsMobile();
- const navigate = useNavigate();
- // No `` 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('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 (
-
- onMobileBack() : undefined}
- />
-
-
- );
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Email/defaultLayout.ts
deleted file mode 100644
index 5fcdc180..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Email/defaultLayout.ts
+++ /dev/null
@@ -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,
- },
- ],
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/index.tsx b/src/apps/officer-web/Screens/Dashboard/Email/index.tsx
deleted file mode 100644
index 06c73682..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Email/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { EmailScreen } from './EmailScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/shared.ts b/src/apps/officer-web/Screens/Dashboard/Email/shared.ts
deleted file mode 100644
index d154d4a2..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Email/shared.ts
+++ /dev/null
@@ -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 `