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.
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
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 };
|
|
};
|