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.
239 lines
8.4 KiB
TypeScript
239 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
};
|