From 9bffb72338644b3621d45bce6069b3459f46565c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:42:41 +0100 Subject: [PATCH] feat: iframe-based email rendering with smart dark mode support - Render HTML emails in sandboxed iframe (srcdoc) for true-to-life display with complete CSS isolation from app styles - Detect emails with native dark mode (prefers-color-scheme) and let them handle their own theming - Apply CSS filter inversion for dark mode on emails without native support, with re-inversion for images/media to preserve appearance - Add per-email light/dark toggle button (Sun/Moon icon) next to email size, resets on email change (not persisted) - Fix HTML reply/forward to include original email HTML content - Send replies as multipart/alternative (text + HTML) - Add drag-and-drop file attachments with overlay indicator - Auto-resize composer textarea to avoid double scrolling - Pin attachments section and bottom toolbar outside scroll area - Collapsible attachment list (show 3, toggle for more) --- app/[locale]/page.tsx | 4 +- app/globals.css | 20 ++++ components/email/email-composer.tsx | 157 +++++++++++++++++++++++++--- components/email/email-viewer.tsx | 121 +++++++++++++++------ lib/jmap/client.ts | 41 +++++--- locales/en/common.json | 2 + stores/email-store.ts | 6 +- 7 files changed, 287 insertions(+), 64 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index e1c140c5..69d57021 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -423,6 +423,7 @@ export default function Home() { bcc: string[]; subject: string; body: string; + htmlBody?: string; draftId?: string; fromEmail?: string; fromName?: string; @@ -431,7 +432,7 @@ export default function Home() { if (!client) return; try { - await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName); + await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody); setShowComposer(false); // Refresh the current mailbox to update the UI @@ -1349,6 +1350,7 @@ export default function Home() { cc: selectedEmail.cc, subject: selectedEmail.subject, body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', + htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, receivedAt: selectedEmail.receivedAt } : undefined)} initialDraftText={composerDraftText} diff --git a/app/globals.css b/app/globals.css index 59b6d2db..f6649424 100644 --- a/app/globals.css +++ b/app/globals.css @@ -242,6 +242,26 @@ body { list-style-type: decimal; } +/* Reply quoted HTML - preserves original inline styles/colors */ +.email-reply-quote { + overflow-wrap: break-word; + word-wrap: break-word; + max-width: none; +} + +.email-reply-quote p { + margin: 0.5rem 0; +} + +.email-reply-quote img { + max-width: 100%; + height: auto; +} + +.email-reply-quote a { + text-decoration: underline; +} + /* Only style tables that are actual data tables, not layout tables */ .email-content table.data-table, .email-content table[border="1"] { diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index f8960043..da02a189 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -9,6 +9,7 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma import { cn, formatFileSize } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; +import { sanitizeEmailHtml } from "@/lib/email-sanitization"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useContactStore } from "@/stores/contact-store"; @@ -42,6 +43,7 @@ interface EmailComposerProps { bcc: string[]; subject: string; body: string; + htmlBody?: string; draftId?: string; fromEmail?: string; fromName?: string; @@ -60,6 +62,7 @@ interface EmailComposerProps { cc?: { email?: string; name?: string }[]; subject?: string; body?: string; + htmlBody?: string; receivedAt?: string; }; } @@ -113,16 +116,22 @@ export function EmailComposer({ const getInitialBody = () => { const prefix = initialDraftText || ""; - if (!replyTo?.body) return prefix; + if (!replyTo?.body && !replyTo?.htmlBody) return prefix; const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); + // When HTML body is available, don't include quoted text in the textarea + // The HTML original will be shown separately below the textarea + if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { + return prefix; + } + if (mode === 'forward') { return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; + return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`; } return prefix; }; @@ -138,6 +147,18 @@ export function EmailComposer({ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); + const textareaRef = useRef(null); + + const autoResizeTextarea = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; + }, []); + + useEffect(() => { + autoResizeTextarea(); + }, [body, autoResizeTextarea]); const [attachments, setAttachments] = useState>([]); const fileInputRef = useRef(null); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); @@ -147,6 +168,7 @@ export function EmailComposer({ const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); + const [showAllAttachments, setShowAllAttachments] = useState(false); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, @@ -333,13 +355,9 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, []); - const handleFileSelect = async (event: React.ChangeEvent) => { - if (!client || !event.target.files) return; + const addFiles = useCallback(async (files: File[]) => { + if (!client || files.length === 0) return; - const files = Array.from(event.target.files); - - // AbortController tracks cancellation state but uploadBlob doesn't accept a signal, - // so abort only prevents post-upload state updates (cosmetic cancellation) const newAttachments = files.map(file => { const controller = new AbortController(); return { file, uploading: true, abortController: controller }; @@ -375,12 +393,60 @@ export function EmailComposer({ ); } } + }, [client, t]); + const handleFileSelect = async (event: React.ChangeEvent) => { + if (!event.target.files) return; + await addFiles(Array.from(event.target.files)); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; + const [isDraggingOver, setIsDraggingOver] = useState(false); + const dragTimeoutRef = useRef(null); + + const clearDragState = useCallback(() => { + if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current); + dragTimeoutRef.current = null; + setIsDraggingOver(false); + }, []); + + const resetDragTimeout = useCallback(() => { + if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current); + dragTimeoutRef.current = setTimeout(clearDragState, 150); + }, [clearDragState]); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes('Files')) { + setIsDraggingOver(true); + resetDragTimeout(); + } + }, [resetDragTimeout]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + resetDragTimeout(); + }, [resetDragTimeout]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + resetDragTimeout(); + }, [resetDragTimeout]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + clearDragState(); + if (e.dataTransfer.files?.length) { + addFiles(Array.from(e.dataTransfer.files)); + } + }, [addFiles, clearDragState]); + const removeAttachment = (index: number) => { const att = attachments[index]; att?.abortController?.abort(); @@ -558,6 +624,23 @@ export function EmailComposer({ finalBody = body + '\n\n-- \n' + currentIdentity.textSignature; } + // Build HTML body when replying/forwarding with original HTML content + let finalHtmlBody: string | undefined; + if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { + const escapedBody = body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); + const signatureHtml = currentIdentity?.textSignature + ? `

--
${currentIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}` + : ''; + const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ''; + const fromAddr = replyTo.from?.[0]; + const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown'); + const quoteHeader = mode === 'forward' + ? `---------- ${t('prefix.forward')} ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` + : `On ${date}, ${fromStr} wrote:
`; + + finalHtmlBody = `
${escapedBody}
${signatureHtml}
${quoteHeader}
${replyTo.htmlBody}
`; + } + try { await onSend?.({ to: toAddresses, @@ -565,6 +648,7 @@ export function EmailComposer({ bcc: bccAddresses, subject, body: finalBody, + htmlBody: finalHtmlBody, draftId: finalDraftId || undefined, fromEmail, fromName: currentIdentity?.name || undefined, @@ -626,7 +710,22 @@ export function EmailComposer({ }; return ( -
+
+ {/* Drag overlay */} + {isDraggingOver && ( +
+
+ + {t('drop_files')} +
+
+ )} {/* Header - mobile: clean bar with close/send, desktop: title bar */}
@@ -668,7 +767,7 @@ export function EmailComposer({
-
+
{/* Fields section */}
{/* From field */} @@ -834,10 +933,11 @@ export function EmailComposer({
{/* Body */} -
+