Files
webmail/components/email/thread-conversation-view.tsx
T
Linus Rath 7401ea6658 fix: recolour mails for dark mode instead of inverting them
Dark mode used to invert the whole message body with a CSS filter and
re-invert images and coloured cells. Brand colours came out shifted
(red turned pink), white cells set through bgcolor stayed white, and
the result changed after loading remote content because the blocker
strips the background attributes the selectors relied on.

The rendered body is now read back through computed styles and its
colours are rewritten in place: light backgrounds are darkened in their
own hue, vivid and dark fills stay as sent, text and borders only change
when they lack contrast, and anything painted on a background image is
left alone. Transparent dark images get their original light background
behind them when their pixels show they would vanish. The thread
conversation view uses the same pass.

Also fixed along the way:
- blocking remote content dropped every leading `<style>` block, so
  mails lost their CSS until images were loaded
- a mail's `prefers-color-scheme` rules followed the OS instead of the
  app theme
2026-10-11 18:43:04 +02:00

724 lines
28 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
import {
ArrowLeft,
ChevronDown,
ChevronUp,
Reply,
ReplyAll,
Forward,
Paperclip,
Star,
Download,
Loader2,
FileText,
FileImage,
FileVideo,
FileAudio,
FileArchive,
File,
Eye,
} from "@/components/icons";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
interface ThreadConversationViewProps {
thread: ThreadGroup;
emails: Email[];
isLoading?: boolean;
onBack: () => void;
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
}
// Helper function to get file icon based on mime type or extension
const getFileIcon = (name?: string, type?: string) => {
const ext = name?.split('.').pop()?.toLowerCase();
const mimeType = type?.toLowerCase();
if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
return FileImage;
}
if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) {
return FileVideo;
}
if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) {
return FileAudio;
}
if (mimeType === 'application/pdf' || ext === 'pdf') {
return FileText;
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
return FileArchive;
}
return File;
};
export function ThreadConversationView({
thread,
emails,
isLoading = false,
onBack,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
onMarkAsRead,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [allowExternalContent, setAllowExternalContent] = useState<Set<string>>(new Set());
// Auto-expand most recent email AND all unread emails when thread opens
useEffect(() => {
if (emails.length > 0) {
const idsToExpand = new Set<string>();
// Always expand most recent
idsToExpand.add(emails[0].id);
// Also expand all unread emails
emails.forEach(email => {
if (!email.keywords?.$seen) {
idsToExpand.add(email.id);
}
});
setExpandedIds(idsToExpand);
}
}, [emails]);
const toggleExpanded = (emailId: string) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(emailId)) {
next.delete(emailId);
} else {
next.add(emailId);
}
return next;
});
};
const toggleAllowExternal = (emailId: string) => {
setAllowExternalContent(prev => {
const next = new Set(prev);
next.add(emailId);
return next;
});
};
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("threads.loading")}</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
<button
onClick={onBack}
className="p-2 -ms-2 rounded-full hover:bg-muted transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1 min-w-0">
<h1 className="font-semibold text-foreground break-words">
{thread.latestEmail.subject || t("email_viewer.no_subject")}
</h1>
<p className="text-sm text-muted-foreground">
{t("threads.messages_other", { count: emails.length })}
</p>
</div>
</div>
{/* Email Cards */}
<div className="flex-1 overflow-y-auto">
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return (
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
allowExternal={externalContentPolicy === 'allow' || senderIsTrusted || allowExternalContent.has(email.id)}
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id);
} : undefined}
onReply={onReply ? () => onReply(email) : undefined}
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
onForward={onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
onMarkAsRead={onMarkAsRead}
/>
);
})}
</div>
</div>
</div>
);
}
// Individual email card component
interface EmailCardProps {
email: Email;
isExpanded: boolean;
isLatest: boolean;
allowExternal: boolean;
onToggleExpanded: () => void;
onAllowExternal: () => void;
onTrustSender?: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
}
function EmailCard({
email,
isExpanded,
isLatest: _isLatest,
allowExternal,
onToggleExpanded,
onAllowExternal,
onTrustSender,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
onMarkAsRead,
}: EmailCardProps) {
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const wopiStatus = useWopiStatus(true);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode;
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes);
const verificationCode = useMemo(
() => (showVerificationCodes && isExpanded ? findVerificationCode(email.subject, verificationCodeBodyText(email)) : null),
// A keyword change replaces the email object; only another body needs another look.
// eslint-disable-next-line react-hooks/exhaustive-deps
[showVerificationCodes, isExpanded, email.id, email.subject, email.bodyValues],
);
const sender = email.from?.[0];
const formatAddress = useOwnDomainAddress();
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
const { client } = useAuthStore();
// Mark as read when email is expanded
useEffect(() => {
// Only trigger if expanded, email is unread, and we have a handler
if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) {
return;
}
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
// Never auto-mark
if (markAsReadDelay === -1) {
return;
}
// Instant mark
if (markAsReadDelay === 0) {
onMarkAsRead(email.id, true);
return;
}
// Delayed mark
const timeout = setTimeout(() => {
onMarkAsRead(email.id, true);
}, markAsReadDelay);
return () => clearTimeout(timeout);
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
if (!client || !email?.attachments) {
setCidBlobUrls({});
return;
}
const cidAttachments = email.attachments.filter(att => att.cid && att.blobId);
if (cidAttachments.length === 0) {
setCidBlobUrls({});
return;
}
let cancelled = false;
const objectUrls: string[] = [];
async function fetchCidBlobs() {
const urls: Record<string, string> = {};
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
// Re-type sender-declared script-bearing parts before they become a
// blob: URL in our origin (GHSA-xvjh-v9c6-qcvc).
const objectUrl = URL.createObjectURL(toInertBlob(await client!.fetchBlob(att.blobId, att.name || 'inline', att.type)));
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
} else {
URL.revokeObjectURL(objectUrl);
}
} catch {
// Failed to fetch inline image, will show placeholder
}
}));
if (!cancelled) {
setCidBlobUrls(urls);
}
}
fetchCidBlobs();
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [client, email?.id, email?.attachments]);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
if (email.bodyValues) {
let htmlContent = getRenderableHtmlBody(email);
if (htmlContent) {
// Replace cid: references with authenticated blob URLs (fetched via useEffect)
// This prevents browser auth dialogs that occur when loading raw JMAP download URLs
if (email.attachments) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(_match, cidRef) => {
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
);
}
let blockedExternalContent = false;
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// Re-apply the data:-URI allowlist DOMPurify skips on media tags.
restrictDataUriResourcesOnNode(node);
// The desktop viewer's blocker: srcset, <source>, poster,
// background attributes and escaped url()s as well as img src.
if (!allowExternal && blockExternalResourcesOnNode(node)) {
blockedExternalContent = true;
}
if (node.tagName === 'A' || node.tagName === 'AREA') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
DOMPurify.removeHook('afterSanitizeAttributes');
let finalHtml = sanitized;
if (blockedExternalContent) {
if (!hasBlockedContent) setHasBlockedContent(true);
finalHtml = collapseBlockedImageContainers(sanitized);
}
return { html: finalHtml, isHtml: true };
}
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
return {
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
}),
isHtml: false,
};
}
}
// Fallback to preview
if (email.preview) {
const previewHtml = email.preview
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return { html: previewHtml, isHtml: false };
}
return { html: "", isHtml: false };
// `hasBlockedContent` is only read to skip a redundant setState; as a
// dependency it would re-run the sanitizer once the banner shows.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, allowExternal, cidBlobUrls, t]);
// Parts the body embeds via cid: stay out of the attachment row while the
// user hides inline images - the desktop viewer's rule, shared through
// lib/attachment-visibility.ts so the two views cannot drift apart.
const visibleAttachments = useMemo(() => {
const attachments = email.attachments ?? [];
if (!hideInlineImageAttachments) return attachments;
const bodyCids = collectReferencedCids(getRenderableHtmlBody(email));
return attachments.filter(att => !isEmbeddedInBody(att, bodyCids));
}, [email, hideInlineImageAttachments]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
// emits a <script> tag through a parser quirk.
const iframeRef = useRef<HTMLIFrameElement>(null);
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml || !emailContent.html) return '';
// Strict while external content is blocked: the network-level backstop
// for whatever the DOM walk above cannot see.
const csp = emailIframeCsp(!allowExternal);
// Colours are rewritten after load (applyEmailDarkMode in handleIframeLoad).
return `<!DOCTYPE html><html class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<meta name="referrer" content="no-referrer">
<style>
/* overflow-y hidden keeps the scrollHeight measurement below honest. overflow-x
is auto so intrinsically wide content can pan instead of being clipped outright;
on a phone fitEmailBodyWidth() shrinks it to the screen first, so the pan is
only left for content too wide to stay legible when scaled. */
html { overflow: hidden; }
body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
/* Only force the cap on images that set no width of their own: an
!important 100% also overrides a sender's inline max-width, and an
image sized by max-width + max-height + width:100% then grows to
the pane width while its max-height still clamps the height - the
picture renders stretched. Same reasoning as the table rule below (#790).
height stays !important so a fixed inline height cannot squash it. */
img:not([style*="max-width"]) { max-width: 100% !important; }
img[style*="max-width"] { max-width: 100%; }
img { height: auto !important; }
a { color: #1a73e8; }
/* Only force the cap on tables that set no width of their own: an
!important 100% would also override a newsletter's inline
max-width:600px and stretch it across the pane. (#790) */
table:not([style*="max-width"]) { max-width: 100% !important; }
table[style*="max-width"] { max-width: 100%; }
table { table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${isDark ? emailDarkModeCss() : ''}
</style></head><body dir="auto">${emailContent.html}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark]);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (!doc?.body) return;
// Collapse the quoted original of a reply behind a "•••" toggle (#480),
// before the first resize so the height reflects the collapsed body.
setupQuoteCollapse(doc, {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
});
if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal });
const resize = () => {
// Fit desktop-width mail to the screen before measuring: the transform
// leaves the layout height untouched, so scale the box by hand.
const scale = fitEmailBodyWidth(doc);
const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
iframe.style.height = Math.ceil(height * scale) + 'px';
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(doc.body);
doc.querySelectorAll('a, area').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, [t, isDark, allowExternal]);
return (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-s-2 border-l-primary"
)}>
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
className={cn(
"w-full flex items-start text-start transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={cn(
"font-medium truncate",
isUnread ? "text-foreground" : "text-muted-foreground"
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"}
</span>
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
{email.hasAttachment && (
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)}
</div>
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{cleanPreview(email.preview) || t('email_viewer.no_preview_available')}
</p>
)}
</div>
<div className="flex-shrink-0 p-1">
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
{/* External content warning */}
{hasBlockedContent && !allowExternal && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("email_viewer.external_content_warning")}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAllowExternal();
}}
>
{t("email_viewer.load_external_content")}
</Button>
{onTrustSender && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onTrustSender();
}}
>
{t("email_viewer.trust_sender")}
</Button>
)}
</div>
</div>
)}
{verificationCode && (
<div className="flex" style={{ paddingInline: 'var(--density-card-p)', paddingTop: 'var(--density-card-p)' }}>
<VerificationCodeChip code={verificationCode} className="py-1 text-sm" />
</div>
)}
{/* Email Body */}
<div style={{ padding: 'var(--density-card-p)' }}>
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '60px' }}
onLoad={handleIframeLoad}
/>
) : (
<div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
/>
)}
</div>
{/* Attachments */}
{visibleAttachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{visibleAttachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<button
key={idx}
onClick={(e) => {
e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
}}
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="truncate max-w-[150px]">{attachment.name || 'Attachment'}</span>
<span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)}
</span>
{opensPreview ? (
<Eye className="w-4 h-4 text-muted-foreground" />
) : (
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
);
})}
</div>
</div>
)}
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
{onReply && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReply();
}}
className="flex-1"
>
<Reply className="w-4 h-4 me-2" />
{t("email_viewer.reply")}
</Button>
)}
{onReplyAll && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReplyAll();
}}
className="flex-1"
>
<ReplyAll className="w-4 h-4 me-2" />
{t("email_viewer.reply_all")}
</Button>
)}
{onForward && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onForward();
}}
className="flex-1"
>
<Forward className="w-4 h-4 me-2" />
{t("email_viewer.forward")}
</Button>
)}
</div>
</div>
)}
</div>
);
}