From 089963b1b3918cd5a0fea2ae30203553c24ff23f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 1 May 2026 18:22:06 +0200 Subject: [PATCH] refactor: redesign expanded details panel --- components/email/email-viewer.tsx | 706 ++++++++++++------------------ locales/en/common.json | 25 ++ 2 files changed, 314 insertions(+), 417 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7f089689..9d6b2bfb 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; -import ReactDOM from "react-dom"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization"; @@ -25,7 +24,6 @@ import { Download, Mail, MailOpen, - Clock, Loader2, Printer, FileText, @@ -44,13 +42,9 @@ import { Minus, ShieldCheck, ShieldAlert, - Network, - Hash, - List, Code, Copy, Brain, - Sparkles, Keyboard, Phone, Building, @@ -65,7 +59,6 @@ import { Sun, Upload, Moon, - HelpCircle, EditIcon, PlayCircle, PenSquare, @@ -807,46 +800,6 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component ); } -function InfoTooltip({ text }: { text: string }) { - const [open, setOpen] = useState(false); - const [pos, setPos] = useState<{ top: number; left: number } | null>(null); - const btnRef = useRef(null); - - const show = () => { - if (btnRef.current) { - const rect = btnRef.current.getBoundingClientRect(); - setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 }); - } - setOpen(true); - }; - const hide = () => setOpen(false); - - return ( - - - {open && pos && ReactDOM.createPortal( - - {text} - , - document.body - )} - - ); -} - export function EmailViewer({ email, isLoading = false, @@ -4026,390 +3979,309 @@ export function EmailViewer({ {/* Expandable Details */} - {showFullHeaders && ( -
- {/* Full Recipients Section */} -
-
-

- - {t('message_details')} -

