From c5b1731a63ac877ae474c896223328f536c143f2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:14:18 +0100 Subject: [PATCH] feat: add attachment position setting in email settings - Introduced a new setting for attachment position in email settings, allowing users to choose between displaying attachments beside the sender or below the header. - Updated the settings store to include the new attachment position type and default value. - Added translations for the new setting in multiple languages (de, en, es, fr, it, ja, nl, pt). --- app/[locale]/page.tsx | 4 +- app/globals.css | 6 + components/email/email-viewer.tsx | 565 +++++++++++++++++++------ components/settings/email-settings.tsx | 12 + locales/de/common.json | 16 +- locales/en/common.json | 16 +- locales/es/common.json | 16 +- locales/fr/common.json | 16 +- locales/it/common.json | 16 +- locales/ja/common.json | 16 +- locales/nl/common.json | 16 +- locales/pt/common.json | 16 +- stores/settings-store.ts | 4 + 13 files changed, 574 insertions(+), 145 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 41b1a8de..520fbb56 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -816,13 +816,13 @@ export default function Home() { }; }, []); - const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => { + const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => { if (!client) return; try { const { mailAttachmentAction } = useSettingsStore.getState(); - if (mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { + if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { setPreviewAttachment({ blobId, name, type }); return; } diff --git a/app/globals.css b/app/globals.css index f6649424..eac64432 100644 --- a/app/globals.css +++ b/app/globals.css @@ -20,6 +20,8 @@ --color-accent-foreground: #1e40af; --color-destructive: #ef4444; --color-destructive-foreground: #ffffff; + --color-popover: #ffffff; + --color-popover-foreground: #0f172a; /* Settings variables */ --font-size-base: 16px; @@ -50,6 +52,8 @@ --color-accent-foreground: #dbeafe; --color-destructive: #ef4444; --color-destructive-foreground: #fafafa; + --color-popover: #1c1c1c; + --color-popover-foreground: #fafafa; } @theme inline { @@ -68,6 +72,8 @@ --color-accent-foreground: var(--color-accent-foreground); --color-destructive: var(--color-destructive); --color-destructive-foreground: var(--color-destructive-foreground); + --color-popover: var(--color-popover); + --color-popover-foreground: var(--color-popover-foreground); } * { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index fec4f7ce..b4ccd9d3 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -105,7 +105,7 @@ interface EmailViewerProps { onToggleStar?: () => void; onMarkAsRead?: (emailId: string, read: boolean) => void; onSetColorTag?: (emailId: string, color: string | null) => void; - onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; + onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void; onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; onUndoSpam?: () => void; @@ -149,6 +149,42 @@ const getFileIcon = (name?: string, type?: string) => { return File; }; +const MIME_TYPE_LABELS: Record = { + 'application/pdf': 'Document.pdf', + 'application/zip': 'Archive.zip', + 'application/x-zip-compressed': 'Archive.zip', + 'application/gzip': 'Archive.gz', + 'application/x-rar-compressed': 'Archive.rar', + 'application/x-7z-compressed': 'Archive.7z', + 'application/msword': 'Document.doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Document.docx', + 'application/vnd.ms-excel': 'Spreadsheet.xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Spreadsheet.xlsx', + 'application/vnd.ms-powerpoint': 'Presentation.ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Presentation.pptx', + 'text/plain': 'Text.txt', + 'text/html': 'Document.html', + 'text/csv': 'Data.csv', + 'application/json': 'Data.json', + 'application/xml': 'Data.xml', + 'application/octet-stream': 'Attachment', + 'message/rfc822': 'Email.eml', +}; + +const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: string): string => { + if (name) return name; + if (mimeType) { + const label = MIME_TYPE_LABELS[mimeType.toLowerCase()]; + if (label) return label; + const sub = mimeType.split('/')[1]; + if (sub) { + const clean = sub.replace(/^x-/, '').replace(/^vnd\./, ''); + return `Attachment.${clean}`; + } + } + return 'Attachment'; +}; + const getCurrentColor = (keywords: Record | undefined) => { if (!keywords) return null; for (const key of Object.keys(keywords)) { @@ -837,6 +873,7 @@ export function EmailViewer({ const tFiles = useTranslations('files'); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); + const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const emailKeywords = useSettingsStore((state) => state.emailKeywords); @@ -864,6 +901,8 @@ export function EmailViewer({ const { identities, client } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [showFullHeaders, setShowFullHeaders] = useState(false); + const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); + const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); const [allowExternalContent, setAllowExternalContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false); const [cidBlobUrls, setCidBlobUrls] = useState>({}); @@ -2394,6 +2433,44 @@ export function EmailViewer({ setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); }, [mailAttachmentAction, onDownloadAttachment]); + const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => { + if (attachment.blobId && onDownloadAttachment) { + onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true); + return; + } + + if (attachment.tnefData) { + const buffer = attachment.tnefData.buffer.slice( + attachment.tnefData.byteOffset, + attachment.tnefData.byteOffset + attachment.tnefData.byteLength, + ) as ArrayBuffer; + const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = attachment.name || 'download'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 60000); + return; + } + + if (!attachment.decryptedAttachment) return; + const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); + if (!bytes || bytes.byteLength === 0) return; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = attachment.name || 'download'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + }, [onDownloadAttachment]); + // Iframe for rendering HTML emails true-to-life const iframeRef = useRef(null); @@ -2913,6 +2990,21 @@ export function EmailViewer({ + {/* Dark/light mode toggle for HTML emails */} + {effectiveEmailContent.isHtml && ( + + )} + {/* More menu — click-based */}
+ {/* Overflow: dark/light mode toggle */} + {effectiveEmailContent.isHtml && ( + + )}
{/* Export email */} + {effectiveEmailContent.isHtml && ( + + )}
@@ -3408,13 +3524,14 @@ export function EmailViewer({ name={sender?.name} email={sender?.email} size="lg" - className="shadow-sm w-12 h-12 group-hover:ring-2 group-hover:ring-primary/30 transition-all" + className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all" /> -
- {/* Sender line with email and badges */} -
+
+
+ {/* Row 1: Sender name + badges */} +
- {sender?.email && ( -
- {sender.email} - {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( - { - const messageId = email?.messageId || ''; - const newSet = new Set(dismissedUnsubBanners).add(messageId); - setDismissedUnsubBanners(newSet); - localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet])); - }} - /> - )} -
- )} -
- {/* Date and size on the right */} -
-
- {formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} -
- {email.size > 0 && ( -
- {formatFileSize(email.size)} -
- )} - {effectiveEmailContent.isHtml && ( - + {/* Email address under name */} + {sender?.email && sender?.name && ( +
{sender.email}
)}
- {/* Recipient section - separate line */} -
+ {/* Row 2: Recipients + Show details */} +
{email.to && email.to.length > 0 && ( -
- {t('recipient_to_prefix')} + <> + {t('recipient_to_prefix')} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} {email.to.length > 2 && ( )} -
+ )} - {email.cc && email.cc.length > 0 && ( -
- CC: + <> + | + CC: {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} {email.cc.length > 2 && ( - +{email.cc.length - 2} + +{email.cc.length - 2} )} -
+ )} - {email.bcc && email.bcc.length > 0 && ( -
- {t('bcc')}: + <> + | + {t('bcc')}: {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)} {email.bcc.length > 2 && ( - +{email.bcc.length - 2} + +{email.bcc.length - 2} )} -
+ )} +
- {/* Details toggle - stays in place when expanded */} - - {/* Expandable Details */} {showFullHeaders && (
@@ -3893,46 +3987,248 @@ export function EmailViewer({
)} +
+ {/* Attachments on the right (beside-sender mode) */} + {attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && ( +
+ {effectiveAttachments.slice(0, 2).map((attachment) => { + const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); + const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); + const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + return ( +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+ + {opensPreview && ( + + )} +
+
+ ); + })} + {effectiveAttachments.length > 2 && ( + + )} + {/* Floating popup for remaining attachments */} + {showAllBesideAttachments && effectiveAttachments.length > 2 && ( + <> +
setShowAllBesideAttachments(false)} /> +
+ {effectiveAttachments.slice(2).map((attachment) => { + const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); + const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); + const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + return ( +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+ + {opensPreview && ( + + )} +
+
+ ); + })} +
+ + )} +
+ )}
- {/* === ATTACHMENTS (integrated into header) === */} - {effectiveAttachments.length > 0 && ( -
-
+ {/* === ATTACHMENTS below header (below-header mode, desktop only) === */} + {attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && ( +
+
{effectiveAttachments.map((attachment) => { const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - + {opensPreview && ( + + )}
- {opensPreview ? ( - - ) : ( - - )} - +
); })}
)} + {/* Mobile/Tablet Attachments */} + {effectiveAttachments.length > 0 && ( +
+
+ {effectiveAttachments.slice(0, 2).map((attachment) => { + const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); + const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); + const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + return ( +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+ + {opensPreview && ( + + )} +
+
+ ); + })} + {effectiveAttachments.length > 2 && ( + + )} + {showAllMobileAttachments && effectiveAttachments.length > 2 && ( + <> +
setShowAllMobileAttachments(false)} /> +
+ {effectiveAttachments.slice(2).map((attachment) => { + const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); + const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); + const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + return ( +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+ + {opensPreview && ( + + )} +
+
+ ); + })} +
+ + )} +
+
+ )} + {/* Mobile/Tablet Sender Info - scrolls with content */}
@@ -3949,8 +4245,8 @@ export function EmailViewer({ />
- {/* Mobile 2-line layout */} -
+ {/* Row 1: Sender name + badges */} +
-
-
- {sender?.email && sender?.name && ( - <> - {sender.email} - {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( - { - const messageId = email?.messageId || ''; - const newSet = new Set(dismissedUnsubBanners).add(messageId); - setDismissedUnsubBanners(newSet); - localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet])); - }} - /> - )} - · - + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + { + const messageId = email?.messageId || ''; + const newSet = new Set(dismissedUnsubBanners).add(messageId); + setDismissedUnsubBanners(newSet); + localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet])); + }} + /> )} +
+ {/* Email address under name */} + {sender?.email && sender?.name && ( +
{sender.email}
+ )} + {/* Row 2: Recipients */} +
{email.to && email.to.length > 0 && ( <> → {t('recipient_to_prefix')} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} )} + {email.cc && email.cc.length > 0 && ( + <> + | + CC: + {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} + {email.cc.length > 2 && ( + +{email.cc.length - 2} + )} + + )}
- {/* CC line (mobile - only if present) */} - {email.cc && email.cc.length > 0 && ( -
- CC: - {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} - {email.cc.length > 2 && ( - +{email.cc.length - 2} - )} -
- )}
diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx index ee6189bf..ed74e35e 100644 --- a/components/settings/email-settings.tsx +++ b/components/settings/email-settings.tsx @@ -24,6 +24,7 @@ export function EmailSettings() { emailsPerPage, externalContentPolicy, mailAttachmentAction, + attachmentPosition, emailAlwaysLightMode, archiveMode, trustedSenders, @@ -195,6 +196,17 @@ export function EmailSettings() { /> + + ()( emailsPerPage: state.emailsPerPage, externalContentPolicy: state.externalContentPolicy, mailAttachmentAction: state.mailAttachmentAction, + attachmentPosition: state.attachmentPosition, archiveMode: state.archiveMode, trustedSenders: state.trustedSenders, autoSaveDraftInterval: state.autoSaveDraftInterval,