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('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([]); const [open, setOpen] = useState(false); const seg = value.split(',').pop()?.trim() ?? ''; useEffect(() => { if (seg.length < 1) { setSuggestions([]); return; } const t = setTimeout(() => { client .get(`/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 (
{ 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 && (
{suggestions.map((s) => ( ))}
)}
); }; const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); export const ComposeModal = () => { const client = useClient(); const queryClient = useQueryClient(); const [draft, setDraft] = useComposer(); const [to, setTo] = useState(''); const [cc, setCc] = useState(''); const [showCc, setShowCc] = useState(false); const [bcc, setBcc] = useState(''); const [showBcc, setShowBcc] = useState(false); const [subject, setSubject] = useState(''); const [files, setFiles] = useState([]); const [dragging, setDragging] = useState(false); const [bodyEmpty, setBodyEmpty] = useState(true); const [sending, setSending] = useState(false); const seeded = useRef(null); const fileInput = useRef(null); const editorRef = useRef(null); const inlineMap = useRef(new Map()); // data-inline-id → File, for the images embedded in the body const nextImgId = useRef(0); const refreshEmpty = () => { const el = editorRef.current; setBodyEmpty(!el || (!el.textContent?.trim() && !el.querySelector('img'))); }; // Free object URLs for any images still in the editor, and clear the id→File map. const revokeInline = () => { editorRef.current?.querySelectorAll('img[data-inline-id]').forEach((img) => { const src = (img as HTMLImageElement).src; if (src.startsWith('blob:')) URL.revokeObjectURL(src); }); inlineMap.current.clear(); }; useEffect(() => { if (draft && draft !== seeded.current) { seeded.current = draft; setTo(draft.to ?? ''); setCc(draft.cc ?? ''); setShowCc(!!draft.cc); setBcc(''); setShowBcc(false); setSubject(draft.subject ?? ''); setFiles([]); inlineMap.current.clear(); nextImgId.current = 0; // Seed the contenteditable body directly (uncontrolled — React never re-renders its content). if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '
') : ''; refreshEmpty(); } if (!draft) seeded.current = null; }, [draft]); // Clipboard images often come nameless — give them a sensible filename. const named = (f: File) => f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }); // Attach button (and non-image paste/drop): everything goes as a regular attachment. const addAttachments = (incoming: FileList | File[]) => { const list = Array.from(incoming).map(named); if (list.length) setFiles((prev) => [...prev, ...list]); }; // Insert an image into the body at the given range (or the current caret, or the end). const insertImage = (file: File, at?: Range | null) => { const el = editorRef.current; if (!el) return; const id = `img-${nextImgId.current++}`; inlineMap.current.set(id, file); const img = document.createElement('img'); img.src = URL.createObjectURL(file); img.setAttribute('data-inline-id', id); img.style.maxWidth = '100%'; img.style.height = 'auto'; const sel = window.getSelection(); const caret = at ?? (sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.getRangeAt(0) : null); if (caret && el.contains(caret.startContainer)) { caret.deleteContents(); caret.insertNode(img); caret.setStartAfter(img); caret.collapse(true); sel?.removeAllRanges(); sel?.addRange(caret); } else { el.appendChild(img); } refreshEmpty(); }; // Paste inside the body: images insert at the caret, other files attach, text pastes as plain text. const onEditorPaste = (ev: React.ClipboardEvent) => { const fileItems = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file'); if (fileItems.length) { ev.preventDefault(); fileItems .map((it) => it.getAsFile()) .filter((f): f is File => !!f) .map(named) .forEach((f) => (f.type.startsWith('image/') ? insertImage(f) : addAttachments([f]))); return; } const text = ev.clipboardData?.getData('text/plain'); if (text) { ev.preventDefault(); document.execCommand('insertText', false, text); } }; const onDrop = (ev: React.DragEvent) => { ev.preventDefault(); setDragging(false); const dropped = Array.from(ev.dataTransfer?.files ?? []).map(named); if (!dropped.length) return; // Place images at the drop point if it lands inside the body, otherwise append at the end. const el = editorRef.current; const point = document.caretRangeFromPoint?.(ev.clientX, ev.clientY) ?? null; const range = point && el?.contains(point.startContainer) ? point : null; dropped.forEach((f) => (f.type.startsWith('image/') ? insertImage(f, range) : addAttachments([f]))); }; const close = () => { revokeInline(); setDraft(null); }; const send = async () => { if (!to.trim()) { toast.error('Add at least one recipient'); return; } setSending(true); try { const fd = new FormData(); fd.append('to', to); if (cc.trim()) fd.append('cc', cc); if (bcc.trim()) fd.append('bcc', bcc); fd.append('subject', subject); if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo); files.forEach((f) => fd.append('files', f)); // Serialize the body. Clone it, rewrite each inline to a `cid:inline-N` ref (backend // matches by order), and collect those files. Plain text goes as the fallback part. const el = editorRef.current; const inlineFiles: File[] = []; if (el) { const clone = el.cloneNode(true) as HTMLElement; clone.querySelectorAll('img[data-inline-id]').forEach((node) => { const img = node as HTMLImageElement; const file = inlineMap.current.get(img.getAttribute('data-inline-id') ?? ''); if (!file) return img.remove(); img.setAttribute('src', `cid:inline-${inlineFiles.length}`); img.removeAttribute('data-inline-id'); inlineFiles.push(file); }); fd.append('body', el.innerText); if (inlineFiles.length) { fd.append('html', clone.innerHTML); inlineFiles.forEach((f) => fd.append('inline', f)); } } await client.post('/email/send', fd); toast.success('Email sent'); // Gmail files the sent copy in Sent, so refresh the list to surface it. queryClient.invalidateQueries({ queryKey: ['email-messages'] }); close(); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to send'); } finally { setSending(false); } }; const fmtSize = (n: number) => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`; if (!draft) return null; return ( !o && close()}> { ev.preventDefault(); setDragging(true); }} onDragLeave={(ev) => { if (ev.currentTarget === ev.target) setDragging(false); }} > {dragging && (
Drop images to inline · other files attach
)}
{/^re:/i.test(subject) ? 'Reply' : 'New message'}
To
{!showCc && ( )} {!showBcc && ( )}
{showCc && (
Cc
)} {showBcc && (
Bcc
)} setSubject(ev.target.value)} placeholder="Subject" className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40" />
{bodyEmpty && (
Write your message…
)}
{files.length > 0 && (
{files.map((f, i) => ( {f.name} {fmtSize(f.size)} ))}
)}
{ if (ev.target.files?.length) addAttachments(ev.target.files); ev.target.value = ''; }} />
); }; // 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 }; };