-
-
- {/* From */} -
- {t('from')}: -
- ` : undefined} - onViewContact={handleViewContactSidebar} - className="text-sm" - /> -
-
- {/* To - show all */} - {email.to && email.to.length > 0 && ( -
- {t('to')}: -
- {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)} -
-
- )} - {/* CC - show all */} - {email.cc && email.cc.length > 0 && ( -
- {t('cc')}: -
- {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)} -
-
- )} - {/* BCC - show all */} - {email.bcc && email.bcc.length > 0 && ( -
- {t('bcc')}: -
- {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)} -
-
- )} - {/* Date */} -
- {t('date')}: - - {formatDateTime(email.receivedAt, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })} - -
- {/* Reply-To if different */} - {email.replyTo && email.replyTo.length > 0 && - (!email.from || email.replyTo[0].email !== email.from[0]?.email) && ( -
- {t('reply_to_label').replace(':', '')} -
- {email.replyTo.map((r, i) => ( - - ))} -
-
- )} -
+ {showFullHeaders && (() => { + const translateAuthResult = (result?: string) => { + const r = (result || '').toLowerCase(); + switch (r) { + case 'pass': return t('authentication.result.pass'); + case 'fail': return t('authentication.result.fail'); + case 'softfail': return t('authentication.result.softfail'); + case 'neutral': return t('authentication.result.neutral'); + case 'permerror': return t('authentication.result.permerror'); + case 'temperror': return t('authentication.result.temperror'); + case 'none': return t('authentication.result.none'); + default: return result || ''; + } + }; + const replyToDifferent = !!email.replyTo?.length && + (!email.from || email.replyTo[0].email !== email.from[0]?.email); + const deliveryDeltaMs = email.sentAt && email.receivedAt + ? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()) + : 0; + const formatDelta = (diff: number) => { + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const dayUnit = days > 1 ? t('time.days') : t('time.day'); + const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour'); + const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute'); + const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute'); + if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`; + if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`; + return `${minutes} ${minuteUnitSingle}`; + }; + const fullDate = (iso?: string) => iso + ? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' }) + : '—'; + const auth = email.authenticationResults; + const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0); + const topMimeType = email.bodyStructure?.type; + const SectionHeader = ({ children }: { children: React.ReactNode }) => ( +
+ {children}
+ ); + const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => ( + <> +
{label}
+
{children}
+ + ); + const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => { + if (!result) return null; + const status = getSecurityStatus(result); + const Icon = status.icon === 'check' ? Check + : status.icon === 'x' ? X + : status.icon === 'alert' ? AlertTriangle + : Minus; + return ( + + + {name} + + {translateAuthResult(result)} + + {extra && ( + <> + · + {extra} + + )} + + ); + }; - {/* Security & Authentication Section */} - {(email.authenticationResults || email.spamScore !== undefined) && ( -
-
-

- - {t('security_authentication')} -

+ const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId); + const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost); + const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM); + + return ( +
+
+ {t('details.recipients_routing')} +
+ +
+ ` : undefined} + onViewContact={handleViewContactSidebar} + className="text-sm text-left" + />
-
- {/* Authentication Results */} - {email.authenticationResults && (() => { - const translateAuthResult = (result?: string) => { - const r = (result || '').toLowerCase(); - switch (r) { - case 'pass': return t('authentication.result.pass'); - case 'fail': return t('authentication.result.fail'); - case 'softfail': return t('authentication.result.softfail'); - case 'neutral': return t('authentication.result.neutral'); - case 'permerror': return t('authentication.result.permerror'); - case 'temperror': return t('authentication.result.temperror'); - case 'none': return t('authentication.result.none'); - default: return result || ''; - } - }; - return ( -
- {/* SPF Check */} - {email.authenticationResults.spf && ( -
-
- {getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' && - } - {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' && - } - {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' && - } - {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' && - } -
-
- SPF - -
-
- {translateAuthResult(email.authenticationResults.spf.result)} -
-
-
- {email.authenticationResults.spf.domain && ( -
- {email.authenticationResults.spf.domain} -
- )} -
- )} + + {replyToDifferent && ( + +
+ {email.replyTo!.map((r, i) => ( + + ))} +
+
+ )} + {email.to && email.to.length > 0 && ( + +
+ {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)} +
+
+ )} + {email.cc && email.cc.length > 0 && ( + +
+ {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)} +
+
+ )} + {email.bcc && email.bcc.length > 0 && ( + +
+ {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)} +
+
+ )} + {email.sentAt && ( + {fullDate(email.sentAt)} + )} + + {fullDate(email.receivedAt)} + {deliveryDeltaMs > 60000 && ( + · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()} + )} + +
+
- {/* DKIM Check */} - {email.authenticationResults.dkim && ( -
-
- {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' && - } - {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' && - } - {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' && - } - {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' && - } -
-
- DKIM - -
-
- {translateAuthResult(email.authenticationResults.dkim.result)} -
-
-
- {email.authenticationResults.dkim.domain && ( -
- {email.authenticationResults.dkim.domain} -
- )} -
- )} - - {/* DMARC Check */} - {email.authenticationResults.dmarc && ( -
-
- {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' && - } - {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' && - } - {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' && - } - {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' && - } -
-
- DMARC - -
-
- {translateAuthResult(email.authenticationResults.dmarc.result)} -
-
-
- {email.authenticationResults.dmarc.policy && ( -
- {t('authentication.policy')}: {email.authenticationResults.dmarc.policy} -
- )} -
- )} - - {/* Spam Score */} - {email.spamScore !== undefined && ( -
5 ? "bg-gray-50 dark:bg-gray-800 border-l-4 border-red-600 dark:border-red-500" : - email.spamScore > 2 ? "bg-gray-50 dark:bg-gray-800 border-l-4 border-amber-600 dark:border-amber-500" : - "bg-gray-50 dark:bg-gray-800 border-l-4 border-green-600 dark:border-green-500" - )}> -
- 5 ? "text-red-700 dark:text-red-400" : - email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : - "text-green-700 dark:text-green-400" - )} /> -
-
- Spam Score - -
-
5 ? "text-red-700 dark:text-red-400" : - email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : - "text-green-700 dark:text-green-400" - )}> - {email.spamScore.toFixed(1)} -
-
-
- {email.spamStatus && ( -
- {email.spamStatus} -
- )} -
- )} -
- ); - })()} - - {/* AI Analysis (X-Spam-LLM) - Full width card */} - {email.spamLLM && ( -
+ {t('details.authentication_security')} +
+ {auth?.spf && ( + + )} + {auth?.dkim && ( + + )} + {auth?.dmarc && ( + + )} + {auth?.iprev && ( + + )} + {email.spamScore !== undefined && ( + 5 ? "bg-red-500/[0.07] border-red-500/30" : + email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" : + "bg-green-500/[0.07] border-green-500/30", )}> -
-
- {email.spamLLM.verdict === 'LEGITIMATE' ? ( -
- - -
- ) : email.spamLLM.verdict === 'SPAM' ? ( - - ) : ( - - )} -
-
-
- - AI Analysis: {email.spamLLM.verdict} - -
-

- {email.spamLLM.explanation} -

-
-
-
+ 5 ? "text-red-700 dark:text-red-400" : + email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : + "text-green-700 dark:text-green-400", + )} /> + {t('authentication.spam_score')} + 5 ? "text-red-700 dark:text-red-400" : + email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : + "text-green-700 dark:text-green-400", + )}> + {email.spamScore.toFixed(1)} + + {email.spamStatus && ( + <> + · + {email.spamStatus} + + )} + )}
-
+ {email.spamLLM && ( +
+ {email.spamLLM.verdict === 'LEGITIMATE' ? : + email.spamLLM.verdict === 'SPAM' ? : + } +
+ + {email.spamLLM.verdict} + + · {email.spamLLM.explanation} +
+
+ )} + )} - {/* Technical Details Section - Only show if we have useful technical info */} - {(email.messageId || email.replyTo?.length || (email.sentAt && email.receivedAt && - Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000)) && ( -
-
-

- - {t('technical_details')} -

-
-
-
- {/* Message ID */} - {email.messageId && ( -
- -
- {t('message_id_label')} -
- {email.messageId} -
-
+ {hasIdentifiers && ( +
+ {t('details.identifiers_threading')} +
+ {email.messageId && ( + {email.messageId} + )} + {email.inReplyTo && email.inReplyTo.length > 0 && ( + +
+ {email.inReplyTo.map((id, i) =>
{id}
)}
- )} +
+ )} + {email.references && email.references.length > 0 && ( + +
+ + + {t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })} + +
+ {email.references.map((id, i) =>
{id}
)} +
+
+
+ )} + {email.threadId && ( + {email.threadId} + )} +
+
+ )} - {/* Reply-To if different from sender */} - {email.replyTo && email.replyTo.length > 0 && - (!email.from || email.replyTo[0].email !== email.from[0]?.email) && ( -
- -
- {t('reply_to_label')} -
- {email.replyTo.map((recipient, i) => ( - - {recipient.name && {recipient.name}} - {recipient.email} - - ))} -
-
-
- )} +
+ {t('details.message_properties')} +
+ {email.subject !== undefined && ( + {email.subject || {t('details.no_subject')}} + )} + + {formatFileSize(email.size)} + {topMimeType && ( + · {topMimeType} + )} + + {effectiveAttachments.length > 0 && ( + + {t('details.attachments_summary', { + count: effectiveAttachments.length, + size: formatFileSize(totalAttachmentSize), + })} + + )} + {email.accountLabel && ( + {email.accountLabel} + )} +
+
- {/* Time delay if significant (>1 minute difference) */} - {email.sentAt && email.receivedAt && - Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000 && ( -
- -
- {t('delivery_time_label')} -
- {(() => { - const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()); - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - const dayUnit = days > 1 ? t('time.days') : t('time.day'); - const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour'); - const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute'); - const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute'); - if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`; - if (hours > 0) return `${hours} ${hours > 1 ? t('time.hours') : t('time.hour')} ${minutes % 60} ${minuteUnit}`; - return `${minutes} ${minuteUnitSingle}`; - })()} -
-
-
- )} - - {/* Part of conversation */} - {email.references && email.references.length > 0 && ( -
- -
- {t('conversation_part_label')} -
- {t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })} -
-
-
- )} -
-
-
+ {hasListInfo && ( +
+ {t('details.mailing_list')} +
+ {listHeaders?.listId && ( + {listHeaders.listId} + )} + {listHeaders?.listUnsubscribe?.preferred && ( + + + {listHeaders.listUnsubscribe.preferred === 'http' + ? listHeaders.listUnsubscribe.http + : listHeaders.listUnsubscribe.mailto} + + + )} + {listHeaders?.listHelp && ( + {listHeaders.listHelp} + )} + {listHeaders?.listPost && ( + {listHeaders.listPost} + )} +
+
)}
- )} + ); + })()}
{/* Attachments on the right (beside-sender mode) */} diff --git a/locales/en/common.json b/locales/en/common.json index 1251e3d6..b3020ed8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -321,6 +321,31 @@ "none": "None" } }, + "details": { + "recipients_routing": "Recipients & routing", + "authentication_security": "Authentication & security", + "identifiers_threading": "Identifiers & threading", + "mailing_list": "Mailing list", + "message_properties": "Message properties", + "sent": "Sent", + "received": "Received", + "delivery_time": "Delivery time", + "in_reply_to": "In-Reply-To", + "references": "References", + "thread_id": "Thread ID", + "size": "Size", + "mime_type": "MIME type", + "attachments_summary": "{count} files · {size}", + "list_id": "List ID", + "list_help": "List help", + "list_post": "List post", + "list_unsubscribe": "Unsubscribe", + "iprev": "Reverse DNS", + "spam_status": "Spam status", + "ai_verdict": "AI verdict", + "account": "Account", + "no_subject": "(no subject)" + }, "headers": { "routing": "Routing", "received": "Received",