refactor: redesign expanded details panel
This commit is contained in:
+289
-417
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import ReactDOM from "react-dom";
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||||
@@ -25,7 +24,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Mail,
|
Mail,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Clock,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Printer,
|
Printer,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -44,13 +42,9 @@ import {
|
|||||||
Minus,
|
Minus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Network,
|
|
||||||
Hash,
|
|
||||||
List,
|
|
||||||
Code,
|
Code,
|
||||||
Copy,
|
Copy,
|
||||||
Brain,
|
Brain,
|
||||||
Sparkles,
|
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Phone,
|
Phone,
|
||||||
Building,
|
Building,
|
||||||
@@ -65,7 +59,6 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Upload,
|
Upload,
|
||||||
Moon,
|
Moon,
|
||||||
HelpCircle,
|
|
||||||
EditIcon,
|
EditIcon,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
PenSquare,
|
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<HTMLButtonElement>(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 (
|
|
||||||
<span className="inline-flex">
|
|
||||||
<button
|
|
||||||
ref={btnRef}
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
|
||||||
onMouseEnter={show}
|
|
||||||
onMouseLeave={hide}
|
|
||||||
onClick={(e) => { e.stopPropagation(); if (open) { hide(); } else { show(); } }}
|
|
||||||
aria-label="More info"
|
|
||||||
>
|
|
||||||
<HelpCircle className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
{open && pos && ReactDOM.createPortal(
|
|
||||||
<span
|
|
||||||
className="fixed w-56 px-3 py-2 text-xs leading-relaxed rounded-lg shadow-lg border border-border bg-background text-foreground z-[9999] pointer-events-none animate-in fade-in zoom-in-95 duration-150"
|
|
||||||
style={{ top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)' }}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmailViewer({
|
export function EmailViewer({
|
||||||
email,
|
email,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -4026,390 +3979,309 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expandable Details */}
|
{/* Expandable Details */}
|
||||||
{showFullHeaders && (
|
{showFullHeaders && (() => {
|
||||||
<div className="mt-3 space-y-3">
|
const translateAuthResult = (result?: string) => {
|
||||||
{/* Full Recipients Section */}
|
const r = (result || '').toLowerCase();
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
switch (r) {
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
case 'pass': return t('authentication.result.pass');
|
||||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
|
case 'fail': return t('authentication.result.fail');
|
||||||
<Mail className="w-3.5 h-3.5" />
|
case 'softfail': return t('authentication.result.softfail');
|
||||||
{t('message_details')}
|
case 'neutral': return t('authentication.result.neutral');
|
||||||
</h3>
|
case 'permerror': return t('authentication.result.permerror');
|
||||||
</div>
|
case 'temperror': return t('authentication.result.temperror');
|
||||||
<div className="bg-background p-4 space-y-2 text-sm">
|
case 'none': return t('authentication.result.none');
|
||||||
{/* From */}
|
default: return result || '';
|
||||||
<div className="flex items-start gap-2">
|
}
|
||||||
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('from')}:</span>
|
};
|
||||||
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
const replyToDifferent = !!email.replyTo?.length &&
|
||||||
<RecipientPopover
|
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
|
||||||
name={sender?.name}
|
const deliveryDeltaMs = email.sentAt && email.receivedAt
|
||||||
email={sender?.email || ''}
|
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
|
||||||
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
|
: 0;
|
||||||
onViewContact={handleViewContactSidebar}
|
const formatDelta = (diff: number) => {
|
||||||
className="text-sm"
|
const minutes = Math.floor(diff / 60000);
|
||||||
/>
|
const hours = Math.floor(minutes / 60);
|
||||||
</div>
|
const days = Math.floor(hours / 24);
|
||||||
</div>
|
const dayUnit = days > 1 ? t('time.days') : t('time.day');
|
||||||
{/* To - show all */}
|
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
|
||||||
{email.to && email.to.length > 0 && (
|
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
|
||||||
<div className="flex items-start gap-2">
|
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
|
||||||
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('to')}:</span>
|
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
|
||||||
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`;
|
||||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
|
return `${minutes} ${minuteUnitSingle}`;
|
||||||
</div>
|
};
|
||||||
</div>
|
const fullDate = (iso?: string) => iso
|
||||||
)}
|
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
|
||||||
{/* CC - show all */}
|
: '—';
|
||||||
{email.cc && email.cc.length > 0 && (
|
const auth = email.authenticationResults;
|
||||||
<div className="flex items-start gap-2">
|
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
|
||||||
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('cc')}:</span>
|
const topMimeType = email.bodyStructure?.type;
|
||||||
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
|
||||||
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
|
||||||
</div>
|
{children}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* BCC - show all */}
|
|
||||||
{email.bcc && email.bcc.length > 0 && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('bcc')}:</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
|
||||||
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Date */}
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('date')}:</span>
|
|
||||||
<span className="text-foreground">
|
|
||||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Reply-To if different */}
|
|
||||||
{email.replyTo && email.replyTo.length > 0 &&
|
|
||||||
(!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('reply_to_label').replace(':', '')}</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
|
||||||
{email.replyTo.map((r, i) => (
|
|
||||||
<RecipientPopover
|
|
||||||
key={r.email + i}
|
|
||||||
name={r.name}
|
|
||||||
email={r.email}
|
|
||||||
onViewContact={handleViewContactSidebar}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
|
||||||
|
<dd className={cn(
|
||||||
|
"text-sm text-foreground min-w-0 break-words",
|
||||||
|
mono && "font-mono text-xs",
|
||||||
|
)}>{children}</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
||||||
|
tooltip && "cursor-help",
|
||||||
|
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
|
||||||
|
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
|
||||||
|
status.icon === 'alert' && "bg-amber-500/[0.07] border-amber-500/30",
|
||||||
|
status.icon === 'minus' && "bg-muted/40 border-border",
|
||||||
|
)}
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
|
||||||
|
<span className="font-medium text-foreground">{name}</span>
|
||||||
|
<span className={cn("text-[10px] uppercase tracking-wider", status.color)}>
|
||||||
|
{translateAuthResult(result)}
|
||||||
|
</span>
|
||||||
|
{extra && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">·</span>
|
||||||
|
<span className="text-muted-foreground">{extra}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{/* Security & Authentication Section */}
|
const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId);
|
||||||
{(email.authenticationResults || email.spamScore !== undefined) && (
|
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
|
return (
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
<div className="mt-3 pt-3 border-t border-border grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
|
||||||
{t('security_authentication')}
|
<section>
|
||||||
</h3>
|
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
<Row label={t('from')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<RecipientPopover
|
||||||
|
name={sender?.name}
|
||||||
|
email={sender?.email || ''}
|
||||||
|
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
|
||||||
|
onViewContact={handleViewContactSidebar}
|
||||||
|
className="text-sm text-left"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background p-4 space-y-3">
|
</Row>
|
||||||
{/* Authentication Results */}
|
{replyToDifferent && (
|
||||||
{email.authenticationResults && (() => {
|
<Row label={t('reply_to_label').replace(':', '')}>
|
||||||
const translateAuthResult = (result?: string) => {
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
const r = (result || '').toLowerCase();
|
{email.replyTo!.map((r, i) => (
|
||||||
switch (r) {
|
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
|
||||||
case 'pass': return t('authentication.result.pass');
|
))}
|
||||||
case 'fail': return t('authentication.result.fail');
|
</div>
|
||||||
case 'softfail': return t('authentication.result.softfail');
|
</Row>
|
||||||
case 'neutral': return t('authentication.result.neutral');
|
)}
|
||||||
case 'permerror': return t('authentication.result.permerror');
|
{email.to && email.to.length > 0 && (
|
||||||
case 'temperror': return t('authentication.result.temperror');
|
<Row label={t('to')}>
|
||||||
case 'none': return t('authentication.result.none');
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
default: return result || '';
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
}
|
</div>
|
||||||
};
|
</Row>
|
||||||
return (
|
)}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
{email.cc && email.cc.length > 0 && (
|
||||||
{/* SPF Check */}
|
<Row label={t('cc')}>
|
||||||
{email.authenticationResults.spf && (
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<div className={cn(
|
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
"px-3 py-2 rounded-md",
|
</div>
|
||||||
getSecurityStatus(email.authenticationResults.spf.result).bgColor,
|
</Row>
|
||||||
getSecurityStatus(email.authenticationResults.spf.result).borderColor
|
)}
|
||||||
)}>
|
{email.bcc && email.bcc.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<Row label={t('bcc')}>
|
||||||
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' &&
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
|
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' &&
|
</div>
|
||||||
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
|
</Row>
|
||||||
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' &&
|
)}
|
||||||
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
|
{email.sentAt && (
|
||||||
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' &&
|
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
|
||||||
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
|
)}
|
||||||
<div className="flex-1">
|
<Row label={t('details.received')}>
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
{fullDate(email.receivedAt)}
|
||||||
SPF
|
{deliveryDeltaMs > 60000 && (
|
||||||
<InfoTooltip text={t('authentication.tooltip_spf')} />
|
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
|
||||||
</div>
|
)}
|
||||||
<div className={cn("text-xs", getSecurityStatus(email.authenticationResults.spf.result).color)}>
|
</Row>
|
||||||
{translateAuthResult(email.authenticationResults.spf.result)}
|
</dl>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{email.authenticationResults.spf.domain && (
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate" title={email.authenticationResults.spf.domain}>
|
|
||||||
{email.authenticationResults.spf.domain}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DKIM Check */}
|
{hasAuthSection && (
|
||||||
{email.authenticationResults.dkim && (
|
<section>
|
||||||
<div className={cn(
|
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
||||||
"px-3 py-2 rounded-md",
|
<div className="flex flex-wrap gap-1.5">
|
||||||
getSecurityStatus(email.authenticationResults.dkim.result).bgColor,
|
{auth?.spf && (
|
||||||
getSecurityStatus(email.authenticationResults.dkim.result).borderColor
|
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
|
||||||
)}>
|
)}
|
||||||
<div className="flex items-center gap-2">
|
{auth?.dkim && (
|
||||||
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' &&
|
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
||||||
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
|
)}
|
||||||
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' &&
|
{auth?.dmarc && (
|
||||||
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
|
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
|
||||||
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' &&
|
)}
|
||||||
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
|
{auth?.iprev && (
|
||||||
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' &&
|
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
|
||||||
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
|
)}
|
||||||
<div className="flex-1">
|
{email.spamScore !== undefined && (
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
<span className={cn(
|
||||||
DKIM
|
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
||||||
<InfoTooltip text={t('authentication.tooltip_dkim')} />
|
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
|
||||||
</div>
|
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
|
||||||
<div className={cn("text-xs", getSecurityStatus(email.authenticationResults.dkim.result).color)}>
|
"bg-green-500/[0.07] border-green-500/30",
|
||||||
{translateAuthResult(email.authenticationResults.dkim.result)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{email.authenticationResults.dkim.domain && (
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate" title={email.authenticationResults.dkim.domain}>
|
|
||||||
{email.authenticationResults.dkim.domain}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DMARC Check */}
|
|
||||||
{email.authenticationResults.dmarc && (
|
|
||||||
<div className={cn(
|
|
||||||
"px-3 py-2 rounded-md",
|
|
||||||
getSecurityStatus(email.authenticationResults.dmarc.result).bgColor,
|
|
||||||
getSecurityStatus(email.authenticationResults.dmarc.result).borderColor
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' &&
|
|
||||||
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
|
|
||||||
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' &&
|
|
||||||
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
|
|
||||||
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' &&
|
|
||||||
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
|
|
||||||
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' &&
|
|
||||||
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
|
||||||
DMARC
|
|
||||||
<InfoTooltip text={t('authentication.tooltip_dmarc')} />
|
|
||||||
</div>
|
|
||||||
<div className={cn("text-xs", getSecurityStatus(email.authenticationResults.dmarc.result).color)}>
|
|
||||||
{translateAuthResult(email.authenticationResults.dmarc.result)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{email.authenticationResults.dmarc.policy && (
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{t('authentication.policy')}: {email.authenticationResults.dmarc.policy}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spam Score */}
|
|
||||||
{email.spamScore !== undefined && (
|
|
||||||
<div className={cn(
|
|
||||||
"px-3 py-2 rounded-md",
|
|
||||||
email.spamScore > 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"
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Shield className={cn(
|
|
||||||
"w-4 h-4",
|
|
||||||
email.spamScore > 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"
|
|
||||||
)} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
|
||||||
Spam Score
|
|
||||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
|
||||||
</div>
|
|
||||||
<div className={cn(
|
|
||||||
"text-xs",
|
|
||||||
email.spamScore > 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)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{email.spamStatus && (
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 capitalize">
|
|
||||||
{email.spamStatus}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* AI Analysis (X-Spam-LLM) - Full width card */}
|
|
||||||
{email.spamLLM && (
|
|
||||||
<div className={cn(
|
|
||||||
"mt-3 px-4 py-3 rounded-lg",
|
|
||||||
email.spamLLM.verdict === 'LEGITIMATE'
|
|
||||||
? "bg-gray-50 dark:bg-gray-800 border-l-4 border-green-600 dark:border-green-500"
|
|
||||||
: email.spamLLM.verdict === 'SPAM'
|
|
||||||
? "bg-gray-50 dark:bg-gray-800 border-l-4 border-red-600 dark:border-red-500"
|
|
||||||
: "bg-gray-50 dark:bg-gray-800 border-l-4 border-amber-600 dark:border-amber-500"
|
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-start gap-3">
|
<Shield className={cn(
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
"w-3.5 h-3.5",
|
||||||
{email.spamLLM.verdict === 'LEGITIMATE' ? (
|
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
|
||||||
<div className="flex items-center gap-1.5">
|
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
|
||||||
<Brain className="w-4 h-4 text-green-700 dark:text-green-400" />
|
"text-green-700 dark:text-green-400",
|
||||||
<Sparkles className="w-3 h-3 text-green-700 dark:text-green-400" />
|
)} />
|
||||||
</div>
|
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
|
||||||
) : email.spamLLM.verdict === 'SPAM' ? (
|
<span className={cn(
|
||||||
<ShieldAlert className="w-4 h-4 text-red-700 dark:text-red-400" />
|
"text-[10px] uppercase tracking-wider",
|
||||||
) : (
|
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-400" />
|
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
|
||||||
)}
|
"text-green-700 dark:text-green-400",
|
||||||
</div>
|
)}>
|
||||||
<div className="flex-1 min-w-0">
|
{email.spamScore.toFixed(1)}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
</span>
|
||||||
<span className={cn(
|
{email.spamStatus && (
|
||||||
"text-xs font-semibold uppercase tracking-wide",
|
<>
|
||||||
email.spamLLM.verdict === 'LEGITIMATE'
|
<span className="text-muted-foreground/50">·</span>
|
||||||
? "text-green-700 dark:text-green-400"
|
<span className="text-muted-foreground">{email.spamStatus}</span>
|
||||||
: email.spamLLM.verdict === 'SPAM'
|
</>
|
||||||
? "text-red-700 dark:text-red-400"
|
)}
|
||||||
: "text-amber-700 dark:text-amber-400"
|
</span>
|
||||||
)}>
|
|
||||||
AI Analysis: {email.spamLLM.verdict}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">
|
|
||||||
{email.spamLLM.explanation}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{email.spamLLM && (
|
||||||
|
<div className="mt-2 flex items-start gap-2 text-sm">
|
||||||
|
{email.spamLLM.verdict === 'LEGITIMATE' ? <Brain className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-700 dark:text-green-400" /> :
|
||||||
|
email.spamLLM.verdict === 'SPAM' ? <ShieldAlert className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" /> :
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-700 dark:text-amber-400" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className={cn(
|
||||||
|
"font-medium",
|
||||||
|
email.spamLLM.verdict === 'LEGITIMATE' ? "text-green-700 dark:text-green-400" :
|
||||||
|
email.spamLLM.verdict === 'SPAM' ? "text-red-700 dark:text-red-400" :
|
||||||
|
"text-amber-700 dark:text-amber-400",
|
||||||
|
)}>
|
||||||
|
{email.spamLLM.verdict}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground"> · {email.spamLLM.explanation}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Technical Details Section - Only show if we have useful technical info */}
|
{hasIdentifiers && (
|
||||||
{(email.messageId || email.replyTo?.length || (email.sentAt && email.receivedAt &&
|
<section>
|
||||||
Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000)) && (
|
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
{email.messageId && (
|
||||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
|
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
|
||||||
<Network className="w-3.5 h-3.5" />
|
)}
|
||||||
{t('technical_details')}
|
{email.inReplyTo && email.inReplyTo.length > 0 && (
|
||||||
</h3>
|
<Row label={t('details.in_reply_to')} mono>
|
||||||
</div>
|
<div className="space-y-0.5">
|
||||||
<div className="bg-background p-4">
|
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</div>)}
|
||||||
<div className="space-y-3 text-xs">
|
|
||||||
{/* Message ID */}
|
|
||||||
{email.messageId && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Hash className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="font-medium text-muted-foreground">{t('message_id_label')}</span>
|
|
||||||
<div className="text-foreground break-all font-mono text-xs mt-0.5">
|
|
||||||
{email.messageId}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.references && email.references.length > 0 && (
|
||||||
|
<Row label={t('details.references')}>
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
|
||||||
|
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1 space-y-0.5 font-mono text-xs">
|
||||||
|
{email.references.map((id, i) => <div key={i} className="break-all">{id}</div>)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.threadId && (
|
||||||
|
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Reply-To if different from sender */}
|
<section>
|
||||||
{email.replyTo && email.replyTo.length > 0 &&
|
<SectionHeader>{t('details.message_properties')}</SectionHeader>
|
||||||
(!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
<div className="flex items-start gap-2">
|
{email.subject !== undefined && (
|
||||||
<Mail className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
|
||||||
<div className="flex-1">
|
)}
|
||||||
<span className="font-medium text-muted-foreground">{t('reply_to_label')}</span>
|
<Row label={t('details.size')}>
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
{formatFileSize(email.size)}
|
||||||
{email.replyTo.map((recipient, i) => (
|
{topMimeType && (
|
||||||
<span key={i} className="inline-flex items-center px-2 py-1 bg-accent/50 border border-accent rounded text-xs">
|
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
|
||||||
{recipient.name && <span className="font-medium mr-1 text-accent-foreground">{recipient.name}</span>}
|
)}
|
||||||
<span className="text-accent-foreground/90">{recipient.email}</span>
|
</Row>
|
||||||
</span>
|
{effectiveAttachments.length > 0 && (
|
||||||
))}
|
<Row label={t('attachments')}>
|
||||||
</div>
|
{t('details.attachments_summary', {
|
||||||
</div>
|
count: effectiveAttachments.length,
|
||||||
</div>
|
size: formatFileSize(totalAttachmentSize),
|
||||||
)}
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.accountLabel && (
|
||||||
|
<Row label={t('details.account')}>{email.accountLabel}</Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Time delay if significant (>1 minute difference) */}
|
{hasListInfo && (
|
||||||
{email.sentAt && email.receivedAt &&
|
<section className="lg:col-span-2">
|
||||||
Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000 && (
|
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
|
||||||
<div className="flex items-start gap-2">
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
<Clock className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
{listHeaders?.listId && (
|
||||||
<div className="flex-1">
|
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
|
||||||
<span className="font-medium text-muted-foreground">{t('delivery_time_label')}</span>
|
)}
|
||||||
<div className="text-foreground">
|
{listHeaders?.listUnsubscribe?.preferred && (
|
||||||
{(() => {
|
<Row label={t('details.list_unsubscribe')}>
|
||||||
const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime());
|
<span className="break-all">
|
||||||
const minutes = Math.floor(diff / 60000);
|
{listHeaders.listUnsubscribe.preferred === 'http'
|
||||||
const hours = Math.floor(minutes / 60);
|
? listHeaders.listUnsubscribe.http
|
||||||
const days = Math.floor(hours / 24);
|
: listHeaders.listUnsubscribe.mailto}
|
||||||
const dayUnit = days > 1 ? t('time.days') : t('time.day');
|
</span>
|
||||||
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
|
</Row>
|
||||||
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
|
)}
|
||||||
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
|
{listHeaders?.listHelp && (
|
||||||
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
|
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
|
||||||
if (hours > 0) return `${hours} ${hours > 1 ? t('time.hours') : t('time.hour')} ${minutes % 60} ${minuteUnit}`;
|
)}
|
||||||
return `${minutes} ${minuteUnitSingle}`;
|
{listHeaders?.listPost && (
|
||||||
})()}
|
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</dl>
|
||||||
</div>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Part of conversation */}
|
|
||||||
{email.references && email.references.length > 0 && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<List className="w-3.5 h-3.5 text-muted-foreground mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="font-medium text-muted-foreground">{t('conversation_part_label')}</span>
|
|
||||||
<div className="text-foreground text-xs mt-0.5">
|
|
||||||
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/* Attachments on the right (beside-sender mode) */}
|
{/* Attachments on the right (beside-sender mode) */}
|
||||||
|
|||||||
@@ -321,6 +321,31 @@
|
|||||||
"none": "None"
|
"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": {
|
"headers": {
|
||||||
"routing": "Routing",
|
"routing": "Routing",
|
||||||
"received": "Received",
|
"received": "Received",
|
||||||
|
|||||||
Reference in New Issue
Block a user