From 73ab3c8670cea5bc34009593e9c8e1d6477e8906 Mon Sep 17 00:00:00 2001 From: hamedf62 Date: Thu, 30 Jul 2026 23:33:01 +0530 Subject: [PATCH 1/2] Add RTL/Farsi compose and viewer support --- components/email/email-viewer.tsx | 3 +- components/email/rich-text-editor.tsx | 27 +++++++++--- components/email/text-direction.ts | 63 --------------------------- lib/email-sanitization.ts | 2 +- stores/settings-store.ts | 2 +- 5 files changed, 24 insertions(+), 73 deletions(-) delete mode 100644 components/email/text-direction.ts diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0aa7fb975..343263423 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2202,9 +2202,10 @@ export function EmailViewer({ word's min-content width so columns are not collapsed to a single char. */ td, th { overflow-wrap: break-word; } pre { white-space: pre-wrap; word-wrap: break-word; } + [dir="auto"] { unicode-bidi: plaintext; } ${wordHtmlCSS} ${darkModeCSS} -${effectiveEmailContent.html}`; + ${effectiveEmailContent.html}`; }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]); // Unblocking external content is handled by rebuilding the iframe srcDoc: diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 446865b61..6cf5f32aa 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -8,7 +8,9 @@ import Heading from "@tiptap/extension-heading"; import Underline from "@tiptap/extension-underline"; import Link from "@tiptap/extension-link"; import TextAlign from "@tiptap/extension-text-align"; -import { TextDirection } from "@/components/email/text-direction"; +// NOTE: @tiptap/core v3 ships a built-in TextDirection extension (auto-loaded +// via enableCoreExtensions). We no longer ship a custom one; configure it via +// the `textDirection` editor option below. import { TextStyle } from "@tiptap/extension-text-style"; import Color from "@tiptap/extension-color"; import { ResizableImage } from "@/components/email/resizable-image"; @@ -32,7 +34,8 @@ import { AlignLeft, AlignCenter, AlignRight, - ArrowLeftRight, + ArrowRightToLine as LtrIcon, + ArrowLeftToLine as RtlIcon, Link as LinkIcon, Undo, Redo, @@ -255,8 +258,12 @@ export function RichTextEditor({ // rich/branded signatures keep their inline styling in the editor and // in the sent mail (see signature-block.ts). SignatureBlock, - TextDirection, ], + // Enable @tiptap/core v3's built-in TextDirection extension with "auto" + // default. Each block auto-detects direction from its first strong + // character (RTL for Farsi/Arabic/Hebrew, LTR for Latin). The toolbar + // button can still pin an explicit ltr/rtl per block. + textDirection: "auto", content, editorProps: { attributes: { @@ -530,16 +537,22 @@ export function RichTextEditor({ {rtlEditingSupport && ( { const cur = - editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir; - editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run(); + editor.getAttributes("paragraph").dir || + editor.getAttributes("heading").dir || + "auto"; + const next = cur === "rtl" ? "ltr" : "rtl"; + editor.chain().focus().setTextDirection(next).run(); }} title={tToolbar("text_direction")} > - + {(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl" + ? + : } )} diff --git a/components/email/text-direction.ts b/components/email/text-direction.ts deleted file mode 100644 index 6665c3732..000000000 --- a/components/email/text-direction.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Extension } from "@tiptap/core"; - -export type TextDir = "ltr" | "rtl"; - -declare module "@tiptap/core" { - interface Commands { - textDirection: { - setTextDirection: (dir: TextDir) => ReturnType; - unsetTextDirection: () => ReturnType; - }; - } -} - -/** - * Adds a `dir` attribute to block nodes so the composer can mark individual - * paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). - * - * The default is `"auto"`: each block detects its own direction from its first - * strong character, so a paragraph typed in English renders LTR and one typed - * in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins - * an explicit `ltr`/`rtl` when you want to override the auto-detection, and the - * attribute round-trips to HTML so the direction is preserved in the sent mail. - */ -export const TextDirection = Extension.create({ - name: "textDirection", - - addOptions() { - return { types: ["paragraph", "heading", "blockquote", "listItem"] }; - }, - - addGlobalAttributes() { - return [ - { - types: this.options.types, - attributes: { - dir: { - default: "auto", - parseHTML: (element) => element.getAttribute("dir") || "auto", - renderHTML: (attributes) => - attributes.dir ? { dir: attributes.dir } : { dir: "auto" }, - }, - }, - }, - ]; - }, - - addCommands() { - return { - setTextDirection: - (dir) => - ({ commands }) => - this.options.types.every((type: string) => - commands.updateAttributes(type, { dir }), - ), - unsetTextDirection: - () => - ({ commands }) => - this.options.types.every((type: string) => - commands.resetAttributes(type, "dir"), - ), - }; - }, -}); diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index 06ac7faa5..fd6216a1f 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -12,7 +12,7 @@ export const EMAIL_SANITIZE_CONFIG = { // components/email/quoted-html.ts). Explicitly whitelisted despite // ALLOW_DATA_ATTR:false so the viewer can detect and collapse the quoted // original (lib/quote-collapse.ts); it's inert otherwise. - ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color', 'data-quoted-html'], + ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color', 'dir', 'data-quoted-html'], ALLOW_DATA_ATTR: false, FORCE_BODY: true, // Allow blob: URIs so authenticated inline images (CID) are not stripped. diff --git a/stores/settings-store.ts b/stores/settings-store.ts index cf32971e1..9a46d8299 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -402,7 +402,7 @@ const DEFAULT_SETTINGS = { defaultReplyMode: 'reply' as ReplyMode, autoSelectReplyIdentity: false, plainTextMode: false, - rtlEditingSupport: false, + rtlEditingSupport: true, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, sendDelaySeconds: 0 as SendDelaySeconds, signaturePosition: 'below_quote' as SignaturePosition, From 191f3b64fffa0c89d932ce9f955528fc4dfa5bf9 Mon Sep 17 00:00:00 2001 From: hamedf62 Date: Fri, 31 Jul 2026 00:02:26 +0530 Subject: [PATCH 2/2] refactor: streamline settings migration logic for clarity and maintainability - Reorganized the migration logic for settings to improve readability. - Ensured consistent formatting and indentation throughout the migration function. - Maintained existing functionality while enhancing code structure. --- components/email/email-viewer.tsx | 4315 +++++++++++++++-------------- stores/settings-store.ts | 116 +- 2 files changed, 2217 insertions(+), 2214 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 343263423..8102e6f19 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -225,7 +225,7 @@ const _formatRecipients = ( const firstRecipient = recipients[0]; const isFirstRecipientMe = currentUserEmail && (firstRecipient.email.toLowerCase() === currentUserEmail.toLowerCase() || - firstRecipient.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+')); + firstRecipient.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+')); // If only one recipient and it's the current user, show "me" if (recipients.length === 1 && isFirstRecipientMe) { @@ -332,7 +332,7 @@ function renderClickableRecipients( return visible.map((r, index) => { const isMe = currentUserEmail && (r.email.toLowerCase() === currentUserEmail.toLowerCase() || - r.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+')); + r.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+')); return ( @@ -1065,11 +1065,11 @@ export function EmailViewer({ const sidebarContact = contactSidebarEmail ? contacts.find((c) => { - if (!c.emails) return false; - return Object.values(c.emails).some( - (e) => e.address.toLowerCase() === contactSidebarEmail.toLowerCase() - ); - }) ?? null + if (!c.emails) return false; + return Object.values(c.emails).some( + (e) => e.address.toLowerCase() === contactSidebarEmail.toLowerCase() + ); + }) ?? null : null; // Close contact sidebar when email changes @@ -2347,7 +2347,7 @@ export function EmailViewer({ const htmlEl = el as HTMLElement; // Skip elements already handled by CSS attribute selectors if (htmlEl.style.backgroundImage || htmlEl.style.background || - htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return; + htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return; // Skip leaf media elements (already re-inverted by CSS) const tag = htmlEl.tagName; if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return; @@ -2874,840 +2874,1088 @@ export function EmailViewer({ )} {!isScheduled && !isDraft && (<> - - - + + + )} {/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */} {!isScheduled && ( -
- {/* Archive */} - - {/* Delete */} - - {/* Move to folder */} - {moveTree.length > 0 && onMoveToMailbox && ( -
- - {moveMenuOpen && ( -
- {(() => { - const renderNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderNodes(moveTree); - })()} -
- )} -
- )} - {/* Tag Picker - hidden on mobile, overflows to More menu */} -
-
-
- + {/* Delete */} + + {/* Move to folder */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+ + {moveMenuOpen && ( +
+ {(() => { + const renderNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderNodes(moveTree); + })()} +
+ )} +
+ )} + {/* Tag Picker - hidden on mobile, overflows to More menu */} +
+
+
+ - {tagMenuOpen && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - + + ) : ( + <> + + {showToolbarLabels && {t('tag')}} + + )} + + {tagMenuOpen && ( +
+ {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( + <> +
+ + + )} +
)}
+
+ + {/* Spam */} + {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( + )} -
-
- {/* Spam */} - {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( + {/* Toggle read state */} - )} - {/* Toggle read state */} - - - {/* Print - hidden on mobile, overflows to More menu */} - - - {/* View source - hidden on mobile, overflows to More menu */} - - - {/* Dark/light mode toggle for HTML emails */} - {effectiveEmailContent.isHtml && ( - - )} + {/* Print - hidden on mobile, overflows to More menu */} + - {/* More menu - click-based */} -
+ {/* View source - hidden on mobile, overflows to More menu */} - {moreMenuOpen && !isMobile && ( -
- {/* Star toggle */} - - {/* Overflow: reply */} - - {/* Overflow: reply all */} - - {/* Overflow: forward */} - - {/* Overflow: archive */} - - {/* Overflow: move to folder - submenu */} - {moveTree.length > 0 && onMoveToMailbox && ( -
setMoreMenuSub('move')} - onMouseLeave={() => setMoreMenuSub(null)} - > - - {moreMenuSub === 'move' && ( -
- {(() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} -
- )} -
- )} - {/* Overflow: tag - submenu */} - {colorOptions.length > 0 && ( -
setMoreMenuSub('tag')} - onMouseLeave={() => setMoreMenuSub(null)} + + {/* Dark/light mode toggle for HTML emails */} + {effectiveEmailContent.isHtml && ( + + )} + + {/* More menu - click-based */} +
+ + {moreMenuOpen && !isMobile && ( +
+ {/* Star toggle */} + - {moreMenuSub === 'tag' && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} -
- )} -
- )} - {/* Overflow: spam */} - {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( + + {isStarred ? t('tooltips.unstar') : t('tooltips.star')} + + {/* Overflow: reply */} - )} - {/* Overflow: toggle read */} - - {/* Overflow: print */} - - {/* Overflow: view source */} - - {/* Overflow: dark/light mode toggle */} - {effectiveEmailContent.isHtml && ( + {/* Overflow: reply all */} - )} -
- {/* Forward as attachment */} - {onForwardAsAttachment && email?.blobId && ( + {/* Overflow: forward */} - )} - {/* Export email */} - - {/* Import email */} - - {onShowShortcuts && ( + {/* Overflow: archive */} - )} -
- )} -
-
- )} - - ); - - return ( -
- {/* Mobile More menu sidebar overlay */} - {!isScheduled && isMobile && moreMenuOpen && ( -
setMoreMenuOpen(false)} - /> - )} - {!isScheduled && isMobile && ( -
-
- {moreMenuSub ? ( - - ) : ( - {t('more_actions')} - )} - -
-
- {moreMenuSub === null && ( - <> - {/* Star toggle */} - - {/* Tag (opens sub-view) */} - {colorOptions.length > 0 && ( + {/* Overflow: move to folder - submenu */} + {moveTree.length > 0 && onMoveToMailbox && ( +
setMoreMenuSub('move')} + onMouseLeave={() => setMoreMenuSub(null)} + > + + {moreMenuSub === 'move' && ( +
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+ )} +
+ )} + {/* Overflow: tag - submenu */} + {colorOptions.length > 0 && ( +
setMoreMenuSub('tag')} + onMouseLeave={() => setMoreMenuSub(null)} + > + + {moreMenuSub === 'tag' && ( +
+ {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( + <> +
+ + + )} +
+ )} +
+ )} + {/* Overflow: spam */} + {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( + + )} + {/* Overflow: toggle read */} - )} - - - {effectiveEmailContent.isHtml && ( + {/* Overflow: print */} - )} -
- {onForwardAsAttachment && email?.blobId && ( + {/* Overflow: view source */} - )} - + {/* Overflow: dark/light mode toggle */} + {effectiveEmailContent.isHtml && ( + + )} +
+ {/* Forward as attachment */} + {onForwardAsAttachment && email?.blobId && ( + + )} + {/* Export email */} + + {/* Import email */} + + {onShowShortcuts && ( + + )} +
+ )} +
+
+ )} + + ); + + return ( +
+ {/* Mobile More menu sidebar overlay */} + {!isScheduled && isMobile && moreMenuOpen && ( +
setMoreMenuOpen(false)} + /> + )} + {!isScheduled && isMobile && ( +
+
+ {moreMenuSub ? ( - {onShowShortcuts && ( + ) : ( + {t('more_actions')} + )} + +
+
+ {moreMenuSub === null && ( + <> + {/* Star toggle */} - )} - - )} - {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} + {/* Tag (opens sub-view) */} + {colorOptions.length > 0 && ( +
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} - {moreMenuSub === 'tag' && colorOptions.length > 0 && ( - <> - {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( + + + )} + + + {effectiveEmailContent.isHtml && ( - ); - })} - {currentColors.length > 0 && ( + )} +
+ {onForwardAsAttachment && email?.blobId && ( + + )} - )} - - )} -
-
- )} - {/* Main email content */} -
- {/* === TOOLBAR (top position) === */} - {toolbarPosition === 'top' && ( -
-
-
- {renderToolbarItems(true)} -
-
-
- )} - - {/* === SUBJECT BLOCK === */} -
-
-
- {/* Back button (for below-subject mode on tablet) */} - {toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && ( - - )} -
-
-

