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:
Claude Opus 5
2026-08-18 00:29:50 +00:00
commit 980a9d3c38
30 changed files with 5616 additions and 0 deletions
+238
View File
@@ -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>
);
};