feat: show details toggle and panel on mobile sender info
This commit is contained in:
+323
-304
@@ -4143,310 +4143,6 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expandable Details */}
|
|
||||||
{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 }) => (
|
|
||||||
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
|
|
||||||
{children}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="mt-3 pt-3 border-t border-border grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
|
|
||||||
<section className="min-w-0">
|
|
||||||
<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>
|
|
||||||
</Row>
|
|
||||||
{replyToDifferent && (
|
|
||||||
<Row label={t('reply_to_label').replace(':', '')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{email.replyTo!.map((r, i) => (
|
|
||||||
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.to && email.to.length > 0 && (
|
|
||||||
<Row label={t('to')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.cc && email.cc.length > 0 && (
|
|
||||||
<Row label={t('cc')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.bcc && email.bcc.length > 0 && (
|
|
||||||
<Row label={t('bcc')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.sentAt && (
|
|
||||||
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
|
|
||||||
)}
|
|
||||||
<Row label={t('details.received')}>
|
|
||||||
{fullDate(email.receivedAt)}
|
|
||||||
{deliveryDeltaMs > 60000 && (
|
|
||||||
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{hasAuthSection && (
|
|
||||||
<section className="min-w-0">
|
|
||||||
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{auth?.spf && (
|
|
||||||
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
|
|
||||||
)}
|
|
||||||
{auth?.dkim && (
|
|
||||||
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
|
||||||
)}
|
|
||||||
{auth?.dmarc && (
|
|
||||||
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
|
|
||||||
)}
|
|
||||||
{auth?.iprev && (
|
|
||||||
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
|
|
||||||
)}
|
|
||||||
{email.spamScore !== undefined && (
|
|
||||||
<span className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
|
||||||
email.spamScore > 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",
|
|
||||||
)}>
|
|
||||||
<Shield className={cn(
|
|
||||||
"w-3.5 h-3.5",
|
|
||||||
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",
|
|
||||||
)} />
|
|
||||||
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
|
|
||||||
<span className={cn(
|
|
||||||
"text-[10px] uppercase tracking-wider",
|
|
||||||
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)}
|
|
||||||
</span>
|
|
||||||
{email.spamStatus && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground/50">·</span>
|
|
||||||
<span className="text-muted-foreground">{email.spamStatus}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasIdentifiers && (
|
|
||||||
<section className="min-w-0">
|
|
||||||
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
|
|
||||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
{email.messageId && (
|
|
||||||
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
|
|
||||||
)}
|
|
||||||
{email.inReplyTo && email.inReplyTo.length > 0 && (
|
|
||||||
<Row label={t('details.in_reply_to')} mono>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="min-w-0">
|
|
||||||
<SectionHeader>{t('details.message_properties')}</SectionHeader>
|
|
||||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
{email.subject !== undefined && (
|
|
||||||
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
|
|
||||||
)}
|
|
||||||
<Row label={t('details.size')}>
|
|
||||||
{formatFileSize(email.size)}
|
|
||||||
{topMimeType && (
|
|
||||||
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
{effectiveAttachments.length > 0 && (
|
|
||||||
<Row label={t('attachments')}>
|
|
||||||
{t('details.attachments_summary', {
|
|
||||||
count: effectiveAttachments.length,
|
|
||||||
size: formatFileSize(totalAttachmentSize),
|
|
||||||
})}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.accountLabel && (
|
|
||||||
<Row label={t('details.account')}>{email.accountLabel}</Row>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{hasListInfo && (
|
|
||||||
<section className="lg:col-span-2 min-w-0">
|
|
||||||
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
|
|
||||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
{listHeaders?.listId && (
|
|
||||||
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
|
|
||||||
)}
|
|
||||||
{listHeaders?.listUnsubscribe?.preferred && (
|
|
||||||
<Row label={t('details.list_unsubscribe')}>
|
|
||||||
<span className="break-all">
|
|
||||||
{listHeaders.listUnsubscribe.preferred === 'http'
|
|
||||||
? listHeaders.listUnsubscribe.http
|
|
||||||
: listHeaders.listUnsubscribe.mailto}
|
|
||||||
</span>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{listHeaders?.listHelp && (
|
|
||||||
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
|
|
||||||
)}
|
|
||||||
{listHeaders?.listPost && (
|
|
||||||
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/* Attachments on the right (beside-sender mode) */}
|
{/* Attachments on the right (beside-sender mode) */}
|
||||||
@@ -4648,6 +4344,22 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
{showFullHeaders ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
{t('hide_details')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
{t('show_details')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Date/time + size on the right (mobile) */}
|
{/* Date/time + size on the right (mobile) */}
|
||||||
@@ -4664,6 +4376,313 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Details (shared across mobile/tablet/desktop) */}
|
||||||
|
{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 }) => (
|
||||||
|
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
|
||||||
|
{children}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
|
||||||
|
<section className="min-w-0">
|
||||||
|
<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>
|
||||||
|
</Row>
|
||||||
|
{replyToDifferent && (
|
||||||
|
<Row label={t('reply_to_label').replace(':', '')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{email.replyTo!.map((r, i) => (
|
||||||
|
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.to && email.to.length > 0 && (
|
||||||
|
<Row label={t('to')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.cc && email.cc.length > 0 && (
|
||||||
|
<Row label={t('cc')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.bcc && email.bcc.length > 0 && (
|
||||||
|
<Row label={t('bcc')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.sentAt && (
|
||||||
|
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
|
||||||
|
)}
|
||||||
|
<Row label={t('details.received')}>
|
||||||
|
{fullDate(email.receivedAt)}
|
||||||
|
{deliveryDeltaMs > 60000 && (
|
||||||
|
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hasAuthSection && (
|
||||||
|
<section className="min-w-0">
|
||||||
|
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{auth?.spf && (
|
||||||
|
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
|
||||||
|
)}
|
||||||
|
{auth?.dkim && (
|
||||||
|
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
||||||
|
)}
|
||||||
|
{auth?.dmarc && (
|
||||||
|
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
|
||||||
|
)}
|
||||||
|
{auth?.iprev && (
|
||||||
|
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
|
||||||
|
)}
|
||||||
|
{email.spamScore !== undefined && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
||||||
|
email.spamScore > 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",
|
||||||
|
)}>
|
||||||
|
<Shield className={cn(
|
||||||
|
"w-3.5 h-3.5",
|
||||||
|
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",
|
||||||
|
)} />
|
||||||
|
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] uppercase tracking-wider",
|
||||||
|
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)}
|
||||||
|
</span>
|
||||||
|
{email.spamStatus && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">·</span>
|
||||||
|
<span className="text-muted-foreground">{email.spamStatus}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasIdentifiers && (
|
||||||
|
<section className="min-w-0">
|
||||||
|
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
{email.messageId && (
|
||||||
|
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
|
||||||
|
)}
|
||||||
|
{email.inReplyTo && email.inReplyTo.length > 0 && (
|
||||||
|
<Row label={t('details.in_reply_to')} mono>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="min-w-0">
|
||||||
|
<SectionHeader>{t('details.message_properties')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
{email.subject !== undefined && (
|
||||||
|
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
|
||||||
|
)}
|
||||||
|
<Row label={t('details.size')}>
|
||||||
|
{formatFileSize(email.size)}
|
||||||
|
{topMimeType && (
|
||||||
|
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
{effectiveAttachments.length > 0 && (
|
||||||
|
<Row label={t('attachments')}>
|
||||||
|
{t('details.attachments_summary', {
|
||||||
|
count: effectiveAttachments.length,
|
||||||
|
size: formatFileSize(totalAttachmentSize),
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.accountLabel && (
|
||||||
|
<Row label={t('details.account')}>{email.accountLabel}</Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hasListInfo && (
|
||||||
|
<section className="lg:col-span-2 min-w-0">
|
||||||
|
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
{listHeaders?.listId && (
|
||||||
|
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
|
||||||
|
)}
|
||||||
|
{listHeaders?.listUnsubscribe?.preferred && (
|
||||||
|
<Row label={t('details.list_unsubscribe')}>
|
||||||
|
<span className="break-all">
|
||||||
|
{listHeaders.listUnsubscribe.preferred === 'http'
|
||||||
|
? listHeaders.listUnsubscribe.http
|
||||||
|
: listHeaders.listUnsubscribe.mailto}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{listHeaders?.listHelp && (
|
||||||
|
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
|
||||||
|
)}
|
||||||
|
{listHeaders?.listPost && (
|
||||||
|
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* S/MIME Status Banner */}
|
{/* S/MIME Status Banner */}
|
||||||
{smimeStatus && (
|
{smimeStatus && (
|
||||||
<div className="border-b border-border bg-muted/30">
|
<div className="border-b border-border bg-muted/30">
|
||||||
|
|||||||
Reference in New Issue
Block a user