- {email.subject || t('no_subject')} -

- {/* Star inline with subject (top toolbar mode) */} - {toolbarPosition === 'top' && ( + + {onShowShortcuts && ( )} - {/* Color tag dots */} + + )} + {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} + {moreMenuSub === 'tag' && colorOptions.length > 0 && ( + <> + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} {currentColors.length > 0 && ( - - {currentColors.map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; - const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; - return ( - - ); - })} - - )} - {isImportant && ( - - {t('important')} - + )} + + )} +
+
+ )} + {/* Main email content */} +
+ {/* === TOOLBAR (top position) === */} + {toolbarPosition === 'top' && ( +
+
+
+ {renderToolbarItems(true)}
- {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} -
- - {formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} - - {email.size > 0 && ( -
- {formatFileSize(email.size)} -
+
+ )} + + {/* === SUBJECT BLOCK === */} +
+
+
+ {/* Back button (for below-subject mode on tablet) */} + {toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && ( + )} +
+
+

+ {email.subject || t('no_subject')} +

+ {/* Star inline with subject (top toolbar mode) */} + {toolbarPosition === 'top' && ( + + )} + {/* Color tag dots */} + {currentColors.length > 0 && ( + + {currentColors.map((tagId) => { + const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; + const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; + return ( + + ); + })} + + )} + {isImportant && ( + + {t('important')} + + )} +
+
+ {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} +
+ + {formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} + + {email.size > 0 && ( +
+ {formatFileSize(email.size)} +
+ )} +
-
- {/* === TOOLBAR (below-subject position) === */} - {toolbarPosition === 'below-subject' && ( -
-
-
- {renderToolbarItems(false)} + {/* === TOOLBAR (below-subject position) === */} + {toolbarPosition === 'below-subject' && ( +
+
+
+ {renderToolbarItems(false)} +
-
- )} + )} - {/* Email Content Area */} -
-
+ {/* Email Content Area */} +
+
- {/* === SENDER INFO (Desktop) === */} -
-
- + {/* === SENDER INFO (Desktop) === */} +
+
+ -
-
- {/* Row 1: Sender name + badges */} -
-
-
- {sender?.email ? ( - +
+ {/* Row 1: Sender name + badges */} +
+
+
+ {sender?.email ? ( + + ) : ( + {t('unknown_sender')} + )} + + {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 + Show details */} +
+ {email.to && email.to.length > 0 && ( + <> + {t('recipient_to_prefix')} + {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} + {email.to.length > 2 && ( + + )} + + )} + {email.cc && email.cc.length > 0 && ( + <> + | + CC: + {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} + {email.cc.length > 2 && ( + +{email.cc.length - 2} + )} + + )} + {email.bcc && email.bcc.length > 0 && ( + <> + | + {t('bcc')}: + {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)} + {email.bcc.length > 2 && ( + +{email.bcc.length - 2} + )} + + )} + +
+ + +
+ {/* 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'; + const thumbUrl = imageThumbUrls[attachment.id]; + return ( + + {(dragProps) => ( +
handleEffectiveAttachmentOpen(attachment)} + data-testid="attachment" + data-attachment-name={attachment.name} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} + > + {thumbUrl && ( +
+ +
+ )} +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+
+ + {opensPreview && ( + + )} +
+
+ )} +
+ ); + })} + {effectiveAttachments.length > 2 && ( + + )} + {downloadAllButton} + {/* 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 ( + + {(dragProps) => ( +
{ handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} + > + + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+ + {opensPreview && ( + + )} +
+
+ )} +
+ ); + })} +
+ + )} +
+ )} +
+
+
+ + {/* Mobile/Tablet Sender Info - scrolls with content */} +
+
+ +
+ {/* Row 1: Sender name + badges */} +
+ {sender?.email ? ( + ) : ( - {t('unknown_sender')} + {t('unknown_sender')} )} {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( @@ -3726,1469 +3974,1224 @@ export function EmailViewer({
{/* Email address under name */} {sender?.email && sender?.name && ( -
{sender.email}
+
{sender.email}
)} -
-
- - {/* Row 2: Recipients + Show details */} -
- {email.to && email.to.length > 0 && ( - <> - {t('recipient_to_prefix')} - {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} - {email.to.length > 2 && ( - - )} - - )} - {email.cc && email.cc.length > 0 && ( - <> - | - CC: - {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} - {email.cc.length > 2 && ( - +{email.cc.length - 2} + {/* Row 2: Recipients */} +
+ {email.to && email.to.length > 0 && ( + <> + → {t('recipient_to_prefix')} + {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} + )} - - )} - {email.bcc && email.bcc.length > 0 && ( - <> - | - {t('bcc')}: - {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)} - {email.bcc.length > 2 && ( - +{email.bcc.length - 2} + {email.cc && email.cc.length > 0 && ( + <> + | + CC: + {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} + {email.cc.length > 2 && ( + +{email.cc.length - 2} + )} + )} - - )} - +
+
+ {/* Date/time + size on the right (mobile) */} +
+ + {formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} + + {email.size > 0 && ( +
+ {formatFileSize(email.size)} +
)} - +
+
+ {/* 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 }) => ( +
+ {children} +
+ ); + const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => ( + <> +
{label}
+
{children}
+ + ); + const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => { + if (!result) return null; + const status = getSecurityStatus(result); + const Icon = status.icon === 'check' ? Check + : status.icon === 'x' ? X + : status.icon === 'alert' ? AlertTriangle + : Minus; + return ( + + + {name} + + {translateAuthResult(result)} + + {extra && ( + <> + · + {extra} + + )} + + ); + }; + + 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); + + // Projected, read-only view handed to plugins that render in the + // "more details" panel. Includes the parsed `headers` map and full + // `source` so plugins can inspect raw headers / message source. + // Built lazily here - only when the details panel is expanded. + const detailsView = emailToReadView(email); + // Lets a plugin add rows under an existing category. The plugin's + // own `shouldShow({ email, category })` decides which category it + // appears under (or `category === null` for the new bottom section). + const CategorySlot = ({ category }: { category: string }) => ( + + ); -
- {/* 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'; - const thumbUrl = imageThumbUrls[attachment.id]; - return ( - - {(dragProps) => ( -
handleEffectiveAttachmentOpen(attachment)} - data-testid="attachment" - data-attachment-name={attachment.name} - draggable={dragProps.draggable} - onPointerEnter={dragProps.onPointerEnter} - onDragStart={dragProps.onDragStart} - onDragEnd={dragProps.onDragEnd} - > - {thumbUrl && ( -
- + return ( +
+
+
+ {t('details.recipients_routing')} +
+ +
+ ` : undefined} + onViewContact={handleViewContactSidebar} + className="text-sm text-start" + />
+
+ {replyToDifferent && ( + +
+ {email.replyTo!.map((r, i) => ( + + ))} +
+
)} -
- - - {getAttachmentDisplayName(attachment.name, attachment.type)} - - - {formatFileSize(attachment.size)} - -
-
- - {opensPreview && ( - + {email.to && email.to.length > 0 && ( + +
+ {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)} +
+
+ )} + {email.cc && email.cc.length > 0 && ( + +
+ {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)} +
+
+ )} + {email.bcc && email.bcc.length > 0 && ( + +
+ {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)} +
+
+ )} + {email.sentAt && ( + {fullDate(email.sentAt)} + )} + + {fullDate(email.receivedAt)} + {deliveryDeltaMs > 60000 && ( + · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()} + )} + +
+ +
+ + {hasAuthSection && ( +
+ {t('details.authentication_security')} +
+ {auth?.spf && (() => { + // When multiple identities (HELO + MAIL FROM) were + // evaluated, list each result in the tooltip for full + // transparency; the chip itself shows the most severe. + const breakdown = auth.spf.all && auth.spf.all.length > 1 + ? auth.spf.all + .map((r) => { + const label = r.identity === 'mailfrom' ? 'MAIL FROM' : r.identity === 'helo' ? 'HELO' : 'SPF'; + return `${label}: ${translateAuthResult(r.result)}${r.domain ? ` (${r.domain})` : ''}`; + }) + .join('\n') + : null; + return ( + + ); + })()} + {auth?.dkim && ( + + )} + {auth?.dmarc && ( + + )} + {auth?.iprev && ( + + )} + {email.spamScore !== undefined && ( + 5 ? "bg-red-500/[0.07] border-red-500/30" : + email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" : + "bg-green-500/[0.07] border-green-500/30", + )}> + 5 ? "text-red-700 dark:text-red-400" : + email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : + "text-green-700 dark:text-green-400", + )} /> + {t('authentication.spam_score')} + 5 ? "text-red-700 dark:text-red-400" : + email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : + "text-green-700 dark:text-green-400", + )}> + {email.spamScore.toFixed(1)} + + {email.spamStatus && ( + <> + · + {email.spamStatus} + + )} + )}
-
+ {email.spamLLM && ( +
+ {email.spamLLM.verdict === 'LEGITIMATE' ? : + email.spamLLM.verdict === 'SPAM' ? : + } +
+ + {email.spamLLM.verdict} + + · {email.spamLLM.explanation} +
+
)} - - ); - })} - {effectiveAttachments.length > 2 && ( -
+
+ ); + })()} + + {/* Scheduled Banner */} + {isScheduled && ( +
+
+
+ + + {t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })} + +
+
+ {canCancelScheduled && ( + <> + + + + + + )} +
+
+
+ )} + + {/* Draft Banner */} + {isDraft && ( +
+
+
+ + {t('draft_banner')} +
+ {onEditDraft && ( + + + {t('edit_draft')} + )} - {downloadAllButton} - {/* 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 ( - - {(dragProps) => ( -
{ handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }} - draggable={dragProps.draggable} - onPointerEnter={dragProps.onPointerEnter} - onDragStart={dragProps.onDragStart} - onDragEnd={dragProps.onDragEnd} - > - - - {getAttachmentDisplayName(attachment.name, attachment.type)} - - - {formatFileSize(attachment.size)} - -
+
+
+ )} + + {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} + {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || + hasCalendarInvitation || + (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && ( +
+
+
+ {/* External Content Controls */} + {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( +
+
+ +
+
+
+
+ External Content +
+
+ {t('external_content_warning')} +
+
+
+ {externalContentPolicy === 'ask' && ( - {opensPreview && ( - - )} -
-
)} - - ); - })} -
- - )} -
- )} -
-
-
- - {/* Mobile/Tablet Sender Info - scrolls with content */} -
-
- -
- {/* Row 1: Sender name + badges */} -
- {sender?.email ? ( - - ) : ( - {t('unknown_sender')} - )} - - {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} - )} - - )} - -
-
- {/* Date/time + size on the right (mobile) */} -
- - {formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} - - {email.size > 0 && ( -
- {formatFileSize(email.size)} -
- )} -
-
-
+ {email.from?.[0]?.email && ( + + )} +
+
+
+ )} - {/* 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 }) => ( -
- {children} -
- ); - const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => ( - <> -
{label}
-
{children}
- - ); - const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => { - if (!result) return null; - const status = getSecurityStatus(result); - const Icon = status.icon === 'check' ? Check - : status.icon === 'x' ? X - : status.icon === 'alert' ? AlertTriangle - : Minus; - return ( - - - {name} - - {translateAuthResult(result)} - - {extra && ( - <> - · - {extra} - - )} - - ); - }; - 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); - - // Projected, read-only view handed to plugins that render in the - // "more details" panel. Includes the parsed `headers` map and full - // `source` so plugins can inspect raw headers / message source. - // Built lazily here - only when the details panel is expanded. - const detailsView = emailToReadView(email); - // Lets a plugin add rows under an existing category. The plugin's - // own `shouldShow({ email, category })` decides which category it - // appears under (or `category === null` for the new bottom section). - const CategorySlot = ({ category }: { category: string }) => ( - - ); - return ( -
-
-
- {t('details.recipients_routing')} -
- -
- ` : undefined} - onViewContact={handleViewContactSidebar} - className="text-sm text-start" - /> -
-
- {replyToDifferent && ( - -
- {email.replyTo!.map((r, i) => ( - - ))} -
-
- )} - {email.to && email.to.length > 0 && ( - -
- {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)} -
-
- )} - {email.cc && email.cc.length > 0 && ( - -
- {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)} -
-
- )} - {email.bcc && email.bcc.length > 0 && ( - -
- {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)} -
-
- )} - {email.sentAt && ( - {fullDate(email.sentAt)} - )} - - {fullDate(email.receivedAt)} - {deliveryDeltaMs > 60000 && ( - · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()} - )} - -
- -
- - {hasAuthSection && ( -
- {t('details.authentication_security')} -
- {auth?.spf && (() => { - // When multiple identities (HELO + MAIL FROM) were - // evaluated, list each result in the tooltip for full - // transparency; the chip itself shows the most severe. - const breakdown = auth.spf.all && auth.spf.all.length > 1 - ? auth.spf.all - .map((r) => { - const label = r.identity === 'mailfrom' ? 'MAIL FROM' : r.identity === 'helo' ? 'HELO' : 'SPF'; - return `${label}: ${translateAuthResult(r.result)}${r.domain ? ` (${r.domain})` : ''}`; - }) - .join('\n') - : null; - return ( - + sendReadReceiptNow(false)} + onIgnore={ignoreReadReceipt} /> - ); - })()} - {auth?.dkim && ( - - )} - {auth?.dmarc && ( - - )} - {auth?.iprev && ( - - )} - {email.spamScore !== undefined && ( - 5 ? "bg-red-500/[0.07] border-red-500/30" : - email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" : - "bg-green-500/[0.07] border-green-500/30", - )}> - 5 ? "text-red-700 dark:text-red-400" : - email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : - "text-green-700 dark:text-green-400", - )} /> - {t('authentication.spam_score')} - 5 ? "text-red-700 dark:text-red-400" : - email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : - "text-green-700 dark:text-green-400", - )}> - {email.spamScore.toFixed(1)} - - {email.spamStatus && ( - <> - · - {email.spamStatus} - - )} - - )} -
- {email.spamLLM && ( -
- {email.spamLLM.verdict === 'LEGITIMATE' ? : - email.spamLLM.verdict === 'SPAM' ? : - } -
- - {email.spamLLM.verdict} - - · {email.spamLLM.explanation}
-
- )} - -
- )} - - {hasIdentifiers && ( -
- {t('details.identifiers_threading')} -
- {email.messageId && ( - {email.messageId} - )} - {email.inReplyTo && email.inReplyTo.length > 0 && ( - -
- {email.inReplyTo.map((id, i) =>
{id}
)} -
-
- )} - {email.references && email.references.length > 0 && ( - -
- - - {t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })} - -
- {email.references.map((id, i) =>
{id}
)} -
-
-
- )} - {email.threadId && ( - {email.threadId} )} -
- -
- )} -
- {t('details.message_properties')} -
- {email.subject !== undefined && ( - {email.subject || {t('details.no_subject')}} - )} - - {formatFileSize(email.size)} - {topMimeType && ( - · {topMimeType} - )} - - {effectiveAttachments.length > 0 && ( - - {t('details.attachments_summary', { - count: effectiveAttachments.length, - size: formatFileSize(totalAttachmentSize), - })} - - )} - {email.accountLabel && ( - {email.accountLabel} - )} -
- -
- - {hasListInfo && ( -
- {t('details.mailing_list')} -
- {listHeaders?.listId && ( - {listHeaders.listId} - )} - {listHeaders?.listUnsubscribe?.preferred && ( - - - {listHeaders.listUnsubscribe.preferred === 'http' - ? listHeaders.listUnsubscribe.http - : listHeaders.listUnsubscribe.mailto} - - - )} - {listHeaders?.listHelp && ( - {listHeaders.listHelp} - )} - {listHeaders?.listPost && ( - {listHeaders.listPost} - )} -
- -
- )} - - {/* Plugin-supplied category. Plugins whose shouldShow accepts - `category === null` render their own titled section here. */} - {hasDetailsSlotOffers && ( -
- -
- )} -
-
- ); - })()} - - {/* Scheduled Banner */} - {isScheduled && ( -
-
-
- - - {t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })} - -
-
- {canCancelScheduled && ( - <> - - - - - - )} -
-
-
- )} - - {/* Draft Banner */} - {isDraft && ( -
-
-
- - {t('draft_banner')} -
- {onEditDraft && ( - - )} -
-
- )} - - {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} - {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || - hasCalendarInvitation || - (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && ( -
-
-
- {/* External Content Controls */} - {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( -
-
- -
-
-
-
- External Content -
-
- {t('external_content_warning')} + {/* Calendar Invitation Banner */} + {hasCalendarInvitation && ( +
+
-
-
- {externalContentPolicy === 'ask' && ( - - )} - {email.from?.[0]?.email && ( - - )} -
+ )}
- )} - - - - {/* Read-receipt (MDN) request banner — only in "ask" mode */} - {readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && ( -
- sendReadReceiptNow(false)} - onIgnore={ignoreReadReceipt} - /> -
- )} - - {/* Calendar Invitation Banner */} - {hasCalendarInvitation && ( -
- -
- )} -
-
-
- )} +
+ )} - + - {/* === ATTACHMENTS below header (below-header mode, desktop only) === */} - {attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && ( -
-
-
- {/* Hidden ghost row used purely for measuring chip widths */} -