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,30 @@
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { EmailAccounts } from './EmailAccounts';
|
||||
|
||||
/*
|
||||
Account management, as a modal rather than a settings page or a panel.
|
||||
|
||||
It was a section in the platform's Settings → Integrations, which a plugin cannot contribute to: a plugin
|
||||
contributes panels and nothing else. A panel was the obvious replacement and does not work either —
|
||||
`PluginScreen` renders the workspace `locked`, so a panel outside the default layout is unreachable and
|
||||
one inside it would spend a quarter of the screen on a form you touch twice a year.
|
||||
|
||||
So it follows Compose, which had already solved the same problem here: a global holds "is it open", any
|
||||
panel can set it, and the panel that mounts the modal renders it once.
|
||||
*/
|
||||
export const useAccountsModal = () => useGlobal<boolean>('EMAIL_ACCOUNTS_OPEN', false);
|
||||
|
||||
export const AccountsModal = () => {
|
||||
const [open, setOpen] = useAccountsModal();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Email accounts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EmailAccounts />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
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, '&').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<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 };
|
||||
};
|
||||
@@ -0,0 +1,509 @@
|
||||
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-destructive 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-info 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-info 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-warning/10 text-warning dark:text-warning">
|
||||
Connected
|
||||
</span>
|
||||
);
|
||||
case 'queued':
|
||||
return (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/10 text-warning dark:text-warning">
|
||||
Queued
|
||||
</span>
|
||||
);
|
||||
case 'syncing':
|
||||
return (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-info/10 text-info dark:text-info">
|
||||
Syncing
|
||||
</span>
|
||||
);
|
||||
case 'synced':
|
||||
return (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-success/10 text-success dark:text-success">
|
||||
Synced
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ProviderIcon = ({ provider }: { provider: string }) => {
|
||||
switch (provider) {
|
||||
case 'gmail':
|
||||
return <Mail className="h-4 w-4 text-destructive shrink-0" />;
|
||||
case 'outlook':
|
||||
return <Mail className="h-4 w-4 text-info shrink-0" />;
|
||||
default:
|
||||
return <Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60 shrink-0" />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ChatPanelWrapper } from 'officerdev';
|
||||
|
||||
// The prompt belongs to this panel rather than to the workspace, so the panel that gets it is the one that
|
||||
// mounts it.
|
||||
//
|
||||
// `email_db` is an MCP tool resolved at runtime from PI_TOOLS_DIRS. It is NOT in this repository and does
|
||||
// not travel with this plugin — the platform points it at the account's SQLite store. If that stops
|
||||
// happening, this prompt is describing a tool the agent does not have, which is worse than no prompt.
|
||||
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.`;
|
||||
|
||||
export const EmailChatPanel = () => <ChatPanelWrapper panelId="email-chat" promptPrefix={PROMPT_PREFIX} />;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
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 '../db/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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// The list, the message, and a chat that knows about your mail.
|
||||
//
|
||||
// `appType` names a panel in `panels.ts`; it was `null` in the platform version because the old screen
|
||||
// passed a components map keyed by panel ID and overrode them by hand. A plugin has no such hook — the
|
||||
// shell mounts panels by appType — so the two ids that were implicit are now declared.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'email-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'email-list', appType: 'email-list' }, size: 25 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'email-right',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'email-reader', appType: 'email-reader' }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'email-chat', appType: 'email-chat' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 75,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Inbox, Mail, MessageSquare } from 'lucide-react';
|
||||
import { EmailList } from './EmailList';
|
||||
import { EmailReader } from './EmailReader';
|
||||
import { EmailChatPanel } from './EmailChatPanel';
|
||||
|
||||
// `panels.ts`, not `.tsx` — discovery checks for that exact name and refuses the plugin by name if it is
|
||||
// missing, so the JSX lives in EmailChatPanel.tsx and this file stays a declaration.
|
||||
//
|
||||
// No component re-exports: the shell mounts these by key, and there is no way to export a component from a
|
||||
// plugin. `availableOnPanel: false` throughout, because PluginScreen renders the workspace `locked` — these
|
||||
// are not swappable, and offering them in a picker would be a lie.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'email-list', name: 'Mail', icon: Inbox, component: EmailList, availableOnPanel: false },
|
||||
{ key: 'email-reader', name: 'Message', icon: Mail, component: EmailReader, availableOnPanel: false },
|
||||
{ key: 'email-chat', name: 'Email chat', icon: MessageSquare, component: EmailChatPanel, availableOnPanel: false },
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user