Files
webmail/components/email/email-viewer.tsx
T

6666 lines
316 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { hasFullBleedCanvas } from "@/lib/email-full-bleed";
import { stripBidiControls } from "@/lib/bidi";
import { withBasePath } from "@/lib/browser-navigation";
import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { SubMenuPanel } from "@/components/ui/context-menu";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { emailDisplayDate } from "@/lib/email-date";
import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
import { generateEmailSource } from "@/lib/email-source";
import {
Reply,
ReplyAll,
Forward,
Paperclip,
Trash2,
Archive,
Star,
MoreVertical,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Download,
Mail,
MailOpen,
Loader2,
Printer,
FileText,
FileImage,
FileVideo,
FileAudio,
FileArchive,
File,
Eye,
Shield,
Image,
Tag,
X,
Check,
AlertTriangle,
Minus,
ShieldCheck,
ShieldAlert,
Code,
Copy,
Brain,
Keyboard,
Phone,
Building,
MapPin,
StickyNote,
PanelRightClose,
PanelRightOpen,
Send,
FolderInput,
FolderX,
Inbox,
Folder,
Sun,
Upload,
Moon,
EditIcon,
PlayCircle,
PenSquare,
CalendarClock,
Link as LinkIcon,
Maximize2,
Minimize2,
Filter,
} from "@/components/icons";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import { rescheduleFailureToast, sendFailureMessage } from "@/lib/send-failure-message";
import { SubmissionQuotaError } from "@/lib/jmap/client";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, emailLinkSource, type EmailBlobContext } from "@/stores/email-store";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { ReadReceiptBanner } from "./read-receipt-banner";
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
import { openPreviewInNewWindow } from "@/lib/standalone-display";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
import { ConversationEmailBody, type ConversationAttachmentHandler, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { RulesPanel, useRulesAvailability } from "./rules-menu";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, renderHooks } from "@/lib/plugin-hooks";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { resolveExternalLink } from "@/lib/plugin-hook-result";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
/** The More menu's two drill-downs: a folder list and a tag list. */
type MoreMenuSub = 'move' | 'tag' | 'rules';
/** Whatever a sub-view offers to act on, in the order it is read out. */
const SUB_MENU_ITEM_SELECTOR = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
interface EmailViewerProps {
email: Email | null;
/** Captured message routing supplied by the standard mail UI. */
blobSource?: EmailBlobContext;
isLoading?: boolean;
onReply?: (draftText?: string) => void;
onReplyAll?: () => void;
onForward?: () => void;
onForwardAsAttachment?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onToggleStar?: () => void;
/**
* `automatic` is the mark-as-read on opening a message. A refusal of that
* (a read-only shared folder) is not the user's doing and needs no notice.
*/
onMarkAsRead?: (emailId: string, read: boolean, opts?: { automatic?: boolean }) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void;
onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onMoveToMailbox?: (mailboxId: string) => void;
/**
* Take the message out of the open folder only (#615). Passed only when
* the message is also in another folder.
*/
onRemoveFromFolder?: () => void;
onBack?: () => void;
onNavigateNext?: () => void;
onNavigatePrev?: () => void;
/** Mountain View: where the open message sits in the list, for the "1 of N" pager. */
mvPosition?: { index: number; total: number };
/** Mountain View: the folder the message is in, shown as a chip by the subject. */
mvFolderName?: string;
/**
* Mountain View: every message of the open conversation, oldest first,
* including the open one. The others are drawn around it as cards that
* fold and unfold in place, as Gmail does; unread ones start unfolded.
*/
mvConversation?: Email[];
/**
* Mountain View: the open message's conversation is still on its way. The
* viewer waits for it rather than showing the message alone first. (#1223)
*/
mvConversationPending?: boolean;
/** Mountain View: fetch the full message for a conversation card that was unfolded. */
onLoadConversationEmail?: (email: Email) => void;
/** Mountain View: an unfolded conversation message was read. */
onMarkConversationEmailRead?: (email: Email) => void;
/** Mountain View: reply to one message of the conversation. */
onReplyConversationEmail?: (email: Email) => void;
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
mvInlineComposer?: React.ReactNode;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: (delayedUntil: string) => void | Promise<void>;
onCompose?: () => void;
/**
* Fullscreen reading toggle (standard interface only - Pro email tabs are
* fullscreen by construction). When set, the toolbar shows a maximize /
* minimize button; `isFullscreen` reflects the host's current state.
*/
onToggleFullscreen?: () => void;
isFullscreen?: boolean;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
mailboxes?: Mailbox[];
selectedMailbox?: string;
className?: string;
}
// 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;
}
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')) {
return FileText;
}
return File;
};
const MIME_TYPE_LABELS: Record<string, string> = {
'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 => {
// Without directional overrides, which would show `report‮fdp.exe`
// as `reportexe.pdf` (the download name is cleaned separately).
if (name) return stripBidiControls(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';
};
// Helper function to format recipients with contextual display
const _formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined,
currentUserEmail: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string
): string => {
if (!recipients || recipients.length === 0) return '';
// Check if the first recipient is the current user
const firstRecipient = recipients[0];
const isFirstRecipientMe = currentUserEmail &&
(firstRecipient.email.toLowerCase() === currentUserEmail.toLowerCase() ||
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) {
return t('recipient_me');
}
// Format up to 2 recipients by name (or email if no name)
const displayRecipients = recipients.slice(0, 2).map((r, index) => {
if (index === 0 && isFirstRecipientMe) {
return t('recipient_me');
}
return r.name || r.email;
});
// If more than 2 recipients, add count
if (recipients.length > 2) {
const displayName = displayRecipients[0];
return t('recipient_and_others', { name: displayName, count: recipients.length - 1 });
}
return displayRecipients.join(', ');
};
function decodeBase64Bytes(input: string): Uint8Array | null {
const cleaned = input.replace(/\s/g, '');
if (!cleaned) return null;
try {
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
} catch {
return null;
}
}
function getAttachmentContentBytes(attachment: {
content?: ArrayBuffer | Uint8Array | string;
encoding?: 'base64' | 'utf8';
}): Uint8Array | null {
const { content, encoding } = attachment;
if (content instanceof Uint8Array) {
return content;
}
if (content instanceof ArrayBuffer) {
return new Uint8Array(content);
}
if (typeof content === 'string') {
if (encoding === 'base64') {
return decodeBase64Bytes(content);
}
return new TextEncoder().encode(content);
}
return null;
}
/**
* Check if an HTML body string is effectively empty (just boilerplate/whitespace).
* Outlook often generates HTML bodies with Word CSS + &nbsp; but no real text.
*/
function isHtmlBodyEffectivelyEmpty(html: string): boolean {
const textContent = html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&#160;/g, ' ')
.replace(/\s+/g, '')
.trim();
return textContent.length === 0;
}
interface EffectiveAttachment {
id: string;
name: string | null;
type: string;
size: number;
blobId?: string;
cid?: string;
decryptedAttachment?: PostalMimeAttachment;
tnefData?: Uint8Array;
}
function getPostalMimeAttachmentSize(attachment: PostalMimeAttachment): number {
const bytes = getAttachmentContentBytes(attachment);
return bytes?.byteLength ?? 0;
}
// Helper to render clickable recipient elements with popovers
function renderClickableRecipients(
recipients: Array<{ name?: string; email: string }>,
currentUserEmail: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string,
onViewContact?: (contact: ContactCard | null, email: string) => void,
maxVisible: number = 2
) {
const visible = recipients.slice(0, maxVisible);
return visible.map((r, index) => {
const isMe = currentUserEmail &&
(r.email.toLowerCase() === currentUserEmail.toLowerCase() ||
r.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+'));
return (
<span key={r.email + index} className="inline-flex items-center">
{index > 0 && <span className="text-muted-foreground me-1">,</span>}
<RecipientPopover
name={r.name}
email={r.email}
displayLabel={isMe ? t('recipient_me') : undefined}
onViewContact={onViewContact}
className="text-sm"
/>
</span>
);
});
}
// Contact sidebar panel that slides in from the right on desktop
export function ContactSidebarPanel({
email,
contact,
senderName,
onClose,
onAddToContacts,
onEditContact,
}: {
email: string;
contact: ContactCard | null;
senderName?: string;
onClose: () => void;
onAddToContacts?: (email: string, name?: string) => void;
onEditContact?: () => void;
}) {
const t = useTranslations('email_viewer');
const tCommon = useTranslations('common');
const name = contact ? getContactDisplayName(contact) : senderName || null;
const primaryEmail = contact ? getContactPrimaryEmail(contact) : email;
const emails = contact?.emails ? Object.values(contact.emails) : [];
const phones = contact?.phones ? Object.values(contact.phones) : [];
const orgs = contact?.organizations ? Object.values(contact.organizations) : [];
const addresses = contact?.addresses ? Object.values(contact.addresses) : [];
const notes = contact?.notes ? Object.values(contact.notes) : [];
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t('contact_sidebar.copied'));
} catch {
toast.error(t('contact_sidebar.copy_failed'));
}
};
return (
<div className="w-[320px] shrink-0 border-s border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
{/* Header - h-14 with the border on the wrapper so it lines up with
the viewer toolbar and the folder sidebar header. */}
<div className="border-b border-border shrink-0">
<div className="flex items-center justify-between gap-2 h-14 ps-4 pe-2">
<h3 className="text-sm font-semibold text-foreground truncate">{t('contact_sidebar.title')}</h3>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-9 w-9 shrink-0"
aria-label={t('contact_sidebar.close')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Profile section */}
<div className="px-4 pt-5 pb-4 flex flex-col items-center text-center">
<Avatar
name={name || email}
email={primaryEmail}
size="lg"
/>
<div className="mt-3 min-w-0 w-full">
<div className="font-semibold text-base truncate">
{name || email}
</div>
{name && (
<div className="text-sm text-muted-foreground truncate mt-0.5">
{primaryEmail}
</div>
)}
{orgs.length > 0 && orgs[0].name && (
<div className="text-sm text-muted-foreground flex items-center justify-center gap-1 mt-1">
<Building className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{orgs[0].name}</span>
</div>
)}
</div>
</div>
{/* Quick actions - styled like the shared Button (default + outline,
size sm) since MailtoLink renders an anchor, not a Button. */}
<div className="px-4 pb-5 flex flex-col gap-2">
<MailtoLink
to={primaryEmail}
className="inline-flex items-center justify-center gap-2 h-9 px-3 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
title={t('contact_sidebar.action_email_title')}
>
<Send className="w-4 h-4" />
{t('contact_sidebar.action_email')}
</MailtoLink>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(primaryEmail)}
className="flex-1 min-w-0 gap-2"
title={t('contact_sidebar.action_copy_title')}
>
<Copy className="w-4 h-4 shrink-0" />
<span className="truncate">{t('contact_sidebar.action_copy')}</span>
</Button>
{contact && onEditContact && (
<Button
variant="outline"
size="sm"
onClick={onEditContact}
className="flex-1 min-w-0 gap-2"
title={t('contact_sidebar.action_edit_title')}
>
<EditIcon className="w-4 h-4 shrink-0" />
<span className="truncate">{tCommon('edit')}</span>
</Button>
)}
</div>
</div>
{/* Details sections */}
{contact && (
<div className="px-4 pb-4 space-y-4">
{/* Emails */}
{emails.length > 0 && (
<SidebarSection icon={Mail} title={t('contact_sidebar.section_emails')}>
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<MailtoLink to={e.address} className="text-sm text-primary hover:underline truncate">
{e.address}
</MailtoLink>
<button
onClick={() => handleCopy(e.address)}
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100 shrink-0"
title="Copy"
>
<Copy className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</SidebarSection>
)}
{/* Phones */}
{phones.length > 0 && (
<SidebarSection icon={Phone} title={t('contact_sidebar.section_phones')}>
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
<button
onClick={() => handleCopy(p.number)}
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100 shrink-0"
title="Copy"
>
<Copy className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</SidebarSection>
)}
{/* Organizations */}
{orgs.length > 1 && (
<SidebarSection icon={Building} title={t('contact_sidebar.section_organizations')}>
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
</SidebarSection>
)}
{/* Addresses */}
{addresses.length > 0 && (
<SidebarSection icon={MapPin} title={t('contact_sidebar.section_addresses')}>
{addresses.map((a, i) => (
<div key={i} className="text-sm text-muted-foreground">
{a.full || a.fullAddress
? (a.full || a.fullAddress)
: a.components && a.components.length > 0
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
</div>
))}
</SidebarSection>
)}
{/* Notes */}
{notes.length > 0 && (
<SidebarSection icon={StickyNote} title={t('contact_sidebar.section_notes')}>
{notes.map((n, i) => (
<p key={i} className="text-sm text-muted-foreground whitespace-pre-wrap">{n.note}</p>
))}
</SidebarSection>
)}
</div>
)}
{/* No contact found message */}
{!contact && (
<div className="px-4 pb-4 text-center space-y-3">
<p className="text-xs text-muted-foreground">
{t('contact_sidebar.not_in_contacts')}
</p>
{onAddToContacts && (
<button
onClick={() => onAddToContacts(email, senderName)}
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
>
<Mail className="w-3.5 h-3.5" />
{t('contact_sidebar.add_to_contacts')}
</button>
)}
</div>
)}
</div>
</div>
);
}
interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment;
client: IJMAPClient | null;
/** Owner accountId for the blob when it lives in a delegated/shared account. */
accountId?: string;
enabled: boolean;
downloadName?: string;
children: (dragProps: {
draggable: boolean;
onPointerEnter: () => void;
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
}) => React.ReactNode;
}
function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
return await client.fetchBlobAsObjectUrl(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId);
} catch {
return null;
}
}
if (attachment.tnefData) {
const bytes = attachment.tnefData;
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
return URL.createObjectURL(new Blob([buffer], { type: inertBlobType(attachment.type) }));
}
if (attachment.decryptedAttachment) {
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
if (!bytes || bytes.byteLength === 0) return null;
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
return URL.createObjectURL(new Blob([buffer], { type: inertBlobType(attachment.type) }));
}
return null;
},
}), [attachment, client, accountId, downloadName]);
const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>;
}
/**
* The time on a Mountain View sender line, followed by how long ago it was:
* "12:16 (3 minutes ago)" today, "Oct 10, 2026, 20:33 (2 days ago)" before.
*/
function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string {
const d = typeof date === 'string' ? new Date(date) : date;
// "Today" in the time zone setting, the zone the time and the relative
// part are shown in, not the browser's (#1259).
const zone = getEffectiveTimeZone();
const day = Number.isNaN(d.getTime()) ? null : getWallClock(d, zone);
const now = getWallClock(new Date(), zone);
const today = !!day && day.year === now.year && day.month === now.month && day.day === now.day;
const when = today
? formatDateTime(d, timeFormat)
: formatDateTime(d, timeFormat, { year: 'numeric', month: 'short', day: 'numeric' });
const ago = formatRelativeAgo(d);
return ago ? `${when} (${ago})` : when;
}
/**
* One message of a Mountain View conversation, folded to its sender, date and
* snippet. Clicking it unfolds the message in place.
*/
function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) {
const sender = email.from?.[0];
const unread = !email.keywords?.$seen;
return (
<button
type="button"
onClick={() => onOpen?.(email)}
data-mv-conversation-card=""
data-email-id={email.id}
className={cn("w-full flex items-start py-3 border-t border-border text-start hover:bg-muted/40 transition-colors", compact ? "gap-3 px-4" : "gap-4 px-6")}
>
<Avatar name={sender?.name} email={sender?.email} size="md" className="w-10 h-10 flex-shrink-0" />
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span className={cn("text-sm truncate", unread ? "font-bold" : "font-semibold")}>
{sender?.name || sender?.email || ''}
</span>
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
</span>
<span className="block text-sm text-muted-foreground truncate">{cleanPreview(email.preview)}</span>
</span>
</button>
);
}
/** True when a click landed on something inside `container` that acts on its own (a button, a link, a field). */
function isInteractiveClick(target: EventTarget | null, container: HTMLElement): boolean {
const el = target instanceof Element ? target.closest('button, a, input, textarea, select, [role="button"], [role="menuitem"]') : null;
return !!el && el !== container && container.contains(el);
}
/**
* The sender, recipients, dates and ids of an unfolded conversation message,
* as the open message's details panel lists them.
*/
function MvConversationDetails({ email, timeFormat, className }: { email: Email; timeFormat: '12h' | '24h'; className?: string }) {
const t = useTranslations('email_viewer');
const addresses = (list?: { name?: string | null; email?: string | null }[]) =>
(list ?? []).map((a) => (a.name && a.email ? `${a.name} <${a.email}>` : a.email || a.name || '')).join(', ');
const fullDate = (iso: string) =>
formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const rows: [string, string | undefined][] = [
[t('from'), addresses(email.from)],
[t('reply_to_label').replace(':', ''), addresses(email.replyTo)],
[t('to'), addresses(email.to)],
[t('cc'), addresses(email.cc)],
[t('bcc'), addresses(email.bcc)],
[t('details.sent'), email.sentAt ? fullDate(email.sentAt) : undefined],
[t('details.received'), fullDate(email.receivedAt)],
[t('subject'), email.subject],
[t('headers.message_id'), email.messageId],
];
return (
<dl className={cn("grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 pb-3 text-xs", className)} data-mv-conversation-details="">
{rows.filter(([, value]) => value).map(([label, value]) => (
<Fragment key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground min-w-0 break-words">{value}</dd>
</Fragment>
))}
</dl>
);
}
/**
* A message of a Mountain View conversation unfolded in place: its sender line,
* which folds it again when clicked, and its body. The list fetch carries no
* body, so the first unfold asks for the full message.
*/
export function MvExpandedConversationCard({
email,
timeFormat,
onCollapse,
onLoadBody,
onMarkRead,
onReply,
onDownloadAttachment,
blobSource,
compact,
}: {
email: Email;
timeFormat: '12h' | '24h';
compact?: boolean;
onCollapse: (email: Email) => void;
onLoadBody?: (email: Email) => void;
onMarkRead?: (email: Email) => void;
onReply?: (email: Email) => void;
onDownloadAttachment?: ConversationAttachmentHandler;
blobSource?: EmailBlobContext;
}) {
const t = useTranslations('email_viewer');
const externalContent = useConversationExternalContent();
const sender = email.from?.[0];
const hasBody = !!email.bodyValues;
const [showDetails, setShowDetails] = useState(false);
const loadRequested = useRef(false);
useEffect(() => {
if (hasBody || loadRequested.current || !onLoadBody) return;
loadRequested.current = true;
onLoadBody(email);
}, [hasBody, email, onLoadBody]);
// Unfolding an unread message reads it, as opening it does.
useMarkReadWhenShown(email, true, onMarkRead ? () => onMarkRead(email) : undefined);
return (
<div className="border-t border-border" data-mv-conversation-expanded="" data-email-id={email.id}>
<div
role="button"
tabIndex={0}
aria-expanded
onClick={(e) => {
if (isInteractiveClick(e.target, e.currentTarget)) return;
if (window.getSelection()?.toString()) return;
onCollapse(email);
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCollapse(email);
}
}}
data-mv-conversation-header=""
className={cn("flex items-start pt-3 pb-2 cursor-pointer", compact ? "gap-3 px-4" : "gap-4 px-6")}
>
<Avatar name={sender?.name} email={sender?.email} size="md" className="w-10 h-10 flex-shrink-0" />
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span className="text-sm font-semibold truncate">{sender?.name || sender?.email || ''}</span>
{!compact && sender?.name && sender.email && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
</span>
<button
type="button"
onClick={() => setShowDetails((v) => !v)}
aria-expanded={showDetails}
data-mv-conversation-details-toggle=""
className="flex items-center gap-0.5 max-w-full text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span className="truncate">
{email.to && email.to.length > 0
? `${t('to')} ${email.to.map((a) => a.name || a.email).join(', ')}`
: t(showDetails ? 'hide_details' : 'show_details')}
</span>
{showDetails ? <ChevronUp className="w-3 h-3 flex-shrink-0" /> : <ChevronDown className="w-3 h-3 flex-shrink-0" />}
</button>
</span>
{onReply && (
<button
type="button"
onClick={() => onReply(email)}
className="grid place-items-center w-10 h-10 -my-2 -me-2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex-shrink-0"
title={t('tooltips.reply')}
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
</button>
)}
</div>
{showDetails && <MvConversationDetails email={email} timeFormat={timeFormat} className={compact ? "px-4" : "ps-[80px] pe-6"} />}
{hasBody ? (
<ConversationEmailBody
email={email}
allowExternal={externalContent.isAllowed(email)}
onAllowExternal={() => externalContent.allow(email.id)}
onTrustSender={externalContent.trustSender(email)}
onDownloadAttachment={onDownloadAttachment}
blobSource={blobSource}
className={cn("pb-4", compact ? "px-4" : "ps-[80px] pe-6")}
bodyStyle={{ paddingBlock: '0.25rem 0.5rem' }}
/>
) : (
<div className={cn("pb-4 pt-1 space-y-2 animate-pulse", compact ? "px-4" : "ps-[80px] pe-6")} data-mv-conversation-loading="">
<div className="h-2 bg-muted rounded w-full" />
<div className="h-2 bg-muted rounded w-5/6" />
<div className="h-2 bg-muted rounded w-4/6" />
</div>
)}
</div>
);
}
/**
* The messages before or after the open one, each folded or unfolded. A long
* run of folded messages before it shows only its first and last message
* around a count, as Gmail does, and the count unfolds the rest.
*/
export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact }: {
emails: Email[];
compact?: boolean;
/** A folded message was clicked. */
onOpen?: (email: Email) => void;
timeFormat: '12h' | '24h';
collapseLongRun?: boolean;
expandedIds?: ReadonlySet<string>;
renderExpanded?: (email: Email) => React.ReactNode;
}) {
const [showAll, setShowAll] = useState(false);
if (emails.length === 0) return null;
const isExpanded = (e: Email) => !!renderExpanded && !!expandedIds?.has(e.id);
const renderOne = (e: Email) => isExpanded(e)
? <Fragment key={e.id}>{renderExpanded!(e)}</Fragment>
: <MvConversationCard key={e.id} email={e} onOpen={onOpen} timeFormat={timeFormat} compact={compact} />;
const middle = emails.slice(1, -1);
if (!collapseLongRun || showAll || emails.length <= 3 || middle.some(isExpanded)) {
return <>{emails.map(renderOne)}</>;
}
return (
<>
{renderOne(emails[0])}
<div className="relative border-t border-border h-4">
<button
type="button"
onClick={() => setShowAll(true)}
data-mv-conversation-more=""
className="absolute start-6 top-1/2 -translate-y-1/2 grid place-items-center w-10 h-10 rounded-full border border-border bg-background text-sm text-muted-foreground hover:bg-muted transition-colors"
>
{middle.length}
</button>
</div>
{renderOne(emails[emails.length - 1])}
</>
);
}
/** Mountain View's "1 of 23" with newer / older arrows, at the end of the message toolbar. */
function MvPager({
position,
onPrev,
onNext,
}: {
position: { index: number; total: number };
onPrev?: () => void;
onNext?: () => void;
}) {
const t = useTranslations('email_viewer');
return (
<div className="ms-auto flex items-center gap-0.5 flex-shrink-0" data-mv-pager="">
<span className="text-xs text-muted-foreground tabular-nums me-2 whitespace-nowrap">
{t('mv_position', { index: position.index.toLocaleString(), total: position.total.toLocaleString() })}
</span>
<button
type="button"
onClick={onPrev}
disabled={!onPrev}
className="grid place-items-center w-9 h-9 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent transition-colors"
title={t('tooltips.previous')}
aria-label={t('tooltips.previous')}
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
type="button"
onClick={onNext}
disabled={!onNext}
className="grid place-items-center w-9 h-9 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent transition-colors"
title={t('tooltips.next')}
aria-label={t('tooltips.next')}
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
);
}
/**
* The plugin slot under one "more details" category. Props are memoised so
* re-renders of the viewer don't post them to the plugin again.
*/
function DetailsCategorySlot({ email, category }: { email: EmailReadView | null; category: string | null }) {
const props = useMemo(() => ({ email, category }), [email, category]);
return <PluginSlot name="email-details-section" className="mt-2 empty:mt-0" extraProps={props} />;
}
/** Narrowest the message may get next to a plugin panel before the panel collapses. */
const MIN_READER_WIDTH = 480;
const NO_BODY_SELECTION = { text: '' };
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
return (
<div>
<div className="flex items-center gap-2 mb-1.5">
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{title}</h4>
</div>
<div className="space-y-1 ps-5.5">{children}</div>
</div>
);
}
// Shared "nothing here" values for the per-message attachment state. Every
// open resets that state; a fresh [] each time is a new value, so React
// renders the viewer once more even though nothing changed.
const NO_POSTAL_ATTACHMENTS: PostalMimeAttachment[] = [];
/**
* Largest winmail.dat or message/rfc822 part the reader downloads and parses
* on its own, on the UI thread, when a message is merely selected. A bigger
* one stays an attachment chip; a sender must not be able to make every
* selection of their message decode tens of megabytes.
*/
const MAX_INLINE_PARSE_BYTES = 5 * 1024 * 1024;
const NO_TNEF_ATTACHMENTS: TnefAttachment[] = [];
export function EmailViewer({
email,
blobSource,
isLoading = false,
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onDelete,
onArchive,
onToggleStar,
onMarkAsRead,
onSetTag,
onDownloadAttachment,
onQuickReply,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
onRemoveFromFolder,
onBack,
onNavigateNext,
onNavigatePrev,
mvPosition,
mvFolderName,
mvConversation,
mvConversationPending,
onLoadConversationEmail,
onMarkConversationEmailRead,
onReplyConversationEmail,
mvInlineComposer,
onShowShortcuts,
onEditDraft,
onCancelScheduledForEdit,
onRescheduleScheduled,
onCompose,
onToggleFullscreen,
isFullscreen = false,
currentUserEmail,
currentUserName,
currentMailboxRole,
mailboxes = [],
selectedMailbox = "",
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
const archiveActionEnabled = useArchiveActionEnabled();
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
const tDeepLink = useTranslations('deep_link');
const tContextMenu = useTranslations('context_menu');
const copyLink = useCopyLink();
const tWelcome = useTranslations('welcome');
const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled);
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
// Office documents the built-in preview can't render still open in the
// configured WOPI editor (#1047) - only blob-backed ones, the editor
// fetches the content server-side.
const wopiStatus = useWopiStatus(true);
const isAttachmentPreviewable = useCallback(
(attachment: EffectiveAttachment) =>
isFilePreviewable(attachment.name || undefined, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name)),
[wopiStatus],
);
const mailAttachmentActionRef = useRef(mailAttachmentAction);
mailAttachmentActionRef.current = mailAttachmentAction;
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
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 emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const mailLayout = useEffectiveMailLayout();
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes);
// The one-time code of a sign-in mail, read from the body as shown here, so
// it is found even where the list's preview stops short of it.
const verificationCode = useMemo(
() => (showVerificationCodes && email ? 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, email?.id, email?.subject, email?.bodyValues],
);
const dragOutActive = useMemo(() => isDragOutSupported(), []);
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement);
const filenameLowercase = useSettingsStore((state) => state.filenameLowercase);
const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics);
const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators);
const emailFilenameOptions = useMemo(() => ({
template: emailDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
const attachmentFilenameOptions = useMemo(() => ({
template: attachmentDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
// The top toolbar leaves the message column on desktops and tablets, so
// it runs over the side panels to the right edge.
const liftToolbar = !isMobile;
// Mountain View's conversation view: an icon-only toolbar at the top, and Reply /
// Reply all / Forward as buttons under the message rather than in the bar.
const mvViewer = interfaceLayout === 'mountain-view' && !isMobile;
// On the phone too Mountain View ends the message with Reply / Reply all / Forward
// and has no bar fixed at the bottom; previous / next move to the top bar.
const mvMobileViewer = interfaceLayout === 'mountain-view' && isMobile;
const mvReplyRow = mvViewer || mvMobileViewer;
// The open conversation around the open message, split at it.
const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : [];
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
const threadSummaryProps = useMemo(
() => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [], deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop' }),
[email?.threadId, mvConversationIds, isMobile, isTablet],
);
// Mountain View: which other messages of the conversation are unfolded. A
// conversation opens with its unread messages unfolded, as Gmail does, so
// reading the thread reads all of them.
const [mvExpandedIds, setMvExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
const mvExpandedIdsRef = useRef(mvExpandedIds);
mvExpandedIdsRef.current = mvExpandedIds;
const mvExpandedThreadRef = useRef<string | null>(null);
useEffect(() => {
if (!mvConversation || mvConversation.length === 0 || !email) {
mvExpandedThreadRef.current = null;
return;
}
const key = mvConversation[0].threadId;
if (mvExpandedThreadRef.current === key) return;
mvExpandedThreadRef.current = key;
setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id)));
}, [mvConversation, email]);
const toggleMvExpanded = useCallback((target: Email) => {
emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id));
setMvExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(target.id)) next.delete(target.id);
else next.add(target.id);
return next;
});
}, []);
// The open message folds too, but only inside a conversation.
const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false);
useEffect(() => setMvOpenCollapsed(false), [email?.id]);
const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed;
// Folding or unfolding the open message of a conversation, told to plugins
// like any other conversation message (onThreadExpand).
const setMvOpenFolded = (folded: boolean) => {
if (email) emitThreadExpand(email.threadId, [email], !folded);
setMvOpenCollapsed(folded);
};
// The conversation is not here yet (the one on hand is another thread's, or
// none). Hold the message back instead of drawing it alone and then pushing
// it down under the earlier messages.
const mvAwaitingConversation = !!mvConversationPending && (mvViewer || mvMobileViewer) && mvOpenIndex < 0;
const renderMvExpanded = (target: Email) => (
<MvExpandedConversationCard
email={target}
timeFormat={timeFormat}
onCollapse={toggleMvExpanded}
onLoadBody={onLoadConversationEmail}
onMarkRead={onMarkConversationEmailRead}
onReply={onReplyConversationEmail}
blobSource={target.sourceClientAccountId || target.sourceAccountId
? resolveEmailBlobContext(target, client)
: blobSource ?? resolveEmailBlobContext(target, client)}
onDownloadAttachment={(blobId, name, type, source) => onDownloadAttachment?.(blobId, name, type, false, source)}
compact={mvMobileViewer}
/>
);
// Mountain View's toolbars are icon-only, on the phone as on the desktop.
const showToolbarLabels = showToolbarLabelsSetting && interfaceLayout !== 'mountain-view';
// Inside a Pro pane, `isMobile` above is pane-width based: overlays that
// would go viewport-fixed must instead cover just the pane (via
// PaneOverlay + absolute positioning), and viewport CSS breakpoints like
// `sm:hidden` must not be trusted - the viewport may be desktop-sized
// while the pane is phone-sized.
const isPaneScoped = useIsPaneScoped();
const { tabletListVisible } = useUIStore(useShallow((state) => ({ tabletListVisible: state.tabletListVisible })));
const { identities, client, isDemoMode, activeAccountId } = useAuthStore(useShallow((state) => ({ identities: state.identities, client: state.client, isDemoMode: state.isDemoMode, activeAccountId: state.activeAccountId })));
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// The standard mail UI supplies message routing for preview/download,
// inline images, export and attachment drag-out. Other viewer hosts keep
// their existing fallback routing.
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
const legacyBlobClient = useMemo(() => {
const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
}, [isUnifiedView, email?.sourceClientAccountId, client]);
const legacyBlobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
const blobClient = blobSource ? blobSource.client : legacyBlobClient;
const blobAccountId = blobSource ? blobSource.accountId : legacyBlobAccountId;
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
// client, handing a mailto: URL to the OS mail handler goes nowhere for
// most users. Route to the email's own account in unified views and prefer
// the identity that received the newsletter, so the list can match the
// subscriber; sendEmail resolves the identity (with its own fallback to
// the account default) from the address we pass.
const handleSendMailtoUnsubscribe = async (fields: { to: string[]; subject?: string; body?: string }) => {
const sendClient = (email?.sourceClientAccountId
? useAuthStore.getState().getClientForAccount(email.sourceClientAccountId)
: undefined) ?? client;
if (!sendClient) throw new Error('Not connected');
const recipientAddresses = [...(email?.to ?? []), ...(email?.cc ?? [])].map(r => r.email?.toLowerCase());
// In unified views the owning account's identities are not loaded here -
// pass nothing and let its client fall back to its default identity.
const fromIdentity = email?.sourceClientAccountId
? undefined
: identities.find(i => i.email && recipientAddresses.includes(i.email.toLowerCase()));
try {
await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name);
} catch (error) {
// The banner only says the unsubscribe did not work. A full submission
// quota gets the same explanation as in the composer: retrying will
// not help until records are freed or the limit is raised.
if (error instanceof SubmissionQuotaError) toast.error(sendFailureMessage(error, tComposer));
throw error;
}
};
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
toast.error(tComposer('schedule_send_invalid'));
return null;
}
if (time <= Date.now()) {
toast.error(tComposer('schedule_send_future'));
return null;
}
if (!client?.hasDelayedSend()) {
toast.error(tComposer('schedule_send_unsupported'));
return null;
}
const maxDelayedSend = client.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
toast.error(tComposer('schedule_send_too_late'));
return null;
}
return new Date(time).toISOString();
}, [client, t, tComposer]);
// "Send now" and "Reschedule" submit a replacement and cancel the original;
// either can fail (a full submission quota, the mail already gone), and the
// failure used to end up as an unhandled rejection nobody saw.
const scheduledSendAt = email?.scheduledSendAt;
const rescheduleScheduled = useCallback(async (delayedUntil: string) => {
try {
await onRescheduleScheduled?.(delayedUntil);
} catch (error) {
console.error('Failed to reschedule the scheduled send:', error);
const { title, message } = rescheduleFailureToast(
error, t, tComposer, scheduledSendAt ? formatDateTime(scheduledSendAt, timeFormat) : undefined,
);
toast.error(title, message ? { message } : undefined);
}
}, [onRescheduleScheduled, t, tComposer, scheduledSendAt, timeFormat]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false);
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState<number | null>(null);
const belowHeaderRowRef = useRef<HTMLDivElement>(null);
const belowHeaderGhostRef = useRef<HTMLDivElement>(null);
const [imageThumbUrls, setImageThumbUrls] = useState<Record<string, string>>({});
const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
// blob: URL -> the cid: part behind it, so a click on a link the body points
// at a part can be routed through the attachment preview/download gate
// instead of window.open() (GHSA-xvjh-v9c6-qcvc). A ref because the iframe
// click handler is bound once per document.
const cidBlobPartsRef = useRef<Map<string, { name: string; type?: string; blob: Blob }>>(new Map());
// A locally held attachment (TNEF, decrypted, cid: part) shown in the
// in-app preview because an installed app cannot open its blob: URL in a
// new window (#865).
const [inAppPreview, setInAppPreview] = useState<{ name: string; downloadName: string; blob: Blob; contentType: string } | null>(null);
const getInAppPreviewContent = useCallback(async () => {
if (!inAppPreview) throw new Error('No attachment selected');
return { blob: inAppPreview.blob, contentType: inAppPreview.contentType };
}, [inAppPreview]);
const downloadInAppPreview = useCallback(() => {
if (!inAppPreview) return;
const url = URL.createObjectURL(inAppPreview.blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = inAppPreview.downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [inAppPreview]);
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
const handleSendQuickReply = async () => {
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
try {
await onQuickReply(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
} catch (error) {
console.error("Failed to send quick reply:", error);
// The text stays in the box, so say why it did not go out.
toast.error(sendFailureMessage(error, tComposer));
} finally {
setIsSendingQuickReply(false);
}
};
const [showSourceModal, setShowSourceModal] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
// The mobile More panel sits off-canvas with a slide transition. Until
// the user opens it once, that transition must stay off: on the first
// paint after the viewer mounts WebKit transitions `translate` from its
// registered initial value (0) to 100%, so the panel flashed on screen
// and slid out every time a message was opened on a phone.
const [moreMenuSlideEnabled, setMoreMenuSlideEnabled] = useState(false);
const [moreMenuSub, setMoreMenuSub] = useState<MoreMenuSub | null>(null);
const [tagMenuOpen, setTagMenuOpen] = useState(false);
const [moveMenuOpen, setMoveMenuOpen] = useState(false);
const moreMenuRef = useRef<HTMLDivElement>(null);
const tagMenuRef = useRef<HTMLDivElement>(null);
const moveMenuRef = useRef<HTMLDivElement>(null);
const moreButtonRef = useRef<HTMLButtonElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
// The rows that open a sub-view, and the pieces of the mobile panel a
// sub-view replaces. Desktop and mobile never render their More menu at the
// same time, so one map serves both.
const moreEntryRefs = useRef<Record<MoreMenuSub, HTMLButtonElement | null>>({ move: null, tag: null, rules: null });
// Filter rules made from the open message; none for a shared account or
// one without Sieve.
const rulesEmails = useMemo(() => (email ? [email] : []), [email]);
const { availability: rulesAvailability, target: rulesTarget } = useRulesAvailability(rulesEmails);
const mobileSubBackRef = useRef<HTMLButtonElement>(null);
const mobileSubListRef = useRef<HTMLDivElement>(null);
// Pro can mount two reading panes side by side, so the menu id has to be
// per-instance for aria-controls to point at the right one.
const moreMenuId = useId();
const toolbarRef = useRef<HTMLDivElement>(null);
const closeMoreMenu = useCallback(() => { setMoreMenuOpen(false); setMoreMenuSub(null); }, []);
const closeMoveMenu = useCallback(() => setMoveMenuOpen(false), []);
// Both menus render away from their trigger (a dropdown on desktop, an
// off-canvas panel on mobile), so focus has to be driven explicitly (#720).
const { menuRef: moreMenuListRef, onKeyDown: onMoreMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open: moreMenuOpen && !isMobile,
onClose: closeMoreMenu,
triggerRef: moreButtonRef,
});
const { menuRef: mobileMoreRef, onKeyDown: onMobileMoreKeyDown } = useMenuNavigation<HTMLDivElement>({
open: moreMenuOpen && isMobile,
onClose: closeMoreMenu,
triggerRef: moreButtonRef,
});
const { menuRef: moveMenuListRef, onKeyDown: onMoveMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open: moveMenuOpen,
onClose: closeMoveMenu,
triggerRef: moveButtonRef,
});
// Leaving a sub-view unmounts the row that opened it, so focus has to be put
// back by hand or it falls to <body> and a screen reader is left with nothing
// to read (#779).
const leaveMoreMenuSub = useCallback(() => {
const sub = moreMenuSub;
setMoreMenuSub(null);
if (!sub) return;
// The mobile panel unmounts the entry while its sub-view is on screen, so
// the ref only points at a button again once the top level is back.
requestAnimationFrame(() => moreEntryRefs.current[sub]?.focus());
}, [moreMenuSub]);
// Escape inside a sub-view backs out of it; only an Escape on the top level
// dismisses the whole menu.
const withSubMenuEscape = useCallback(
(next: (e: React.KeyboardEvent) => void) => (e: React.KeyboardEvent) => {
if (e.key === 'Escape' && moreMenuSub) {
e.preventDefault();
e.stopPropagation();
leaveMoreMenuSub();
return;
}
next(e);
},
[moreMenuSub, leaveMoreMenuSub],
);
const handleMoreMenuKeyDown = useMemo(
() => withSubMenuEscape(onMoreMenuKeyDown),
[withSubMenuEscape, onMoreMenuKeyDown],
);
const handleMobileMoreKeyDown = useMemo(
() => withSubMenuEscape(onMobileMoreKeyDown),
[withSubMenuEscape, onMobileMoreKeyDown],
);
// The mobile panel swaps its whole body for the sub-view, so entering one
// leaves the user nowhere unless focus follows it in. Desktop keeps the entry
// button mounted beside its flyout and needs no help.
const previousMoreMenuSub = useRef<MoreMenuSub | null>(null);
useEffect(() => {
const previous = previousMoreMenuSub.current;
previousMoreMenuSub.current = moreMenuSub;
if (!isMobile || !moreMenuOpen || !moreMenuSub || previous === moreMenuSub) return;
// A frame of slack lets the sub-view render before we look for its items.
const frame = requestAnimationFrame(() => {
const first = mobileSubListRef.current?.querySelector<HTMLElement>(SUB_MENU_ITEM_SELECTOR);
(first ?? mobileSubBackRef.current)?.focus();
});
return () => cancelAnimationFrame(frame);
}, [moreMenuSub, moreMenuOpen, isMobile]);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
// What the overflow measurement last applied. It mostly runs from observer
// and animation-frame callbacks, where even an updater that returns the
// previous state makes React call this component once more before bailing
// out; comparing here means an unchanged result schedules nothing at all.
const appliedHiddenPrioritiesRef = useRef<Set<number>>(hiddenPriorities);
// The toolbar's overflow answers measured so far, by what the toolbar
// looked like (see layoutKey), and the width it last had: an open that
// brings back a toolbar already measured at this width needs no measuring.
// A loading message and a loaded one can differ, so a few are kept.
const measuredToolbarRef = useRef(new Map<string, Set<number>>());
const toolbarWidthRef = useRef<number | undefined>(undefined);
const currentTagIds = getEmailTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds);
// The header spans the reading pane, so it measures its own width rather than
// inheriting the message list's answer.
const headerTagsRef = useRef<HTMLDivElement>(null);
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
const currentColor = currentTagIds[0] ?? null;
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
// crypto plugin's own email-banner slot, so the host keeps no S/MIME state.
const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null);
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>(NO_POSTAL_ATTACHMENTS);
// Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
// after the user unlocks a key from the banner) to force the onRenderEmailBody
// hook to run again for the open message so the body re-decrypts.
const [pluginRenderNonce, setPluginRenderNonce] = useState(0);
useEffect(() => {
const bump = () => setPluginRenderNonce((n) => n + 1);
window.addEventListener('plugin:rerender-email', bump);
return () => window.removeEventListener('plugin:rerender-email', bump);
}, []);
// TNEF (winmail.dat) support
const [tnefHtml, setTnefHtml] = useState<string | null>(null);
const [tnefText, setTnefText] = useState<string | null>(null);
const [tnefAttachments, setTnefAttachments] = useState<TnefAttachment[]>(NO_TNEF_ATTACHMENTS);
// Embedded message/rfc822 unwrapping (Outlook forward-as-attachment)
const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState<string | null>(null);
const [embeddedEmailText, setEmbeddedEmailText] = useState<string | null>(null);
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>(NO_POSTAL_ATTACHMENTS);
// Plugin detail sidebar state. Collapsed/width persist across opens and
// sessions so the panel reopens the way the user last left it.
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0;
// Whether any plugin offers a "more details" section, so we only render the
// bottom plugin category wrapper when something will fill it.
const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0;
const hasBannerOffers = usePluginSlotOffers('email-banner').length > 0;
const hasHeaderOffers = usePluginSlotOffers('reader-header').length > 0;
const hasBelowSubjectOffers = usePluginSlotOffers('reader-below-subject').length > 0;
const hasAboveQuoteOffers = usePluginSlotOffers('reader-above-quote').length > 0;
// One projected view per message for all plugin slots on it, built only
// when a plugin is there to see it. A fresh object on every render made
// each slot post its props to the plugin again and re-run shouldShow.
const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers
|| hasHeaderOffers || hasBelowSubjectOffers || hasAboveQuoteOffers;
const pluginEmailView = useMemo(
() => (email && wantsPluginEmailView ? emailToReadView(email) : null),
[email, wantsPluginEmailView],
);
// Reader frames learn the layout: the bar a plugin shows on phones has no
// place next to a side panel on a desktop.
const pluginDeviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView, deviceType: pluginDeviceType }), [pluginEmailView, pluginDeviceType]);
// The text selected in the body, for the side panel (a plugin working on
// what the user highlights) and the right-click menu on it. Reset per
// message; the body documents report changes a moment after they happen.
const [bodySelection, setBodySelection] = useState<{ text: string }>(NO_BODY_SELECTION);
useEffect(() => { setBodySelection(NO_BODY_SELECTION); }, [email?.id]);
const onBodySelection = useCallback((text: string) => {
setBodySelection((prev) => (prev.text === text ? prev : (text ? { text } : NO_BODY_SELECTION)));
}, []);
const [detailFocusRequest, setDetailFocusRequest] = useState(0);
const pluginDetailProps = useMemo(
() => ({ email: pluginEmailView, selection: bodySelection, focusRequest: detailFocusRequest }),
[pluginEmailView, bodySelection, detailFocusRequest],
);
const selectionMenu = useReaderSelectionMenu();
const emailIdRef = useRef(email?.id);
emailIdRef.current = email?.id;
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
// The user's choice; until they make one, a panel whose plugin asked to
// start folded (a chat opened on demand) is folded, the rest open.
const [detailSidebarChoice, setDetailSidebarCollapsed] = useState<boolean | null>(() => {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('emailDetailSidebarCollapsed');
return stored === '1' ? true : stored === '0' ? false : null;
} catch { return null; }
});
const detailSidebarCollapsed = detailSidebarChoice ?? detailSlots.some((o) => o.startFolded);
const [detailSidebarWidth, setDetailSidebarWidth] = useState<number>(() => {
if (typeof window === 'undefined') return 280;
try {
const n = parseInt(localStorage.getItem('emailDetailSidebarWidth') ?? '', 10);
return Number.isFinite(n) ? Math.max(200, Math.min(500, n)) : 280;
} catch { return 280; }
});
const detailSidebarWidthRef = useRef(detailSidebarWidth);
// The panel takes its width from the message. When that would leave the
// message too narrow to read (three panes on a laptop), the panel starts
// collapsed; opening it anyway is still the user's call.
// A callback ref: the root only exists once a message is open (the empty
// and loading states render other markup), so the observer has to attach
// when the element appears, not when the plugin offer does.
const [viewerRootEl, setViewerRootEl] = useState<HTMLDivElement | null>(null);
const [viewerWidth, setViewerWidth] = useState(0);
const [detailSidebarForcedOpen, setDetailSidebarForcedOpen] = useState(false);
useEffect(() => {
if (!hasDetailSidebar || !viewerRootEl || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width ?? 0);
setViewerWidth(width);
});
ro.observe(viewerRootEl);
return () => ro.disconnect();
}, [hasDetailSidebar, viewerRootEl]);
const readerTooNarrow = viewerWidth > 0 && viewerWidth - detailSidebarWidth < MIN_READER_WIDTH;
const sidebarCollapsed = detailSidebarCollapsed || (readerTooNarrow && !detailSidebarForcedOpen);
// Opened by hand while the message is too narrow for it: the panel lies over
// the message instead of squeezing it.
const sidebarOverlay = !sidebarCollapsed && readerTooNarrow;
// Plugins opening their panel (api.ui.openPanel): the same as the user's
// click on the toggle, plus a new focusRequest for the slot.
const detailPanelRef = useRef({ hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile });
detailPanelRef.current = { hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile };
// The toolbar's panel button, like a plugin's request: opening focuses the panel.
const toggleDetailSidebar = () => {
if (sidebarCollapsed) {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
setDetailFocusRequest((n) => n + 1);
} else {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}
};
useEffect(() => onPanelRequest((request) => {
if (request.slot !== 'email-detail-sidebar') return null;
const state = detailPanelRef.current;
if (!state.hasDetailSidebar || state.isMobile) return null;
const isOpen = !state.sidebarCollapsed;
const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
if (wantOpen) {
setDetailSidebarCollapsed(false);
if (state.readerTooNarrow) setDetailSidebarForcedOpen(true);
setDetailFocusRequest((n) => n + 1);
} else {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}
return wantOpen;
}), []);
useEffect(() => {
if (detailSidebarChoice === null) return;
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarChoice ? '1' : '0'); } catch { /* ignore */ }
}, [detailSidebarChoice]);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ }
}, [detailSidebarWidth]);
// Move-to dropdown: the message's own account first (#1149)
const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox);
const { tree: moveTree, targetIds: moveTargetIds } = useMemo(
() => buildMoveTargets(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: moveOwnerAccountId }),
[mailboxes, selectedMailbox, moveOwnerAccountId],
);
// Get mailbox icon based on role
const getMoveMailboxIcon = (role?: string) => {
switch (role) {
case "inbox": return Inbox;
case "sent": return Send;
case "drafts": return File;
case "trash": return Trash2;
case "archive": return Archive;
default: return Folder;
}
};
// Close dropdown menus on click outside
useEffect(() => {
if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return;
function handleClickOutside(e: MouseEvent) {
// On mobile the More menu is an off-canvas panel rendered as a sibling of
// the toolbar, so it is not inside `moreMenuRef`. Without counting it as
// part of the menu every tap on one of its rows read as a click away and
// tore the menu down before the row's own click could open its sub-view -
// "tag" and "move" just dropped the user back on the trigger (#779).
const moreRoots = [moreMenuRef.current, mobileMoreRef.current].filter((el): el is HTMLDivElement => el !== null);
if (moreMenuOpen && moreRoots.length > 0 && !moreRoots.some((root) => root.contains(e.target as Node))) {
setMoreMenuOpen(false);
setMoreMenuSub(null);
}
if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) {
setTagMenuOpen(false);
}
if (moveMenuOpen && moveMenuRef.current && !moveMenuRef.current.contains(e.target as Node)) {
setMoveMenuOpen(false);
}
}
// A click into a frame (the message body, a plugin's side panel) never
// reaches this document; the window losing focus to it is the click away.
function handleWindowBlur() {
setMoreMenuOpen(false);
setMoreMenuSub(null);
setTagMenuOpen(false);
setMoveMenuOpen(false);
}
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('blur', handleWindowBlur);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('blur', handleWindowBlur);
};
}, [moreMenuOpen, tagMenuOpen, moveMenuOpen, mobileMoreRef]);
// Close dropdowns when email changes
useEffect(() => {
setMoreMenuOpen(false);
setTagMenuOpen(false);
setMoveMenuOpen(false);
}, [email?.id]);
// Dynamically detect which toolbar items overflow and should move to the More menu
useEffect(() => {
const el = toolbarRef.current;
if (!el) return;
let rafId: number | null = null;
const applyHidden = (hidden: Set<number>) => {
const applied = appliedHiddenPrioritiesRef.current;
if (applied.size === hidden.size && [...hidden].every(p => applied.has(p))) return;
appliedHiddenPrioritiesRef.current = hidden;
setHiddenPriorities(hidden);
};
// Everything the decision depends on that can be read without a layout.
const layoutKey = (items: HTMLElement[]) => [
toolbarWidthRef.current, toolbarPosition, showToolbarLabels, isTablet, tabletListVisible,
...items.map((item) => `${item.dataset.overflowPriority}:${item.className}:${item.textContent}`),
].join('|');
const calculate = () => {
rafId = null;
const items = Array.from(el.querySelectorAll<HTMLElement>('[data-overflow-item]'));
if (items.length === 0) {
applyHidden(new Set());
return;
}
// Opening another message usually leaves the toolbar as it was. Then
// the last answer still holds: apply it without reading any width,
// which would force a layout on every open.
const key = layoutKey(items);
const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined;
if (known) {
for (const item of items) item.style.display = known.has(Number(item.dataset.overflowPriority)) ? 'none' : '';
applyHidden(known);
return;
}
// Sort descending by priority so highest number (least important) is hidden first
items.sort((a, b) =>
Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0)
);
// Show all items to measure their natural widths
items.forEach(item => { item.style.display = ''; });
const containerWidth = el.clientWidth;
const leftGroup = el.firstElementChild as HTMLElement;
const rightGroup = el.lastElementChild as HTMLElement;
const mainGap = parseFloat(getComputedStyle(el).gap) || 0;
// Temporarily prevent flex shrinking so we can measure natural widths
leftGroup.style.flexShrink = '0';
rightGroup.style.flexShrink = '0';
el.style.overflow = 'hidden';
// Measure every width in one pass and decide from the numbers (see
// itemsToHide) rather than hiding items one by one and re-reading.
const groupIds = new Map<Element, string>([[leftGroup, 'left'], [rightGroup, 'right']]);
const groupOf = (item: HTMLElement) => {
const parent = item.parentElement;
if (!parent) return '';
if (!groupIds.has(parent)) groupIds.set(parent, String(groupIds.size));
return groupIds.get(parent)!;
};
const measured = items.map((item) => ({ width: item.offsetWidth, group: groupOf(item) }));
const overflow = leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1);
const gapOf = {
left: parseFloat(getComputedStyle(leftGroup).gap) || 0,
right: parseFloat(getComputedStyle(rightGroup).gap) || 0,
};
const hidden = new Set<number>();
for (const i of itemsToHide(measured, overflow, gapOf)) {
items[i].style.display = 'none';
hidden.add(Number(items[i].dataset.overflowPriority));
}
// Restore layout
leftGroup.style.flexShrink = '';
rightGroup.style.flexShrink = '';
el.style.overflow = '';
const answers = measuredToolbarRef.current;
if (answers.size >= 8) answers.delete(answers.keys().next().value!);
answers.set(key, hidden);
applyHidden(hidden);
};
const scheduleCalculate = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(calculate);
};
// Recalculate on container resize. An observer reports once as soon as it
// starts observing; that first report is the size the synchronous
// calculation below has just measured, so it is not worth a second pass.
// Any other size invalidates the last measurement.
let sawInitialReport = false;
const resizeObserver = new ResizeObserver((entries) => {
const width = entries[entries.length - 1]?.contentRect.width;
const known = toolbarWidthRef.current;
toolbarWidthRef.current = width;
if (!sawInitialReport) {
sawInitialReport = true;
if (width === known) return;
}
measuredToolbarRef.current.clear();
scheduleCalculate();
});
resizeObserver.observe(el);
// Recalculate when children change (conditional items, label visibility)
const mutationObserver = new MutationObserver(scheduleCalculate);
mutationObserver.observe(el, { childList: true, subtree: true });
// Initial synchronous calculation to avoid flash
calculate();
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [
toolbarPosition,
liftToolbar,
email?.id,
showToolbarLabels,
isLoading,
moveTree.length,
emailKeywords.length,
currentColor,
isInJunkFolder,
isTablet,
tabletListVisible,
onBack,
onMarkAsSpam,
onUndoSpam,
]);
// Contact sidebar state
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
const contacts = useContactStore((s) => s.contacts);
const { isMobile: isMobileDevice } = useDeviceDetection();
const router = useRouter();
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
if (isMobileDevice) {
// No room for a sidebar on mobile - send the user to the contacts page
// with params describing what to show. The `from=email` flag turns the
// page's mobile back button into a router.back() that returns here.
const allRecipients = [
...(email?.from || []),
...(email?.to || []),
...(email?.cc || []),
...(email?.bcc || []),
...(email?.replyTo || []),
];
const recipientName = allRecipients.find(
(r) => r.email.toLowerCase() === recipientEmail.toLowerCase()
)?.name;
// Canonical contact permalink (#733); `from=email` keeps the mobile back
// button pointing at the message the user came from.
const params = new URLSearchParams({ from: 'email' });
let path: string;
if (contact) {
path = buildContactsPath({ contactId: contact.id });
} else {
path = '/contacts/new';
params.set('email', recipientEmail);
if (recipientName) params.set('name', recipientName);
}
router.push(`${path}?${params.toString()}`);
return;
}
setContactSidebarEmail(recipientEmail);
};
const sidebarContact = contactSidebarEmail
? contacts.find((c) => {
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
useEffect(() => {
setContactSidebarEmail(null);
}, [email?.id]);
// An @mention clicked in the message body: the card for that person, as
// for a name in the header, instead of starting a new message.
const [bodyMention, setBodyMention] = useState<{ email: string; anchor: CardAnchor } | null>(null);
useEffect(() => {
setBodyMention(null);
}, [email?.id]);
const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState<Set<string>>(
() => {
if (typeof window === 'undefined') return new Set();
const saved = localStorage.getItem('dismissed-unsub-banners');
return saved ? new Set(JSON.parse(saved)) : new Set();
}
);
const autoMarkedEmailRef = useRef<string | null>(null);
// Reset auto-mark tracking when email changes
useEffect(() => {
autoMarkedEmailRef.current = null;
}, [email?.id]);
useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting
if (!email || !onMarkAsRead) {
return;
}
// Already read - record that so manual unread toggle won't re-trigger auto-mark
if (email.keywords?.$seen) {
autoMarkedEmailRef.current = email.id;
return;
}
// Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread)
if (autoMarkedEmailRef.current === email.id) {
return;
}
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
// Never auto-mark
if (markAsReadDelay === -1) {
return;
}
// Instant mark
if (markAsReadDelay === 0) {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true, { automatic: true });
return;
}
// Delayed mark
const timeout = setTimeout(() => {
autoMarkedEmailRef.current = email.id;
onMarkAsRead(email.id, true, { automatic: true });
}, markAsReadDelay);
return () => clearTimeout(timeout);
// Keyed to email id + $seen only: depending on the whole `email` object
// would reset the mark-as-read delay timer whenever any unrelated email
// field updates (e.g. a background re-fetch).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
// Reset external content permission and quick reply when email changes
// Initialize allowExternalContent based on externalContentPolicy setting
useEffect(() => {
// 'allow' = always allow, 'block' = always block, 'ask' = user decides per email
setAllowExternalContent(externalContentPolicy === 'allow');
setHasBlockedContent(false);
setQuickReplyText("");
setIsQuickReplyFocused(false);
setShowSourceModal(false);
setEmailViewDarkOverride(null);
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setTnefHtml(null);
setTnefText(null);
setTnefAttachments(NO_TNEF_ATTACHMENTS);
setEmbeddedEmailHtml(null);
setEmbeddedEmailText(null);
setEmbeddedEmailAttachments(NO_POSTAL_ATTACHMENTS);
}, [email?.id, externalContentPolicy]);
// Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin
// can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and
// return a replaced body through the onRenderEmailBody hook. The host stays
// crypto-agnostic; the plugin renders its own verification/encryption status
// via its email-banner slot. Falls through to normal rendering otherwise.
useEffect(() => {
if (!email) return;
// No plugin renders bodies: nothing to ask, and no async round through the
// hook whose only outcome would be resetting state already reset.
if (renderHooks.onRenderEmailBody.size === 0) {
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
return;
}
let cancelled = false;
const dataUrlToBytes = (dataUrl: string): Uint8Array | null => {
try {
const comma = dataUrl.indexOf(',');
if (comma < 0) return null;
const meta = dataUrl.slice(0, comma);
const data = dataUrl.slice(comma + 1);
if (meta.includes(';base64')) {
const bin = atob(data);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8;
}
return new TextEncoder().encode(decodeURIComponent(data));
} catch {
return null;
}
};
(async () => {
try {
const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type'];
const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType;
const initialBody = { html: '', text: '', attachments: [] as unknown[] };
const ctx = {
id: email.id,
contentType,
bodyStructure: email.bodyStructure,
bodyValues: email.bodyValues,
attachments: email.attachments,
blobId: email.blobId,
from: email.from,
};
const result = await renderHooks.onRenderEmailBody.transform(initialBody, ctx) as {
html?: string;
text?: string;
attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>;
handledBy?: string;
};
if (cancelled) return;
if (!result || !result.handledBy) {
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
return;
}
setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null);
setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null);
// Normalise the plugin's attachment shape into the PostalMime-like shape
// the viewer's download / inline-image machinery already understands.
const atts = Array.isArray(result.attachments) ? result.attachments : [];
const decoded = atts.map((a) => ({
filename: a.name ?? null,
mimeType: a.type || 'application/octet-stream',
contentId: a.cid,
content: (a.dataUrl ? dataUrlToBytes(a.dataUrl) : null) ?? new Uint8Array(0),
} as unknown as PostalMimeAttachment));
setPluginRenderedAttachments(decoded);
} catch (err) {
if (cancelled) return;
debug.error('onRenderEmailBody hook failed:', err);
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
}
})();
return () => { cancelled = true; };
}, [email, pluginRenderNonce]);
// TNEF (winmail.dat) detection and processing
useEffect(() => {
if (!email?.attachments || !blobClient) return;
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
if (!tnefAtt?.blobId) {
debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id);
return;
}
debug.group('TNEF Processing', 'email');
debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
// Check if the email already has a usable HTML body with real content
// Outlook often forwards TNEF emails with an HTML body that's just Word
// boilerplate (CSS + &nbsp;) - treat these as effectively empty.
const htmlPartId = email.htmlBody?.[0]?.partId;
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
let hasRealHtmlBody = !!htmlValue;
if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) {
hasRealHtmlBody = false;
debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body');
}
if (hasRealHtmlBody) {
debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only');
} else {
debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction');
}
if ((tnefAtt.size ?? 0) > MAX_INLINE_PARSE_BYTES) {
debug.log('email', 'TNEF: attachment too large to decode on open, leaving it as a chip');
debug.groupEnd();
return;
}
let cancelled = false;
async function processTnef() {
try {
debug.time('TNEF fetch blob', 'email');
const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId);
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
if (cancelled) {
debug.log('email', 'TNEF: Processing cancelled after fetch');
debug.groupEnd();
return;
}
if (blobBytes.byteLength === 0) {
debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)');
debug.groupEnd();
return;
}
const tnefData = new Uint8Array(blobBytes);
debug.time('TNEF parse', 'email');
const parsed = parseTnef(tnefData);
debug.timeEnd('TNEF parse', 'email');
if (cancelled) {
debug.log('email', 'TNEF: Processing cancelled after parse');
debug.groupEnd();
return;
}
debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
if (parsed.htmlBody && !hasRealHtmlBody) {
setTnefHtml(parsed.htmlBody);
}
if (parsed.body && !hasRealHtmlBody) {
setTnefText(parsed.body);
}
if (parsed.attachments.length > 0) {
setTnefAttachments(parsed.attachments);
debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
}
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format');
}
debug.groupEnd();
} catch (err) {
debug.error('TNEF processing failed for email', email?.id, err);
debug.groupEnd();
}
}
processTnef();
return () => { cancelled = true; };
}, [email, client, blobClient, blobAccountId]);
// Embedded message/rfc822 unwrapping
// When Outlook forwards an email as an attachment, the outer email body is
// often empty Word boilerplate and the real content is inside a message/rfc822
// attachment. Detect this pattern and unwrap the embedded email.
useEffect(() => {
if (!email?.attachments || !blobClient) return;
// Find message/rfc822 attachment
const rfc822Att = email.attachments.find(
att => att.type === 'message/rfc822' && att.blobId
);
if (!rfc822Att?.blobId) return;
if ((rfc822Att.size ?? 0) > MAX_INLINE_PARSE_BYTES) return;
// Only unwrap if the outer body is effectively empty
const htmlPartId = email.htmlBody?.[0]?.partId;
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
const textPartId = email.textBody?.[0]?.partId;
const textValue = textPartId ? email.bodyValues?.[textPartId]?.value?.trim() : '';
const hasRealHtml = !!htmlValue && !isHtmlBodyEffectivelyEmpty(htmlValue);
const hasRealText = !!textValue;
if (hasRealHtml || hasRealText) {
debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping');
return;
}
debug.group('Embedded RFC822 Unwrapping', 'email');
debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size);
debug.log('email', 'Outer email body is empty, will unwrap embedded email');
let cancelled = false;
async function unwrapEmbedded() {
try {
const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
debug.groupEnd();
return;
}
const { default: PostalMime } = await import('postal-mime');
const parser = new PostalMime();
const parsed = await parser.parse(new Uint8Array(blobBytes));
if (cancelled) { debug.groupEnd(); return; }
debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
', attachments:', parsed.attachments?.length ?? 0);
if (parsed.html) {
setEmbeddedEmailHtml(parsed.html);
}
if (parsed.text) {
setEmbeddedEmailText(parsed.text);
}
if (parsed.attachments && parsed.attachments.length > 0) {
setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]);
debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map(
a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')'
).join(', '));
}
debug.groupEnd();
} catch (err) {
debug.error('Embedded RFC822 unwrapping failed:', err);
debug.groupEnd();
}
}
unwrapEmbedded();
return () => { cancelled = true; };
}, [email, client, blobClient, blobAccountId]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
let cancelled = false;
const objectUrls: string[] = [];
// The Map itself is never replaced, so the cleanup can hold it directly.
const cidBlobParts = cidBlobPartsRef.current;
const decryptedCidAttachments = pluginRenderedAttachments.filter(att => att.contentId);
if (decryptedCidAttachments.length > 0) {
const urls: Record<string, string> = {};
decryptedCidAttachments.forEach((att) => {
const bytes = getAttachmentContentBytes(att);
if (!bytes) return;
const cidValue = normalizeCid(att.contentId!);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
// Sender-typed part: never let a script-bearing MIME type reach a
// blob: URL in our origin (GHSA-xvjh-v9c6-qcvc).
const blob = new Blob([buffer], { type: inertBlobType(att.mimeType) });
const objectUrl = URL.createObjectURL(blob);
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
cidBlobParts.set(objectUrl, { name: att.filename || cidValue, type: att.mimeType, blob });
});
setCidBlobUrls(urls);
return () => {
cancelled = true;
objectUrls.forEach(url => {
URL.revokeObjectURL(url);
cidBlobParts.delete(url);
});
};
}
if (!blobClient || !email?.attachments) {
setCidBlobUrls({});
return;
}
const cidAttachments = email.attachments.filter(att => att.cid && att.blobId);
if (cidAttachments.length === 0) {
setCidBlobUrls({});
return;
}
async function fetchCidBlobs() {
const urls: Record<string, string> = {};
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = normalizeCid(att.cid!);
try {
// The download URL echoes the sender-declared Content-Type back as
// the Blob type; re-type anything that could execute as our origin
// before it becomes a blob: URL (GHSA-xvjh-v9c6-qcvc).
const blob = await blobClient!.fetchBlob(att.blobId, att.name || 'inline', att.type, blobAccountId);
const inertBlob = toInertBlob(blob);
const objectUrl = URL.createObjectURL(inertBlob);
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
cidBlobParts.set(objectUrl, { name: att.name || cidValue, type: att.type, blob: inertBlob });
} else {
URL.revokeObjectURL(objectUrl);
}
} catch (err) {
// The image shows as the placeholder; say why, or the failure is invisible.
console.warn(`Failed to fetch inline image cid:${cidValue}:`, err);
}
}));
if (!cancelled) {
setCidBlobUrls(urls);
}
}
fetchCidBlobs();
return () => {
cancelled = true;
objectUrls.forEach(url => {
URL.revokeObjectURL(url);
cidBlobParts.delete(url);
});
};
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (pluginRenderedAttachments.length > 0) {
const pluginCids = collectReferencedCids(hideInlineImageAttachments ? pluginRenderedHtml : null);
return pluginRenderedAttachments
// Any cid image counts as embedded here (as before), plus whatever the
// decrypted body references by cid (see lib/attachment-visibility.ts).
.filter(att => !(hideInlineImageAttachments && (
(att.contentId && (att.mimeType || '').startsWith('image/'))
|| isEmbeddedInBody({ cid: att.contentId, type: att.mimeType, disposition: att.disposition }, pluginCids)
)))
.map((attachment, index) => ({
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
name: attachment.filename,
type: attachment.mimeType || 'application/octet-stream',
size: getPostalMimeAttachmentSize(attachment),
cid: attachment.contentId,
decryptedAttachment: attachment,
}));
}
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
// Parts the rendered body embeds via cid: must not double as chips. The
// scan reads the same HTML the body renders from (null when the message
// renders as plain text: nothing embedded, nothing hidden), so a part only
// recognisable by its reference - octet-stream, no disposition, no name -
// is caught as well (see lib/attachment-visibility.ts).
const bodyCids = collectReferencedCids(hideInlineImageAttachments && email ? getRenderableHtmlBody(email) : null);
const jmapAttachments = (email?.attachments ?? [])
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
// Keep the message/rfc822 attachment itself visible in the attachment list even
// when we've unwrapped it for inline preview - it's a real, downloadable
// attachment (e.g. "Forward as attachment" sends), not just preview scaffolding.
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
// banner is shown - prevents raw ICS files appearing as spurious attachments.
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
// Hide body-embedded parts when the user has opted to keep them out of
// the attachment list (default on).
.filter(att => !(hideInlineImageAttachments && isEmbeddedInBody(att, bodyCids)))
// Hide machine-readable report parts (MDN read-receipts, DSN bounce
// reports). These are required MIME parts, not real user attachments.
.filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status')
.map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null,
type: attachment.type || 'application/octet-stream',
size: attachment.size,
blobId: attachment.blobId,
cid: attachment.cid,
}));
// Append attachments extracted from TNEF
const tnefExtracted: EffectiveAttachment[] = tnefAttachments.map((att, index) => ({
id: `tnef-${index}-${att.name}`,
name: att.name,
type: att.mimeType,
size: att.data.byteLength,
tnefData: att.data,
}));
// Append attachments extracted from embedded message/rfc822
const embeddedExtracted: EffectiveAttachment[] = embeddedEmailAttachments
.filter(att => !att.contentId) // Skip inline CID images
.map((att, index) => ({
id: `embedded-${index}-${att.filename || att.mimeType}`,
name: att.filename || null,
type: att.mimeType || 'application/octet-stream',
size: getPostalMimeAttachmentSize(att),
decryptedAttachment: att,
}));
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
// The memo derives only from `email.attachments` (findCalendarAttachment
// scans that array) and the body parts the cid scan reads; depending on
// the whole `email` object would rebuild the attachment list — and its
// downstream layout measurement — on every email field change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email?.attachments, email?.htmlBody, email?.textBody, email?.bodyValues, pluginRenderedAttachments, pluginRenderedHtml, tnefHtml, tnefText, tnefAttachments, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
// Measure attachment chips in the below-header row to determine how many fit
// on a single line; the rest collapse into a "+N attachments" overflow pill.
useLayoutEffect(() => {
if (attachmentPosition !== 'below-header' || effectiveAttachments.length === 0) {
setVisibleBelowHeaderCount(null);
return;
}
const container = belowHeaderRowRef.current;
const ghost = belowHeaderGhostRef.current;
if (!container || !ghost) return;
const measure = () => {
const containerWidth = container.clientWidth;
const chips = Array.from(ghost.children) as HTMLElement[];
if (chips.length === 0) return;
const ghostLeft = ghost.getBoundingClientRect().left;
// Reserve space for the "+N attachments" overflow pill
const RESERVED = 140;
let count = chips.length;
for (let i = 0; i < chips.length; i++) {
const right = chips[i].getBoundingClientRect().right - ghostLeft;
const isLast = i === chips.length - 1;
const limit = isLast ? containerWidth : containerWidth - RESERVED;
if (right > limit) {
count = i;
break;
}
}
setVisibleBelowHeaderCount(count >= chips.length ? null : Math.max(1, count));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(container);
return () => ro.disconnect();
}, [effectiveAttachments, attachmentPosition, imageThumbUrls]);
// Generate email source for viewing
const copySourceToClipboard = async () => {
if (!email) return;
try {
const source = generateEmailSource(email);
await navigator.clipboard.writeText(source);
// Could add a toast notification here
console.log(tNotifications('source_copied'));
} catch (err) {
console.error('Failed to copy source:', err);
}
};
// Whether external resources must be blocked for the message on screen.
// Shared by every body path - the message's own HTML, TNEF, unwrapped
// message/rfc822, and plugin-rendered (decrypted) bodies - so the user's
// preference is enforced no matter which one produces the HTML (#797).
// 'allow' = never block, 'block' = always block (unless trusted),
// 'ask' = block until the user allows this message or trusts the sender.
const shouldBlockExternal = useMemo(() => {
if (!email) return false;
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return !senderIsTrusted && (
externalContentPolicy === 'block' ||
(externalContentPolicy === 'ask' && !allowExternalContent)
);
// Trust selectors are read inside and re-read whenever the message or the
// permission changes, so they're deliberately omitted from deps (matches
// the srcDoc rebuild contract described on `emailContent`).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, allowExternalContent]);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false };
// Check if we have body values
if (email.bodyValues) {
// Rich HTML, or just a plain-text wrapper the server dressed up as HTML?
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 = rewriteCidReferences(htmlContent, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL);
}
// Sanitize (no dark mode color transforms - emails render true-to-life
// in the iframe) and enforce the external-content policy.
const { html: cleanHtml, blockedExternalContent } =
sanitizeEmailBodyForIframe(htmlContent, shouldBlockExternal);
// Update blocked content state
if (blockedExternalContent && !hasBlockedContent) {
setHasBlockedContent(true);
}
return {
html: cleanHtml,
isHtml: true,
hasStyleTag: /<style[\s>]/i.test(htmlContent),
// Drives the strict iframe CSP: when we're in blocking mode the
// iframe forbids external img/media/font fetches entirely, so any
// vector the DOM walk above missed (e.g. <style>-tag url()) still
// can't phone home. Cleared once the user allows / trusts.
externalBlocked: shouldBlockExternal,
};
}
// Use text content if available (either as fallback or when HTML is minimal)
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const textContent = email.bodyValues[email.textBody[0].partId].value;
return {
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
html: collapsePlainTextQuotes(plainTextToSafeHtml(textContent), {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
}),
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}
}
// If no body content is available, show the preview or a message
if (email.preview) {
const previewHtml = email.preview
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return {
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}
return {
html: `<p style="color: var(--color-muted-foreground); font-style: italic;">${t('no_body_content')}</p>`,
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
// Recompute when permission changes so the srcDoc rebuilds with the
// unblocked content AND the permissive CSP. The strict blocking-mode CSP
// can't be relaxed in place (a document's CSP is fixed at load), so the
// "Load images" / "Trust sender" buttons (both flip allowExternalContent)
// intentionally trigger a fresh srcDoc - `shouldBlockExternal` carries that
// change in, and re-reads the trust selectors on the way.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, shouldBlockExternal, cidBlobUrls, t]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
const plainToHtml = (text: string) =>
collapsePlainTextQuotes(plainTextToSafeHtml(text), {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
});
// Bodies that bypass `emailContent` (plugin-decrypted, TNEF, unwrapped
// message/rfc822) are still email content the user never asked to trust, so
// they get the same external-resource treatment - blocking walk, blocked
// banner, and the strict iframe CSP via `externalBlocked` (#797).
const renderHtml = (html: string) => {
const { html: cleanHtml, blockedExternalContent } =
sanitizeEmailBodyForIframe(html, shouldBlockExternal);
if (blockedExternalContent && !hasBlockedContent) {
setHasBlockedContent(true);
}
return {
html: cleanHtml,
isHtml: true,
hasStyleTag: /<style[\s>]/i.test(html),
externalBlocked: shouldBlockExternal,
};
};
if (pluginRenderedHtml) {
return renderHtml(rewriteCidReferences(pluginRenderedHtml, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL));
}
if (pluginRenderedText) {
return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
return renderHtml(tnefHtml);
}
if (tnefText) {
return { html: plainToHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
return renderHtml(embeddedEmailHtml);
}
if (embeddedEmailText) {
return { html: plainToHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
return emailContent;
// `hasBlockedContent` is only read to avoid a redundant setState; including
// it would re-run the whole sanitize pass the moment the banner appears.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cidBlobUrls, emailContent, shouldBlockExternal, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText, t]);
const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => {
const fallback = attachment.name || 'download';
if (!email) return fallback;
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback;
},
[email, attachmentFilenameOptions],
);
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
// image/svg+xml, etc.) would execute as the webmail origin if opened
// top-level. Force the download path for anything not on the inert allowlist.
const opensPreview = isPreviewable
&& mailAttachmentAction === 'preview'
&& isMimeTypeSafeForInlinePreview(attachment.type);
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
};
if (attachment.blobId && onDownloadAttachment) {
emailHooks.onAttachmentDownload.emit(info);
onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
return;
}
// Handle TNEF-extracted attachments
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);
if (opensPreview) {
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) {
setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type });
}
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = downloadName;
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);
if (opensPreview) {
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) {
setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type });
}
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
};
emailHooks.onAttachmentDownload.emit(info);
if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, downloadName, 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 = downloadName;
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 = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
// Bundle every attachment of this email into a single .zip and download it.
// Fetches blob-backed attachments through the JMAP client and reuses already
// decoded bytes for S/MIME-decrypted and TNEF-extracted ones. Individual
// failures are skipped so a single bad blob doesn't sink the whole archive.
const handleDownloadAllAttachments = useCallback(async () => {
if (isDownloadingAll || effectiveAttachments.length === 0) return;
setIsDownloadingAll(true);
try {
const { default: JSZip } = await import('jszip');
const zip = new JSZip();
const used = new Set<string>();
// Zip entries must be unique; suffix collisions with " (n)" before the
// extension so duplicates stay recognisable.
const uniqueName = (raw: string): string => {
const base = raw || 'attachment';
if (!used.has(base)) { used.add(base); return base; }
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : '';
let i = 1;
let candidate = `${stem} (${i})${ext}`;
while (used.has(candidate)) { i++; candidate = `${stem} (${i})${ext}`; }
used.add(candidate);
return candidate;
};
let added = 0;
for (const attachment of effectiveAttachments) {
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
try {
if (attachment.blobId && blobClient) {
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
zip.file(entryName, blob);
added++;
} else if (attachment.tnefData) {
zip.file(entryName, attachment.tnefData);
added++;
} else if (attachment.decryptedAttachment) {
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
if (bytes && bytes.byteLength > 0) {
zip.file(entryName, bytes);
added++;
}
}
} catch {
// Skip individual failures; remaining attachments still bundle.
}
}
if (added === 0) return;
const zipBlob = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' });
const objectUrl = URL.createObjectURL(zipBlob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachmentsBundleFilename(email);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
} finally {
setIsDownloadingAll(false);
}
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
<button
onClick={(e) => { e.stopPropagation(); handleDownloadAllAttachments(); }}
disabled={isDownloadingAll}
title={t('download_all')}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-md border border-border/50 transition-colors flex-shrink-0 disabled:opacity-60 disabled:cursor-wait"
>
{isDownloadingAll
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <FileArchive className="w-3.5 h-3.5" />}
{t('download_all')}
</button>
) : null;
// Pre-fetch URLs for image attachments so their actual contents can be
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
// The thumbnail can be opened on its own (context menu, drag to the tab
// strip), so imageBlobUrl keeps a sender's SVG out of the webmail origin.
useEffect(() => {
let cancelled = false;
const createdUrls: string[] = [];
if (!attachmentImagePreviewsEnabled) {
setImageThumbUrls({});
return;
}
const imageAttachments = effectiveAttachments.filter(
(att) => (att.type || '').startsWith('image/') && att.size <= 10_000_000,
);
if (imageAttachments.length === 0) {
setImageThumbUrls({});
return;
}
(async () => {
const next: Record<string, string> = {};
await Promise.all(imageAttachments.map(async (att) => {
let url: string | undefined;
try {
if (att.blobId && blobClient) {
url = await imageBlobUrl(await blobClient.fetchBlob(att.blobId, att.name || 'thumb', att.type, blobAccountId), att.type);
} else if (att.decryptedAttachment) {
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
if (!bytes || bytes.byteLength === 0) return;
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
url = await imageBlobUrl(new Blob([buffer]), att.type);
} else if (att.tnefData) {
const buffer = att.tnefData.buffer.slice(
att.tnefData.byteOffset,
att.tnefData.byteOffset + att.tnefData.byteLength,
) as ArrayBuffer;
url = await imageBlobUrl(new Blob([buffer]), att.type);
}
} catch {
return;
}
if (!url) return;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
createdUrls.push(url);
next[att.id] = url;
}));
if (!cancelled) setImageThumbUrls(next);
})();
return () => {
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
// Iframe for rendering HTML emails true-to-life
const iframeRef = useRef<HTMLIFrameElement>(null);
// Scale-to-fit is a phone behaviour: on a desktop reading pane a wide mail is
// legible at 1:1 and scrolls. Held in a ref because the measurement runs from
// observers wired up once per document - a plain closure would keep fitting
// (or not) across a rotation or a pane resize that crossed the breakpoint.
const fitBodyToWidthRef = useRef(isMobile);
fitBodyToWidthRef.current = isMobile;
// Detect if the email HTML has native dark mode support
const emailHasNativeDarkMode = useMemo(() => {
if (!effectiveEmailContent.isHtml) return false;
return htmlHasNativeDarkMode(effectiveEmailContent.html);
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml]);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const [emailViewDarkOverride, setEmailViewDarkOverride] = useState<boolean | null>(null);
const isDark = emailViewDarkOverride !== null
? emailViewDarkOverride
: (emailAlwaysLightMode ? false : resolvedTheme === 'dark');
const emailIframeSrcDoc = useMemo(() => {
if (!effectiveEmailContent.isHtml) return '';
// Dark mode rewrites the mail's colours after load (applyEmailDarkMode in
// handleIframeLoad); this only sets the fallback colours and keeps the
// body hidden until that pass ran. A mail with its own dark styles gets
// them pinned to the app theme rather than the OS.
const darkModeCSS = isDark ? emailDarkModeCss() : '';
const colorScheme = emailColorScheme(isDark, emailHasNativeDarkMode);
// Bare HTML emails (no <style>) tend to be plain prose without their own
// layout - give them the same padding as plain-text mails (.email-content-text).
// Word/Outlook HTML emails ship a <style> block but put their gutter in
// @page margins (print-only), so they need a fallback body padding too.
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
// "auto" spacing: only drop our gutter when the mail paints a full-bleed
// background canvas (a width:100% element carrying a background colour) --
// the one case where the gutter shows as a frame around the email's own
// background. A <style> tag alone is too weak a signal: plenty of
// transactional mails ship one for web fonts yet have no gutter of their
// own, and zeroing the padding glues their content to the corner.
// Only looked at when it decides anything (lib/email-full-bleed.ts).
const autoDropsGutter =
messageSpacing === 'auto' && effectiveEmailContent.hasStyleTag && !isWordHtml && hasFullBleedCanvas(effectiveEmailContent.html);
const dropGutter = messageSpacing === 'edge' || autoDropsGutter;
const bodyPadding = dropGutter ? '0' : '1rem 1.25rem';
const mobileBodyPaddingX = dropGutter ? '0' : '0.75rem';
// Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
// tighten to match how Outlook/Gmail render the same source.
const wordHtmlCSS = isWordHtml ? `
body { line-height: 1.15; }
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
// Defense-in-depth CSP inside srcDoc (see emailIframeCsp). When the user
// loads/trusts the sender the srcDoc is rebuilt (see emailContent) with
// the permissive variant so real images, web fonts and media load.
const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked);
return `<!DOCTYPE html>
<html class="${isDark ? DARK_PENDING_CLASS : ''}" style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
<style>
/* Force content height: some emails set html/body { height: 100% }, which -
combined with overflow:hidden and our scrollHeight-based auto-resize -
collapses the measured height and clips everything below the fold. The
height reset is repeated in a trailing <style> after the body content
(see below): the email's own <style> is injected inside our <body> and so
lands later in source order, where - at equal specificity and !important -
it would otherwise win the cascade. */
/* overflow-y stays hidden so our scrollHeight-based height measurement works;
overflow-x is auto on the body so an intrinsically wide table (e.g. a
20-column data table) can scroll horizontally instead of being crushed to
fit - the latter wraps header text to one character per line, which reads
as 90deg-rotated vertical headers (issue #409). On a phone that scroll is
the wrong answer for ordinary fixed-width mail, so fitEmailBodyWidth()
shrinks the whole body to the screen width after load and only content too
wide to stay legible when scaled keeps scrolling. */
html { overflow: hidden; height: auto !important; }
/* Some emails put height:100% on a full-bleed wrapper table/div (not html/body),
which - with body's overflow:hidden - clips the content to a sliver, and the
scrollHeight-based auto-resize then locks the iframe short (a Box.co.il
verification email rendered as a logo-only 150px strip). Neutralise the
full-height trick on any element so the body grows to its content. */
[style*="height:100%"], [style*="height: 100%"] { height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; 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; }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
/* 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; }
/* break-word (not anywhere): break only over-long single words, and keep each
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; }
${wordHtmlCSS}
${darkModeCSS}
</style></head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
// it) recomputes emailContent without blocking and swaps the strict CSP for
// the permissive one. In-place restore isn't possible because a document's
// CSP is fixed at load — the strict blocking-mode CSP would keep refusing the
// restored URLs.
// Tracks the last rendered body height so the loading skeleton can hold
// the same size - avoids the body shrink/expand flash when switching emails.
const lastBodyHeightRef = useRef<number>(300);
// True while the new email's body is still being fetched. Catches the
// window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render.
// An empty bodyValues with no referenced parts means the email has no body
// (e.g. calendar-only invites) - not "still loading".
const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
// The srcDoc whose document has been wired up (handleIframeLoad), or null.
// Readiness is derived from it rather than reset per message: a reset in
// the srcDoc layout effect rendered this component a second time, during
// the commit, on every open.
const [iframeReadyFor, setIframeReadyFor] = useState<string | null>(null);
const latestSrcDocRef = useRef<string | null>(null);
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
// A plain-text body sits in the page: watch the page's selection for it.
useEffect(() => {
if (!plainBodyEl || !hasDetailSidebar) return;
return watchElementSelection(plainBodyEl, { emailId: email?.id, onSelection: onBodySelection });
}, [plainBodyEl, hasDetailSidebar, email?.id, onBodySelection]);
// One object per body: React writes innerHTML again whenever this object
// changes, which would drop what was mounted in the body and fold the quote
// back up on every render.
const plainBodyHtml = useMemo(
() => ({ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }),
[effectiveEmailContent.html],
);
// Tracks which parsed document we've already wired up, so setup runs exactly
// once per srcDoc even though both the readiness poll below and the iframe
// 'load' event can trigger it.
const initializedDocRef = useRef<Document | null>(null);
// The document present at the instant srcDoc changed - i.e. the one about to
// be torn down. contentDocument keeps pointing at it until the browser swaps
// the new srcDoc in, so the poll skips it to avoid wiring up stale content.
const staleDocRef = useRef<Document | null>(null);
// Host-side observer on the iframe box itself: the in-document one watches
// body, whose width scale-to-fit pins to the content width, so a viewport
// change (rotation, pane resize) would otherwise never re-fit. Kept in a ref
// so each srcDoc replaces the previous one instead of stacking observers.
const frameSizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => () => frameSizeObserverRef.current?.disconnect(), []);
useLayoutEffect(() => {
latestSrcDocRef.current = emailIframeSrcDoc;
initializedDocRef.current = null;
// Runs during commit, before the browser processes the new srcDoc, so
// contentDocument here is still the outgoing document.
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
}, [emailIframeSrcDoc]);
// onEmailContentRender: once per opened message, after its body is on
// screen (the iframe has loaded an HTML body; a text body renders at once).
const contentRenderedForRef = useRef<string | null>(null);
useEffect(() => {
if (!email || isBodyLoading) return;
if (effectiveEmailContent.isHtml && !iframeReady) return;
if (contentRenderedForRef.current === email.id) return;
contentRenderedForRef.current = email.id;
if (emailHooks.onEmailContentRender.size === 0) return;
let hasExternalContent = hasBlockedContent;
if (!hasExternalContent && effectiveEmailContent.isHtml) {
// Loaded rather than blocked (allowed by the user or the policy): look
// at what the rendered body actually references.
const doc = iframeRef.current?.contentDocument;
hasExternalContent = !!doc && Array.from(doc.querySelectorAll('[src], [srcset], [background], [poster]')).some((el) =>
['src', 'srcset', 'background', 'poster'].some((attr) => isExternalResourceUrl(el.getAttribute(attr))));
}
void emailHooks.onEmailContentRender.emit({
emailId: email.id,
hasHtml: effectiveEmailContent.isHtml,
hasExternalContent,
});
}, [email, isBodyLoading, effectiveEmailContent.isHtml, iframeReady, hasBlockedContent]);
// Open a blob: link the rendered body points at a cid: part. Mirrors the
// attachment-chip gate: inert previewable types open in a tab, everything
// else (and any blob: URL we did not mint) is downloaded under its part name.
const openCidPartLink = useCallback((href: string) => {
const part = cidBlobPartsRef.current.get(href);
const opensPreview = !!part
&& mailAttachmentActionRef.current === 'preview'
&& isFilePreviewable(part.name, part.type)
&& isMimeTypeSafeForInlinePreview(part.type);
if (opensPreview) {
if (!openPreviewInNewWindow(href)) {
setInAppPreview({ name: part.name, downloadName: part.name, blob: part.blob, contentType: part.type || part.blob.type });
}
return;
}
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = part?.name || 'download';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}, []);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
// Ignore the outgoing document, a transient about:blank (a fresh srcDoc
// document reports URL 'about:srcdoc'), and anything that hasn't finished
// parsing yet; run the setup below at most once per document.
if (!doc?.body || doc === staleDocRef.current || doc.URL !== 'about:srcdoc' || doc.readyState === 'loading') return;
if (initializedDocRef.current === doc) return;
initializedDocRef.current = doc;
{
// Collapse the quoted original of a reply behind a "•••" toggle
// (#480). Before the height wiring, so the initial measurement
// already reflects the collapsed body.
setupQuoteCollapse(doc, {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
});
// Before the height wiring and after the quote toggle exists, so the
// toggle is converted too. Reads the mail's computed colours and
// un-hides the body when done.
if (isDark) {
applyEmailDarkMode(doc, { remoteContentAllowed: !effectiveEmailContent.externalBlocked });
}
// Auto-resize iframe to fit content
// Measure max(documentElement, body): a height:100% wrapper can leave
// documentElement.scrollHeight short while the real content lives in body.
// The document as last measured. Re-measuring is not free: the fit
// first clears its own transform and width, which dirties the layout,
// so the read after it lays the whole mail out again. Reading the
// scroll sizes of an untouched document costs nothing, so the poll and
// the observers only pay for the full pass when something changed.
const sizes = () => ({
width: Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth),
height: Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight),
viewport: doc.documentElement.clientWidth,
});
let measured = { width: -1, height: -1, viewport: -1 };
const applyHeight = (force = false) => {
if (iframe.contentDocument !== doc) return; // navigated away; stale
const now = sizes();
if (!force && now.width === measured.width && now.height === measured.height && now.viewport === measured.viewport) return;
// Shrink desktop-width mail to fit a phone screen first: it changes
// the layout height, and the transform doesn't shrink the measured
// (layout) height, so the iframe box has to be scaled by hand.
const scale = fitEmailBodyWidth(doc, { enabled: fitBodyToWidthRef.current });
const height = Math.ceil(
Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight) * scale
);
iframe.style.height = height + 'px';
lastBodyHeightRef.current = height;
measured = sizes();
};
const resizeObserver = new ResizeObserver(() => applyHeight());
resizeObserver.observe(doc.body);
// Re-fit when the iframe itself changes width. Height-only changes are
// our own applyHeight writing back, so they're ignored - re-entering on
// those would spin the observer.
let lastFrameWidth = iframe.clientWidth;
frameSizeObserverRef.current?.disconnect();
const frameObserver = new ResizeObserver(() => {
if (iframe.contentDocument !== doc) return;
if (iframe.clientWidth === lastFrameWidth) return;
lastFrameWidth = iframe.clientWidth;
applyHeight(true);
});
frameObserver.observe(iframe);
frameSizeObserverRef.current = frameObserver;
applyHeight(true);
// The ResizeObserver only fires on body's border box; a content overflow
// that grows scrollHeight without resizing that box (e.g. a height:100%
// wrapper, or images that reflow the layout after onload) is otherwise
// missed and the iframe stays short. Re-measure on a fixed cadence over a
// short settle window, then stop — a self-clearing catch-all that does
// not depend on image load/error events firing (blocked images may fire
// neither). Cheap: ~12 scrollHeight reads, no early-stop heuristic to
// mis-trigger on a brief-stable-then-grow reflow.
const poll = window.setInterval(() => {
if (iframe.contentDocument !== doc) { window.clearInterval(poll); return; }
applyHeight();
}, 200);
window.setTimeout(() => window.clearInterval(poll), 2400);
setIframeReadyFor(latestSrcDocRef.current);
// Hide images that fail to load (dead/mixed-content/unreachable external
// URLs) rather than leaving the browser's broken-image placeholder and
// alt text, which read as stray label text in an otherwise image-only
// email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1
// transparent pixel (naturalWidth 1) and display:none, so they're skipped.
const hideIfBroken = (img: HTMLImageElement) => {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
img.style.display = 'none';
}
};
doc.querySelectorAll('img').forEach((el) => {
const img = el as HTMLImageElement;
if (img.complete) {
hideIfBroken(img);
} else {
img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true });
img.addEventListener('load', () => hideIfBroken(img), { once: true });
}
});
// Second pass over the rendered iframe DOM (the hook above only sees
// DOMPurify's output); http(s) → new tab, other schemes left in place.
doc.querySelectorAll('a, area').forEach(applyNewTabToAnchor);
// Plugin intercept: let plugins cancel or rewrite external links inside
// the email body before navigation happens. Bound on the iframe doc so
// it survives later DOM mutations.
const onLinkClick = async (ev: Event) => {
const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null;
if (!targetEl) return;
const href = targetEl.getAttribute('href') || '';
if (!href || href.startsWith('#')) return;
if (/^mailto:/i.test(href)) {
const mention = mentionFromLink(targetEl as HTMLElement);
if (mention) {
ev.preventDefault();
ev.stopPropagation();
// The link's box is in the iframe's coordinates.
const frame = iframe.getBoundingClientRect();
const box = targetEl.getBoundingClientRect();
setBodyMention({
email: mention.email,
anchor: { top: frame.top + box.top, bottom: frame.top + box.bottom, left: frame.left + box.left },
});
return;
}
// Write it here, like any other address in the app; the mail page
// asks onMailtoIntercept first. Not taken (no recipient, nobody
// listening): the browser's own handling stays.
if (requestInternalMailto(href)) {
ev.preventDefault();
ev.stopPropagation();
}
return;
}
ev.preventDefault();
ev.stopPropagation();
// A link at a cid: part now carries the blob: URL we minted for it.
// Blob URLs inherit our origin, so it goes through the same
// preview/download gate as an attachment chip rather than
// window.open() (GHSA-xvjh-v9c6-qcvc).
if (href.startsWith('blob:')) {
openCidPartLink(href);
return;
}
// Only web links and external protocol handlers may be opened;
// data:, unresolved cid: and relative paths would land inside our
// own origin.
if (!isOpenableLinkHref(href)) return;
const ctx: ExternalLinkContext = {
href,
target: targetEl.getAttribute('target') ?? undefined,
emailId: email?.id,
};
// A plugin returns false to stop the link or { href } to send it
// somewhere else (a web address only).
const target = await resolveExternalLink(ctx);
if (!target) return;
window.open(target, '_blank', 'noopener,noreferrer');
};
doc.addEventListener('click', onLinkClick, true);
// A press in the body closes a mention's card. The page never sees
// it: the focus is already inside the iframe, so there is no blur.
doc.addEventListener('mousedown', () => setBodyMention(null), true);
// Selected text, for the side panel, the onTextSelectionChange hook
// and the right-click menu plugins may add entries to.
watchBodySelection(doc, iframe, {
emailId: email?.id,
onSelection: (text) => { if (iframe.contentDocument === doc) onBodySelection(text); },
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: emailIdRef.current, text }),
});
}
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink, onBodySelection, selectionMenu.openRef]);
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
// so a single unreachable remote image (server accepts the TCP connection but
// never responds) stalls it for the browser's ~60s timeout - freezing the body
// at its placeholder height that entire time. The parsed DOM we need for
// height, links and dark-mode is ready long before images resolve. Poll the
// fresh document's readyState because a sandbox without allow-scripts can't
// postMessage a DOMContentLoaded signal out, and the onLoad handler is
// idempotent per document so it stays a harmless backstop.
useEffect(() => {
if (!iframeRef.current) return;
const readyPoll = window.setInterval(() => {
handleIframeLoad();
if (initializedDocRef.current) window.clearInterval(readyPoll);
}, 50);
// Safety stop: the 'load' backstop covers anything the poll somehow misses.
const stop = window.setTimeout(() => window.clearInterval(readyPoll), 15000);
return () => { window.clearInterval(readyPoll); window.clearTimeout(stop); };
}, [emailIframeSrcDoc, handleIframeLoad]);
// Export email as .eml file
const handleExportEmail = async () => {
const exportClient = blobSource ? blobSource.client : client;
const exportAccountId = blobSource?.accountId;
if (!email?.blobId || !exportClient) return;
try {
await exportClient.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822', exportAccountId);
} catch {
toast.error(tNotifications('export_email_error'));
return;
}
const action = useSettingsStore.getState().postExportAction;
// With the Archive action off a stored 'archive' keeps the message.
if (action === 'archive' && archiveActionEnabled) onArchive?.();
else if (action === 'trash') onDelete?.();
};
// Import email from .eml file or .zip archive containing .eml files
const handleImportEmail = () => {
if (!client) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = EML_IMPORT_ACCEPT;
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length === 0) return;
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const mailboxId = mailbox?.originalId || selectedMailbox;
if (!mailboxId) {
toast.error(tNotifications('import_email_error'));
return;
}
let emails;
try {
emails = await expandImportableEmails(files);
} catch {
toast.error(tNotifications('import_email_error'));
return;
}
let imported = 0;
let failed = 0;
for (const { blob, receivedAt } of emails) {
try {
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true }, undefined, receivedAt);
imported++;
} catch {
failed++;
}
}
if (imported > 0) {
toast.success(tNotifications('import_email_success'));
await fetchEmails(client);
}
if (failed > 0 || emails.length === 0) {
toast.error(tNotifications('import_email_error'));
}
};
input.click();
};
// Print only the email content in a new window
const handlePrint = () => {
if (!email) return;
const printSender = email.from?.[0];
const date = formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
const formatRecipient = (r: { name?: string | null; email: string }) =>
r.name ? `${escapeHtml(r.name)} &lt;${escapeHtml(r.email)}&gt;` : escapeHtml(r.email);
const toList = email.to?.map(formatRecipient).join(', ') || '';
const ccList = email.cc?.map(formatRecipient).join(', ') || '';
const subjectText = email.subject || t('no_subject');
const senderText = printSender?.name
? `${printSender.name} <${printSender.email}>`
: printSender?.email || t('unknown_sender');
// The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
// <style>. The print window has no iframe isolation, so re-sanitize with
// the stricter config that forbids <style> before injecting into the DOM.
const printableBody = effectiveEmailContent.isHtml
? sanitizeEmailHtml(effectiveEmailContent.html)
: effectiveEmailContent.html;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
// A link in the message that navigates the popup itself (no target, a
// non-web scheme) would hand the sender's page an opener to redirect
// the webmail tab with. We write the document through our own handle.
printWindow.opener = null;
// The popup is an about:blank document of the app, so it carries the
// app's CSP (img-src https:), not the message iframe's strict one. Add
// the iframe's policy while remote content is blocked; it narrows the
// inherited one.
printWindow.document.write(`<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(effectiveEmailContent.externalBlocked)}">
<meta name="referrer" content="no-referrer">
<title>${escapeHtml(subjectText)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px; color: #000; }
.header { border-bottom: 1px solid #ccc; padding-bottom: 16px; margin-bottom: 16px; }
.subject { font-size: 20px; font-weight: bold; margin-bottom: 12px; }
.meta { font-size: 13px; color: #555; line-height: 1.6; }
.meta strong { color: #000; }
.body { font-size: 14px; line-height: 1.6; }
/* 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. Matches the viewer's own image rule.
height stays !important so a fixed inline height cannot squash it. */
.body img:not([style*="max-width"]) { max-width: 100% !important; }
.body img[style*="max-width"] { max-width: 100%; }
.body img { height: auto !important; }
@media print { body { margin: 20px; } }
</style></head><body dir="auto">
<div class="header">
<div class="subject">${escapeHtml(subjectText)}</div>
<div class="meta">
<div><strong>${escapeHtml(t('from'))}:</strong> ${escapeHtml(senderText)}</div>
${toList ? `<div><strong>${escapeHtml(t('to'))}:</strong> ${toList}</div>` : ''}
${ccList ? `<div><strong>CC:</strong> ${ccList}</div>` : ''}
${date ? `<div><strong>${escapeHtml(t('date'))}:</strong> ${escapeHtml(date)}</div>` : ''}
</div>
</div>
<div class="body">${printableBody}</div>
</body></html>`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
};
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
if (!email?.headers) return null;
return extractListHeaders(email.headers);
}, [email?.headers]);
const shouldShowUnsubBanner =
listHeaders?.listUnsubscribe?.preferred &&
!dismissedUnsubBanners.has(email?.messageId || '');
const hasCalendarInvitation = email
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
: false;
// ── Read receipt (MDN, RFC 8098) ──────────────────────────────
// Detect a Disposition-Notification-To request on the open message. The
// header is parsed into email.headers by the client; look it up
// case-insensitively and extract the bare address.
const readReceiptRequestedBy = useMemo(() => {
const headers = email?.headers as Record<string, string | string[]> | undefined;
if (!headers) return null;
let raw: string | undefined;
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'disposition-notification-to') {
const v = headers[key];
raw = Array.isArray(v) ? v[0] : v;
break;
}
}
if (!raw) return null;
// A header is a line: cap it, and give up an unclosed `<` at the next
// `<` rather than rescanning to the end once per `<` (sender-controlled).
const line = raw.slice(0, 1000);
const m = line.match(/<([^<>]+)>/);
const addr = (m ? m[1] : line).trim();
return addr || null;
}, [email?.headers]);
// The identity whose address received the original message — the MDN is sent
// "from" that address. Falls back to the primary identity.
const receiptIdentity = useMemo(() => {
if (!identities?.length) return null;
const recipients = [...(email?.to || []), ...(email?.cc || [])]
.map(r => r.email?.toLowerCase())
.filter(Boolean);
return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0];
}, [identities, email?.to, email?.cc]);
const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true;
const [mdnHandledLocally, setMdnHandledLocally] = useState(false);
useEffect(() => { setMdnHandledLocally(false); }, [email?.id]);
// Only offer the receipt for mail you're actually reading in a "received"
// location. Suppress your own copies (sent/drafts), discarded mail (trash),
// and spam (junk) - never confirm your address to spammers. Inbox, Archive
// and user folders all qualify.
const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || '');
const shouldOfferReadReceipt =
!!readReceiptRequestedBy &&
!mdnAlreadyHandled &&
!mdnHandledLocally &&
readReceiptResponse !== 'never' &&
inReceiptEligibleFolder &&
!isDraft &&
!!receiptIdentity;
const sendReadReceiptNow = useCallback(async (automatic: boolean) => {
if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return;
// Taken now: another folder may be open once the receipt is out.
const source = blobSource ?? emailWriteSource(email, client);
const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id);
try {
await client.sendReadReceipt({
to: readReceiptRequestedBy,
fromEmail: receiptIdentity.email,
fromName: receiptIdentity.name,
identityId: rawId ?? receiptIdentity.id,
originalMessageId: email.messageId,
originalSubject: email.subject,
originalRecipient: receiptIdentity.email,
automatic,
subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }),
humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }),
});
} catch (err) {
// Surface the failure instead of silently resetting the banner so we can
// see which step (upload / import / submission) failed.
console.error('Read-receipt (MDN) send failed:', err);
toast.error(t('read_receipt.send_failed'), {
message: sendFailureMessage(err, tComposer),
});
throw err;
}
// The receipt is out. Failing to flag that must not read as a failed
// send, nor let the "always" mode send it again.
try {
await useEmailStore.getState().markEmailKeyword(client, email.id, '$mdnsent', source);
} catch (err) {
console.error('Read receipt sent, but setting $MDNSent failed:', err);
setMdnHandledLocally(true);
}
}, [client, email, blobSource, readReceiptRequestedBy, receiptIdentity, t, tComposer]);
const ignoreReadReceipt = useCallback(async () => {
setMdnHandledLocally(true);
if (client && email) {
// $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the
// request is suppressed everywhere - not just locally.
try { await useEmailStore.getState().markEmailKeyword(client, email.id, '$mdnsent', blobSource ?? emailWriteSource(email, client)); } catch { /* best effort */ }
}
}, [client, email, blobSource]);
// "always" mode: auto-send the MDN once when the message is opened. A
// failure is not retried while the message stays open: the attempt itself
// changes Sent (the receipt is imported, then taken back out), every change
// hands the viewer a new `email`, and clearing the guard on failure made
// that one more attempt and error toast per refresh - without end once the
// submission quota is full. Opening the message again retries.
const autoMdnRef = useRef<string | null>(null);
useEffect(() => {
if (autoMdnRef.current !== email?.id) autoMdnRef.current = null;
}, [email?.id]);
useEffect(() => {
if (readReceiptResponse !== 'always') return;
if (!shouldOfferReadReceipt || !email) return;
if (autoMdnRef.current === email.id) return;
autoMdnRef.current = email.id;
sendReadReceiptNow(true).catch(() => { /* reported by sendReadReceiptNow */ });
// email is already captured via email?.id and sendReadReceiptNow (which
// depends on `email`); the autoMdnRef guard prevents a double send.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
// The plugin side panel. One element in the same place whatever the viewer
// shows (a message, the next one loading, none), so its frame lives on while
// the user reads on instead of starting over with every message.
const panelToggleInToolbar = !!email;
const sidePanel = (
<>
{hasDetailSidebar && !isMobile && (
<div className="contents" data-reader-side-panel="">
{/* With a message open the toggle sits in its toolbar; without one, here. */}
{sidebarCollapsed && !panelToggleInToolbar && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
)}
<div
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
)}
style={sidebarOverlay
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
{!panelToggleInToolbar && (
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
</div>
</div>
</>
)}
</div>
)}
</>
);
const shell = (content: React.ReactNode) => (
<div
ref={setViewerRootEl}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{null}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
{content}
{sidePanel}
{null}
</div>
</div>
);
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
return shell(
<div className="flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200">
{/* Loading Header Skeleton - gentler animation */}
<div className="bg-background border-b border-border">
<div className="px-4 lg:px-6 py-3 lg:py-4">
<div className="flex items-start justify-between gap-2 lg:gap-4">
<div className="flex-1 min-w-0 space-y-2 lg:space-y-3">
<div className="h-6 lg:h-8 bg-muted/60 rounded-md w-3/4"></div>
<div className="flex items-center gap-2 lg:gap-3">
<div className="h-3 lg:h-4 bg-muted/60 rounded w-24 lg:w-32"></div>
<div className="h-3 lg:h-4 bg-muted/60 rounded w-16 lg:w-24"></div>
</div>
</div>
<div className="flex items-center gap-1 lg:gap-2">
<div className="h-8 w-8 lg:w-20 bg-muted/60 rounded"></div>
<div className="h-8 w-8 bg-muted/60 rounded hidden lg:block"></div>
</div>
</div>
</div>
{/* Loading Sender Info Skeleton */}
<div className="px-4 lg:px-6 pb-3 lg:pb-4">
<div className="flex items-start gap-3 lg:gap-4">
<div className="w-10 h-10 lg:w-12 lg:h-12 bg-muted/60 rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted/60 rounded w-48"></div>
<div className="h-3 bg-muted/60 rounded w-64"></div>
</div>
</div>
</div>
</div>
{/* Loading Content Skeleton */}
<div className="flex-1 overflow-auto bg-muted/20">
<div className="px-6 pt-4 pb-6">
<div className="space-y-3">
<div className="h-4 bg-muted/60 rounded w-full"></div>
<div className="h-4 bg-muted/60 rounded w-5/6"></div>
<div className="h-4 bg-muted/60 rounded w-4/6"></div>
<div className="h-4 bg-muted/60 rounded w-full"></div>
<div className="h-4 bg-muted/60 rounded w-3/4"></div>
</div>
</div>
</div>
</div>
);
}
if (!email) {
if (isDemoMode) {
const logoSrc = withBasePath(resolvedTheme === 'dark'
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
return shell(
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
<div className="text-center p-8 max-w-md">
<img
src={logoSrc}
alt="Bulwark Mail"
className="h-12 mx-auto mb-6"
/>
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
<div className="flex flex-col gap-3 items-center">
<div className="grid grid-cols-2 gap-3 text-start text-sm text-muted-foreground w-full">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_email')}</span>
</div>
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_organize')}</span>
</div>
<div className="flex items-center gap-2">
<Keyboard className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_shortcuts')}</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_privacy')}</span>
</div>
</div>
<button
onClick={startTour}
className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
>
<PlayCircle className="w-4 h-4" />
{tWelcome('start_tour')}
</button>
<p className="text-xs text-muted-foreground/60 mt-2">{tDemoWelcome('hint')}</p>
</div>
</div>
</div>
);
}
// Rendered in Pro (embedded) too: a silent void here read as "broken"
// in a split pane - the empty reading pane should always say what it is
// and offer a way forward.
return shell(
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 me-2" />
{t('compose')}
</Button>
)}
</div>
</div>
);
}
const sender = email.from?.[0];
const senderVerification = getSenderVerification(email.authenticationResults, sender?.email);
const senderVerificationLabel = senderVerification?.status === 'failed'
? t('sender_check.failed_label')
: t('sender_check.unverified_label');
const senderVerificationMessage = !senderVerification
? ''
: senderVerification.status === 'failed'
? senderVerification.sentFrom
? t('sender_check.failed_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
: t('sender_check.failed', { domain: senderVerification.domain })
: senderVerification.sentFrom
? t('sender_check.unverified_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
: t('sender_check.unverified', { domain: senderVerification.domain });
const isStarred = email.keywords?.$flagged;
const isUnread = !email.keywords?.$seen;
const isImportant = email.keywords?.["$important"];
// Shared toolbar items used by both 'top' and 'below-subject' positions
const renderToolbarItems = (showBackButton: boolean) => (
<>
{/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-9 w-9 flex-shrink-0 -ms-1"
aria-label={t('back_to_list')}
>
<ChevronLeft className="w-5 h-5" />
</Button>
)}
{showBackButton && mvMobileViewer && (
<>
<Button
variant="ghost"
size="icon"
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
className="h-9 w-9 flex-shrink-0"
aria-label={t('tooltips.previous')}
title={t('tooltips.previous')}
>
<ChevronUp className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onNavigateNext}
disabled={!onNavigateNext}
className="h-9 w-9 flex-shrink-0"
aria-label={t('tooltips.next')}
title={t('tooltips.next')}
>
<ChevronDown className="w-5 h-5" />
</Button>
</>
)}
{isScheduled && canCancelScheduled && (
<>
<Button
variant="default"
size="sm"
onClick={() => void rescheduleScheduled(new Date(Date.now() + 1000).toISOString())}
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('send_now')}
>
<Send className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('send_now')}</span>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) void rescheduleScheduled(delayedUntil);
}}
className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0"
title={t('reschedule_send')}
>
<CalendarClock className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
<EditIcon className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
</Button>
</>
)}
{!isScheduled && isDraft && onEditDraft && (
<Button
variant="default"
size="sm"
onClick={() => onEditDraft()}
data-testid="edit-draft"
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
<EditIcon className="w-4 h-4" />
<span className="text-sm">{t('edit_draft')}</span>
</Button>
)}
{!isScheduled && !isDraft && !mvViewer && (<>
<Button
variant="ghost"
size="sm"
onClick={() => onReply?.()}
data-overflow-item
data-overflow-priority="1"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.reply')}
>
<Reply className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reply')}</span>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onReplyAll}
data-overflow-item
data-overflow-priority="2"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
title={t('tooltips.reply_all')}
>
<ReplyAll className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reply_all')}</span>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onForward}
data-overflow-item
data-overflow-priority="3"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.forward')}
>
<Forward className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button>
</>)}
<PluginSlot name="toolbar-actions" />
{/* Phones get these in the More panel. */}
{!isMobile && email && (
<PluginActions
surface="reader-actions"
context={readerActionContext}
mailboxRole={currentMailboxRole ?? null}
compact={!showToolbarLabels}
/>
)}
</div>
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
{!isScheduled && (
<div className="flex items-center gap-0 sm:gap-0.5">
{/* Archive */}
{archiveActionEnabled && (
<Button
variant="ghost"
size="sm"
onClick={onArchive}
data-overflow-item
data-overflow-priority="4"
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")}
title={t('tooltips.archive')}
>
<Archive className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('archive')}</span>}
</Button>
)}
{/* Delete */}
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-3")}
title={t('tooltips.delete')}
>
<Trash2 className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>}
</Button>
{/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && (
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="5" className={cn("relative", mvViewer && "order-6")}>
<Button
ref={moveButtonRef}
variant="ghost"
size="sm"
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('move_to')}
aria-label={t('move_to')}
aria-haspopup="menu"
aria-expanded={moveMenuOpen}
>
<FolderInput className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('move')}</span>}
</Button>
{moveMenuOpen && (
<div
ref={moveMenuListRef}
onKeyDown={onMoveMenuKeyDown}
role="menu"
aria-label={t('move_to')}
className="absolute end-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10"
>
{(() => {
const renderNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoveMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderNodes(node.children, depth + 1)}
</div>
);
});
};
return renderNodes(moveTree);
})()}
</div>
)}
</div>
)}
{/* Tag Picker - hidden on mobile, overflows to More menu */}
<div data-overflow-item data-overflow-priority="6" className={cn("hidden sm:flex items-center", mvViewer && "order-7")}>
{!mvViewer && <div className="w-px h-5 bg-border mx-0.5" />}
<div ref={tagMenuRef} className="relative">
<button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
title={t('set_tag')}
aria-label={t('set_tag')}
aria-haspopup="true"
aria-expanded={tagMenuOpen}
>
<Tag className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
</button>
{tagMenuOpen && (
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
)}
</div>
</div>
{/* Spam */}
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="sm"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
data-overflow-item
data-overflow-priority="7"
className={cn(
"flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0",
mvViewer
? "order-2"
: isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
)}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{isInJunkFolder ? (
<ShieldCheck className={cn("h-4 w-4", !mvViewer && "text-green-600 dark:text-green-400")} />
) : (
<ShieldAlert className={cn("h-4 w-4", !mvViewer && "text-red-600 dark:text-red-400")} />
)}
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{isInJunkFolder ? t('not_spam_short') : t('spam_short')}</span>}
</Button>
)}
{/* Toggle read state.
Both "Read" and "Unread" labels are rendered stacked in one grid cell
(the inactive one invisible) so the button keeps a fixed width when the
email is auto-marked as read on open. Otherwise the width change re-runs
the overflow calculation and the toolbar buttons jump around (#864). */}
{/* Mountain View groups the actions as Gmail does, with a rule between groups. */}
{mvViewer && <div className="order-4 w-px h-5 bg-border mx-1" aria-hidden />}
<Button
variant="ghost"
size="sm"
onClick={() => onMarkAsRead?.(email.id, isUnread)}
data-overflow-item
data-overflow-priority="8"
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-5")}
title={isUnread ? t('mark_read') : t('mark_unread')}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
{showToolbarLabels && (
<span className="grid text-center text-[10px] leading-tight sm:text-sm">
<span className={cn("col-start-1 row-start-1", !isUnread && "invisible")} aria-hidden={!isUnread}>{t('read')}</span>
<span className={cn("col-start-1 row-start-1", isUnread && "invisible")} aria-hidden={isUnread}>{t('unread')}</span>
</span>
)}
</Button>
{/* Print - hidden on mobile, overflows to More menu. Mountain View
has it on the subject line. */}
{!mvViewer && (
<Button
variant="ghost"
size="sm"
onClick={handlePrint}
data-overflow-item
data-overflow-priority="9"
className="hidden sm:inline-flex h-8 gap-1.5"
title={t('print')}
>
<Printer className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
</Button>
)}
{/* View source - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSourceModal(true)}
data-overflow-item
data-overflow-priority="10"
className={cn("hidden sm:inline-flex h-8 gap-1.5", mvViewer && "order-8")}
title={t('view_source')}
>
<Code className="w-4 h-4" />
</Button>
{/* Dark/light mode toggle for HTML emails. Always mounted: `isHtml`
flips from false to true once the body arrives, and mounting the
button then changes the toolbar width and re-runs the overflow
calculation, so the other buttons jump. Disable it instead. (#964) */}
<Button
variant="ghost"
size="sm"
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
data-overflow-item
data-overflow-priority="11"
className={cn("hidden sm:inline-flex h-8 gap-1.5", mvViewer && "order-8")}
title={isDark ? t('view_in_light_mode') : t('view_in_dark_mode')}
disabled={!effectiveEmailContent.isHtml}
aria-disabled={!effectiveEmailContent.isHtml}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
{/* Fullscreen toggle - hidden on mobile (already fullscreen there).
Never overflows into the More menu: in fullscreen this button is
the way back out, so it must stay visible. */}
{onToggleFullscreen && !mvViewer && (
<Button
variant="ghost"
size="sm"
onClick={onToggleFullscreen}
className="hidden sm:inline-flex h-8 gap-1.5"
title={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
aria-label={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
)}
{/* More menu - click-based */}
<div ref={moreMenuRef} className={cn("relative", mvViewer && "order-9")}>
<Button
ref={moreButtonRef}
variant="ghost"
size="sm"
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:w-8 sm:gap-0 sm:py-0 sm:px-0"
title={t('more_actions')}
aria-label={t('more_actions')}
aria-haspopup="menu"
aria-expanded={moreMenuOpen}
aria-controls={moreMenuOpen ? moreMenuId : undefined}
onClick={() => { setMoreMenuOpen(!moreMenuOpen); setMoreMenuSlideEnabled(true); setMoreMenuSub(null); setTagMenuOpen(false); setMoveMenuOpen(false); }}
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
{interfaceLayout !== 'mountain-view' && <span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>}
</Button>
{moreMenuOpen && !isMobile && (
<div
ref={moreMenuListRef}
onKeyDown={handleMoreMenuKeyDown}
id={moreMenuId}
role="menu"
aria-label={t('more_actions')}
className="absolute end-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10 py-1"
>
{/* Star toggle */}
<button
role="menuitem"
onClick={() => { onToggleStar?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Star className={cn("w-4 h-4", isStarred && "fill-yellow-400 text-yellow-400")} />
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button>
{/* Overflow: reply */}
<button
role="menuitem"
onClick={() => { onReply?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(1) ? "" : "sm:hidden")}
>
<Reply className="w-4 h-4" />
{t('reply')}
</button>
{/* Overflow: reply all */}
<button
role="menuitem"
onClick={() => { onReplyAll?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(2) ? "" : "sm:hidden")}
>
<ReplyAll className="w-4 h-4" />
{t('reply_all')}
</button>
{/* Overflow: forward */}
<button
role="menuitem"
onClick={() => { onForward?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(3) ? "" : "sm:hidden")}
>
<Forward className="w-4 h-4" />
{t('forward')}
</button>
{/* Overflow: archive */}
{archiveActionEnabled && (
<button
role="menuitem"
onClick={() => { onArchive?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
>
<Archive className="w-4 h-4" />
{t('archive')}
</button>
)}
{/* Overflow: move to folder - submenu */}
{moveTree.length > 0 && onMoveToMailbox && (
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('move')}
onMouseLeave={() => setMoreMenuSub(null)}
>
<button
ref={(el) => { moreEntryRefs.current.move = el; }}
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'move'}
onClick={() => { if (moreMenuSub === 'move') leaveMoreMenuSub(); else setMoreMenuSub('move'); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<FolderInput className="w-4 h-4" />
<span className="flex-1">{t('move_to')}</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'move' && (
<SubMenuPanel
side="start"
role="menu"
aria-label={t('move_to')}
className="py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border"
>
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
</SubMenuPanel>
)}
</div>
)}
{onRemoveFromFolder && (
<button
role="menuitem"
data-testid="viewer-remove-from-folder"
onClick={() => { onRemoveFromFolder(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<FolderX className="w-4 h-4" />
{tContextMenu('remove_from_folder')}
</button>
)}
{/* Overflow: tag - submenu */}
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)}
>
<button
ref={(el) => { moreEntryRefs.current.tag = el; }}
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'tag'}
onClick={() => { if (moreMenuSub === 'tag') leaveMoreMenuSub(); else setMoreMenuSub('tag'); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Tag className="w-4 h-4" />
<span className="flex-1">{t('tag')}</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'tag' && (
<SubMenuPanel
side="start"
role="menu"
aria-label={t('tag')}
className="py-1 w-56 bg-background rounded-md shadow-lg border border-border"
>
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</SubMenuPanel>
)}
</div>
)}
{/* Rules - sub-view of one-click filter rules */}
{rulesAvailability === 'available' && rulesTarget && email && (
<div className="relative"
onMouseEnter={() => setMoreMenuSub('rules')}
onMouseLeave={() => setMoreMenuSub(null)}
>
<button
ref={(el) => { moreEntryRefs.current.rules = el; }}
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'rules'}
data-testid="viewer-rules"
onClick={() => { if (moreMenuSub === 'rules') leaveMoreMenuSub(); else setMoreMenuSub('rules'); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Filter className="w-4 h-4" />
<span className="flex-1">{tContextMenu('rules.title')}</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'rules' && (
<SubMenuPanel
side="start"
role="menu"
aria-label={tContextMenu('rules.title')}
className="py-1 w-72 max-h-80 overflow-y-auto bg-background rounded-md shadow-lg border border-border"
>
<RulesPanel email={email} target={rulesTarget} variant="desktop" onDone={closeMoreMenu} />
</SubMenuPanel>
)}
</div>
)}
{/* Overflow: spam */}
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
<button
role="menuitem"
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Overflow: toggle read */}
<button
role="menuitem"
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(8) ? "" : "sm:hidden")}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
{/* Overflow: print */}
<button
role="menuitem"
onClick={() => { handlePrint(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(9) ? "" : "sm:hidden")}
>
<Printer className="w-4 h-4" />
{t('print')}
</button>
{/* Overflow: view source */}
<button
role="menuitem"
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(10) ? "" : "sm:hidden")}
>
<Code className="w-4 h-4" />
{t('view_source')}
</button>
{/* Overflow: dark/light mode toggle */}
{effectiveEmailContent.isHtml && (
<button
role="menuitem"
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{isDark ? t('view_in_light_mode') : t('view_in_dark_mode')}
</button>
)}
<div className="h-px bg-border my-1" />
{/* Permalink to this message (#733) */}
{email && (
<button
role="menuitem"
onClick={() => {
void copyLink(buildMailPath({ mailboxId: null, emailId: email.id, threadId: null, ...emailLinkSource(email) }));
setMoreMenuOpen(false);
setMoreMenuSub(null);
}}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<LinkIcon className="w-4 h-4" />
{tDeepLink('copy_message')}
</button>
)}
{/* Forward as attachment */}
{onForwardAsAttachment && email?.blobId && (
<button
role="menuitem"
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Paperclip className="w-4 h-4" />
{t('forward_as_attachment')}
</button>
)}
{/* Export email */}
<button
role="menuitem"
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('export_email')}
</button>
{/* Import email */}
<button
role="menuitem"
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{t('import_email')}
</button>
{onShowShortcuts && (
<button
role="menuitem"
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Keyboard className="w-4 h-4" />
{t('keyboard_shortcuts')}
</button>
)}
</div>
)}
</div>
{/* The plugin side panel: shown or hidden for every message alike. */}
{hasDetailSidebar && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={toggleDetailSidebar}
className={cn("h-8 w-8", mvViewer && "order-10")}
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-pressed={!sidebarCollapsed}
data-plugin-panel-toggle=""
>
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
</Button>
)}
</div>
)}
</>
);
// The top toolbar. On a desktop or tablet it spans the whole reader, over
// the plugin and contact side panels too; on a phone it stays in the
// message column.
const topToolbar = (
<div
data-viewer-top-toolbar={liftToolbar ? "full-width" : "column"}
className={cn(
"bg-background flex-shrink-0",
!mvViewer && "border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10"
)}
>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
<div
ref={toolbarRef}
data-mv-viewer-toolbar={mvViewer ? "" : undefined}
className={cn("flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
>
{renderToolbarItems(true)}
{mvViewer && mvPosition && (
<MvPager position={mvPosition} onPrev={onNavigatePrev} onNext={onNavigateNext} />
)}
</div>
</div>
</div>
);
return (
<div
ref={setViewerRootEl}
data-tour="email-viewer"
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{toolbarPosition === 'top' && liftToolbar && topToolbar}
{/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
<>
{/* Mobile More menu sidebar overlay. Inside a Pro pane both pieces
anchor to the viewer's relative root with absolute positioning: the
viewport is desktop-sized there even though the pane is phone-sized,
so neither viewport-fixed positioning nor the viewport-based
`sm:hidden` guard can be trusted (they used to leave the More button
opening an invisible panel in a split pane). */}
{!isScheduled && isMobile && moreMenuOpen && (
<div
className={cn(
"bg-black/50 z-[60]",
isPaneScoped ? "absolute inset-0" : "fixed inset-0 sm:hidden",
)}
onClick={() => setMoreMenuOpen(false)}
/>
)}
{!isScheduled && isMobile && (
<div
ref={mobileMoreRef}
onKeyDown={handleMobileMoreKeyDown}
id={moreMenuId}
role="menu"
/* A sub-view replaces the panel wholesale, so the panel takes its name
rather than pretending the top level is still on screen. */
aria-label={moreMenuSub === 'move' ? t('move_to') : moreMenuSub === 'tag' ? t('tag') : moreMenuSub === 'rules' ? tContextMenu('rules.title') : t('more_actions')}
/* The panel is only slid off-screen, so without `inert` every action in
it stays permanently exposed to screen readers - and lands near the
top of the reading order, far from the toolbar it belongs to (#720). */
inert={!moreMenuOpen}
className={cn(
"bg-background border-s border-border z-[70]",
isPaneScoped ? "absolute inset-y-0 right-0 w-72" : "fixed inset-y-0 right-0 w-72 sm:hidden",
"transform",
moreMenuSlideEnabled && "transition-transform duration-300 ease-in-out",
"flex flex-col",
moreMenuOpen ? "translate-x-0" : "translate-x-full"
)}>
{/* The fixed panel spans the full viewport height, so in the iOS PWA
its header would sit under the status bar without the safe-area
inset (same treatment as the preview modals). (#936) */}
<div className={cn(
"flex items-center justify-between px-4 border-b border-border",
isPaneScoped ? "py-3" : "pb-3 pt-[calc(0.75rem+env(safe-area-inset-top))]"
)}>
{moreMenuSub ? (
<button
ref={mobileSubBackRef}
role="menuitem"
onClick={leaveMoreMenuSub}
className="flex items-center gap-1 -ms-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
<ChevronLeft className="w-5 h-5" />
{moreMenuSub === 'move' ? t('move_to') : moreMenuSub === 'rules' ? tContextMenu('rules.title') : t('tag')}
</button>
) : (
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
)}
<Button
variant="ghost"
size="icon"
role="menuitem"
aria-label={tCommon('close')}
onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="h-9 w-9"
>
<X className="w-5 h-5" />
</Button>
</div>
<div ref={mobileSubListRef} className="flex-1 overflow-y-auto py-2">
{moreMenuSub === null && (
<>
{/* Star toggle */}
<button
role="menuitem"
onClick={() => { onToggleStar?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Star className={cn("w-5 h-5", isStarred && "fill-yellow-400 text-yellow-400")} />
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button>
{/* Move to folder (opens sub-view). The toolbar's own Move button
is the first thing dropped when the toolbar runs out of room,
and on mobile the overflow has nowhere else to go - without
this row a narrow screen loses the action entirely (#779). */}
{moveTree.length > 0 && onMoveToMailbox && (
<button
ref={(el) => { moreEntryRefs.current.move = el; }}
role="menuitem"
aria-haspopup="menu"
onClick={() => setMoreMenuSub('move')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<FolderInput className="w-5 h-5" />
<span className="flex-1">{t('move_to')}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{onRemoveFromFolder && (
<button
role="menuitem"
onClick={() => { onRemoveFromFolder(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<FolderX className="w-5 h-5" />
{tContextMenu('remove_from_folder')}
</button>
)}
{/* Tag (opens sub-view) */}
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<button
ref={(el) => { moreEntryRefs.current.tag = el; }}
role="menuitem"
aria-haspopup="menu"
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentTagIds.length > 0 && (
<div className="flex -space-x-1 me-1">
{sortedTagIds.slice(0, 3).map((tagId) => (
<span
key={tagId}
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
/>
))}
</div>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Rules (opens sub-view) */}
{rulesAvailability === 'available' && rulesTarget && email && (
<button
ref={(el) => { moreEntryRefs.current.rules = el; }}
role="menuitem"
aria-haspopup="menu"
data-testid="viewer-rules"
onClick={() => setMoreMenuSub('rules')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Filter className="w-5 h-5" />
<span className="flex-1">{tContextMenu('rules.title')}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
<button
role="menuitem"
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Printer className="w-5 h-5" />
{t('print')}
</button>
<button
role="menuitem"
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Code className="w-5 h-5" />
{t('view_source')}
</button>
{effectiveEmailContent.isHtml && (
<button
role="menuitem"
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? t('view_in_light_mode') : t('view_in_dark_mode')}
</button>
)}
<div className="h-px bg-border my-1" />
{onForwardAsAttachment && email?.blobId && (
<button
role="menuitem"
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Paperclip className="w-5 h-5" />
{t('forward_as_attachment')}
</button>
)}
<button
role="menuitem"
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Download className="w-5 h-5" />
{t('export_email')}
</button>
<button
role="menuitem"
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Upload className="w-5 h-5" />
{t('import_email')}
</button>
{onShowShortcuts && (
<button
role="menuitem"
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Keyboard className="w-5 h-5" />
{t('keyboard_shortcuts')}
</button>
)}
{email && (
<PluginPanelEntries
surface="reader-actions"
context={readerActionContext}
mailboxRole={currentMailboxRole ?? null}
onRan={() => setMoreMenuOpen(false)}
/>
)}
</>
)}
{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 (
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && (
<TagPicker
touch
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
)}
{moreMenuSub === 'rules' && rulesAvailability === 'available' && rulesTarget && email && (
<RulesPanel email={email} target={rulesTarget} variant="mobile" onDone={closeMoreMenu} />
)}
</div>
</div>
)}
{/* Main email content */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* === TOOLBAR (top position) === */}
{toolbarPosition === 'top' && !liftToolbar && topToolbar}
<PluginSlot name="reader-header" className="w-full bg-background" extraProps={pluginEmailProps} />
{/* === SUBJECT BLOCK === */}
<div className={cn(
"bg-background",
!mvViewer && "border-b border-border",
toolbarPosition === 'below-subject' && "max-lg:sticky max-lg:top-0 max-lg:z-10"
)}>
{/* Mountain View lines the subject up with the sender's name below it. */}
<div className={cn("px-4 lg:px-6", mvViewer && "lg:ps-[80px] pt-3 pb-1")} style={mvViewer ? undefined : { paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-11 w-11 lg:h-10 lg:w-10 flex-shrink-0 -ms-2"
aria-label={t('back_to_list')}
>
<ChevronLeft className="w-5 h-5" />
</Button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<h1 className={cn(
"text-foreground break-words min-w-0",
mvViewer ? "text-[22px] leading-7 font-normal" : "text-lg lg:text-xl font-bold tracking-tight"
)}>
{email.subject || t('no_subject')}
</h1>
{/* Mountain View: the folder the message sits in, as a chip on the subject line. */}
{mvViewer && mvFolderName && (
<span className="self-center flex-shrink-0 rounded px-1.5 py-0.5 text-xs bg-muted text-muted-foreground" data-mv-folder-chip="">
{mvFolderName}
</span>
)}
{/* Star inline with subject (top toolbar mode). Mountain View
has it on the sender's line instead. */}
{toolbarPosition === 'top' && !mvViewer && (
<button
onClick={onToggleStar}
className="flex-shrink-0 p-1 rounded hover:bg-muted transition-colors"
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
>
<Star className={cn(
"w-4 h-4 lg:w-5 lg:h-5 transition-colors",
isStarred ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground/40"
)} />
</button>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
</span>
)}
</div>
{verificationCode && (
<div className="mt-1.5 flex">
<VerificationCodeChip code={verificationCode} className="py-1 text-sm" />
</div>
)}
{sortedTagIds.length > 0 && (
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
{sortedTagIds.map((tagId) => (
<TagBadge
key={tagId}
tagId={tagId}
variant={headerTagVariant}
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
/>
))}
</div>
)}
</div>
{/* Mountain View: print and full screen at the end of the subject line. */}
{mvViewer && (
<div className="hidden lg:flex items-center flex-shrink-0 -me-2" data-mv-subject-actions="">
<button
type="button"
onClick={handlePrint}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('print')}
aria-label={t('print')}
>
<Printer className="w-5 h-5" />
</button>
{onToggleFullscreen && (
<button
type="button"
onClick={onToggleFullscreen}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
aria-label={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
>
{isFullscreen ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
</button>
)}
</div>
)}
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender.
Mountain View puts it on the sender's row instead, and shows no size. */}
<div className={cn("hidden flex-shrink-0 text-end", !mvViewer && "sm:block")}>
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
</div>
</div>
{/* === TOOLBAR (below-subject position) === */}
{toolbarPosition === 'below-subject' && (
<div className="bg-background border-b border-border">
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-1.5">
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
{renderToolbarItems(false)}
</div>
</div>
</div>
)}
{/* Email Content Area. Mountain View reads the conversation on one plain
sheet, so the reply buttons under the message sit on the page colour
rather than on a tinted strip. */}
<div className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
<div className="min-h-full flex flex-col">
{/* Above the messages of the open conversation. */}
{mvOpenIndex >= 0 && (
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
)}
{/* Mountain View: the conversation's earlier messages, folded. */}
{mvBefore.length > 0 && (
<div data-mv-conversation-before="">
<MvConversationRun emails={mvBefore} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} collapseLongRun />
</div>
)}
{/* Mountain View: the open message folded like the rest of the conversation. */}
{mvOpenFolded && email ? (
<div data-mv-open-folded="">
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
</div>
) : mvAwaitingConversation ? (
<div className={cn("flex items-start py-3 animate-pulse", mvMobileViewer ? "gap-3 px-4" : "gap-4 px-6")} data-mv-conversation-pending="">
<div className="w-10 h-10 rounded-full bg-muted flex-shrink-0" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-2.5 bg-muted rounded w-1/3" />
<div className="h-2 bg-muted rounded w-2/3" />
<div className="h-2 bg-muted rounded w-1/2" />
</div>
</div>
) : (<>
{/* === SENDER INFO (Desktop) === */}
<div
className={cn("hidden lg:block bg-background px-6", !mvViewer && "border-b border-border", mvViewer && mvBefore.length > 0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")}
style={mvViewer ? { paddingBlock: '0.75rem 0.5rem' } : { paddingBlock: 'var(--density-header-py)' }}
data-mv-open-header={mvOpenIndex >= 0 ? "" : undefined}
onClick={mvOpenIndex >= 0 ? (e) => {
// In a conversation, a click on the sender line folds the open
// message, as Gmail does; its buttons and links keep their own job.
if (isInteractiveClick(e.target, e.currentTarget)) return;
if (window.getSelection()?.toString()) return;
setMvOpenFolded(true);
} : undefined}
>
<div className="flex items-start" style={{ gap: mvViewer ? '16px' : 'var(--density-item-gap)' }}>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0 flex gap-4">
<div className="flex-1 min-w-0">
{/* Row 1: Sender name + badges */}
<div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{sender?.email ? (
<RecipientPopover
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="font-semibold text-start"
/>
) : (
<span className="font-semibold text-foreground">{t('unknown_sender')}</span>
)}
{mvViewer && sender?.email && sender?.name && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onSendMailtoUnsubscribe={handleSendMailtoUnsubscribe}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
)}
{mvViewer && (
<span className="ms-auto ps-2 flex items-center gap-1 -my-2 -me-2" data-mv-sender-actions="">
<span className="text-xs text-muted-foreground whitespace-nowrap me-1" data-mv-sender-date="">
{mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
<button
type="button"
onClick={onToggleStar}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
aria-label={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
aria-pressed={!!isStarred}
>
<Star className={cn("w-5 h-5", isStarred && "fill-amber-400 text-amber-400")} />
</button>
{!isDraft && !isScheduled && (
<button
type="button"
onClick={() => onReply?.()}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t('tooltips.reply')}
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
</button>
)}
</span>
)}
</div>
{/* Email address under name (on the name's line under Mountain View) */}
{!mvViewer && sender?.email && sender?.name && (
<div className="text-sm text-muted-foreground mt-0.5 truncate">{sender.email}</div>
)}
</div>
</div>
{/* Row 2: Recipients + Show details */}
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
{email.to && email.to.length > 0 && (
<>
<span>{t('recipient_to_prefix')}</span>
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
{email.to.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
</>
)}
{email.cc && email.cc.length > 0 && (
<>
<span className="text-muted-foreground/50">|</span>
<span>CC:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && (
<span className="text-muted-foreground">+{email.cc.length - 2}</span>
)}
</>
)}
{email.bcc && email.bcc.length > 0 && (
<>
<span className="text-muted-foreground/50">|</span>
<span>{t('bcc')}:</span>
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)}
{email.bcc.length > 2 && (
<span className="text-muted-foreground">+{email.bcc.length - 2}</span>
)}
</>
)}
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
>
{showFullHeaders ? (
<>
<ChevronUp className="w-3 h-3" />
{t('hide_details')}
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
{t('show_details')}
</>
)}
</button>
</div>
</div>
{/* Attachments on the right (beside-sender mode) */}
{attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && (
<div className="relative flex flex-col items-end justify-start gap-1 flex-shrink-0 max-w-[50%]">
{effectiveAttachments.slice(0, 2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
thumbUrl
? "inline-flex flex-col w-40"
: "inline-flex items-center gap-1.5 px-2 py-1",
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<div className="w-full h-16 bg-background/40 flex items-center justify-center overflow-hidden">
<img src={thumbUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
)}
<div className={cn(
"flex items-center gap-1.5",
thumbUrl && "px-2 py-1 border-t border-border/50 w-full",
)}>
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className={cn(
"text-xs text-foreground truncate",
thumbUrl ? "flex-1 min-w-0" : "max-w-[140px]",
)}>
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
>
<Download className="w-3.5 h-3.5 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
>
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
{effectiveAttachments.length > 2 && (
<button
onClick={() => setShowAllBesideAttachments(!showAllBesideAttachments)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-0.5"
>
+{effectiveAttachments.length - 2} {t('more')}
</button>
)}
{downloadAllButton}
{/* Floating popup for remaining attachments */}
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllBesideAttachments(false)} />
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllBesideAttachments(false); }}
>
<Download className="w-3.5 h-3.5 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
>
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
</div>
</>
)}
</div>
)}
</div>
</div>
</div>
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div
className={cn("lg:hidden bg-background border-b border-border px-4", mvOpenIndex >= 0 && "cursor-pointer", mvOpenIndex >= 0 && mvBefore.length > 0 && "border-t")}
style={{ paddingBlock: 'var(--density-header-py)' }}
data-mv-open-header-mobile={mvOpenIndex >= 0 ? "" : undefined}
onClick={mvOpenIndex >= 0 ? (e) => {
if (isInteractiveClick(e.target, e.currentTarget)) return;
if (window.getSelection()?.toString()) return;
setMvOpenFolded(true);
} : undefined}
>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0">
{/* Row 1: Sender name + badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{sender?.email ? (
<RecipientPopover
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="text-sm font-semibold text-start"
/>
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onSendMailtoUnsubscribe={handleSendMailtoUnsubscribe}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
)}
</div>
{/* Email address under name - Mountain View keeps it in the details */}
{!mvMobileViewer && sender?.email && sender?.name && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">{sender.email}</div>
)}
{/* Row 2: Recipients */}
<div className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{email.to && email.to.length > 0 && (
<>
<span>→ {t('recipient_to_prefix')}</span>
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
</>
)}
{email.cc && email.cc.length > 0 && (
<>
<span className="text-muted-foreground/50">|</span>
<span>CC:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && (
<span>+{email.cc.length - 2}</span>
)}
</>
)}
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
>
{showFullHeaders ? (
<>
<ChevronUp className="w-3 h-3" />
{t('hide_details')}
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
{t('show_details')}
</>
)}
</button>
</div>
</div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-end ms-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{mvMobileViewer
? formatDate(emailDisplayDate(email))
: formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && !mvMobileViewer && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
</div>
<PluginSlot name="reader-below-subject" className="w-full bg-background" extraProps={pluginEmailProps} />
{/* 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);
// 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).
// A component defined here would be a new type on every render and
// remount the plugin's iframe each time, so it lives outside.
const categorySlot = (category: string) => (
<DetailsCategorySlot email={pluginEmailView} category={category} />
);
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-start"
/>
{senderVerification && (
<span
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-xs whitespace-nowrap cursor-help",
senderVerification.status === 'failed'
? "bg-red-500/[0.07] border-red-500/30 text-red-700 dark:text-red-400"
: "bg-amber-500/[0.07] border-amber-500/30 text-amber-700 dark:text-amber-400",
)}
title={senderVerificationMessage}
>
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
{senderVerificationLabel}
</span>
)}
</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>
{categorySlot("recipients_routing")}
</section>
{hasAuthSection && (
<section className="min-w-0">
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{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 (
<AuthChip
name="SPF"
result={auth.spf.result}
extra={auth.spf.domain}
tooltip={breakdown ? `${t('authentication.tooltip_spf')}\n\n${breakdown}` : 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>
)}
{categorySlot("authentication_security")}
</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>
{categorySlot("identifiers_threading")}
</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>
{categorySlot("message_properties")}
</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>
{categorySlot("mailing_list")}
</section>
)}
{/* Plugin-supplied category. Plugins whose shouldShow accepts
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
<section className="lg:col-span-2 min-w-0">
<DetailsCategorySlot email={pluginEmailView} category={null} />
</section>
)}
</div>
</div>
);
})()}
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="px-4 sm:px-6 py-2.5 flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{canCancelScheduled && (
<>
<Button
size="sm"
variant="outline"
onClick={() => void rescheduleScheduled(new Date(Date.now() + 1000).toISOString())}
className="gap-1.5"
>
<Send className="w-3.5 h-3.5" />
{t('send_now')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) void rescheduleScheduled(delayedUntil);
}}
className="gap-1.5"
>
<CalendarClock className="w-3.5 h-3.5" />
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit} className="gap-1.5">
<EditIcon className="w-3.5 h-3.5" />
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</>
)}
</div>
</div>
</div>
)}
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-warning/10">
<div className="px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-warning">
<File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span>
</div>
{/* The toolbar already has Edit, except for a scheduled message. */}
{onEditDraft && isScheduled && (
<Button
size="sm"
variant="outline"
onClick={() => onEditDraft()}
className="gap-1.5"
>
<EditIcon className="w-3.5 h-3.5" />
{t('edit_draft')}
</Button>
)}
</div>
</div>
)}
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{(senderVerification ||
(hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation ||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
{/* Sender the server's checks don't back */}
{senderVerification && (
<div className="flex items-start gap-3 py-1">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
senderVerification.status === 'failed' ? "bg-destructive/15 text-destructive" : "bg-warning/15 text-warning",
)}>
<ShieldAlert className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{senderVerificationLabel}
</div>
<div className="text-sm font-medium text-foreground break-words">
{senderVerificationMessage}
</div>
<div className="text-sm text-muted-foreground break-words">
{t('sender_check.caution')}
</div>
</div>
</div>
)}
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-start gap-3 py-1">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<Image className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
External Content
</div>
<div className="text-sm font-medium text-foreground break-words">
{t('external_content_warning')}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
</button>
)}
{/* Trusting a forged address would load remote content
for the next forgery too. */}
{email.from?.[0]?.email && !senderVerification && (
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
>
<ShieldCheck className="w-3.5 h-3.5" />
{t('trust_sender')}
</button>
)}
</div>
</div>
</div>
)}
{/* Read-receipt (MDN) request banner — only in "ask" mode */}
{readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && (
<div className="py-1">
<ReadReceiptBanner
requestedBy={readReceiptRequestedBy}
onSend={() => sendReadReceiptNow(false)}
onIgnore={ignoreReadReceipt}
/>
</div>
)}
{/* Calendar Invitation Banner */}
{hasCalendarInvitation && (
<div className="py-1">
<CalendarInvitationBanner email={email} />
</div>
)}
</div>
</div>
</div>
)}
<PluginSlot name="email-banner" extraProps={pluginEmailProps} />
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
<div className="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
<div className="relative flex items-center gap-2">
<div ref={belowHeaderRowRef} className="relative flex items-center gap-2 overflow-hidden flex-1 min-w-0">
{/* Hidden ghost row used purely for measuring chip widths */}
<div
ref={belowHeaderGhostRef}
aria-hidden="true"
className="absolute inset-y-0 left-0 right-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
>
{effectiveAttachments.map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const hasThumb = !!imageThumbUrls[attachment.id];
if (hasThumb) {
// Image chip is a fixed-width vertical card; only its width
// matters for the row-fit measurement.
return (
<div
key={`ghost-${attachment.id}`}
className="inline-block w-44 rounded-md border border-border/50 flex-shrink-0"
style={{ height: 1 }}
/>
);
}
return (
<div
key={`ghost-${attachment.id}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border border-border/50 flex-shrink-0"
>
<FileIcon className="w-4 h-4" />
<span className="text-sm truncate max-w-[200px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs">
{formatFileSize(attachment.size)}
</span>
</div>
);
})}
</div>
{effectiveAttachments
.slice(0, visibleBelowHeaderCount ?? effectiveAttachments.length)
.map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
thumbUrl
? "inline-flex flex-col w-44"
: "inline-flex items-center gap-1.5 px-2.5 py-1.5",
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
<img
src={thumbUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
)}
<div className={cn(
"flex items-center gap-1.5",
thumbUrl && "px-2 py-1.5 border-t border-border/50 w-full",
)}>
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className={cn(
"text-sm text-foreground truncate",
thumbUrl ? "flex-1 min-w-0" : "max-w-[200px]",
)}>
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
</div>
<div className={cn(
"absolute rounded-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
>
<Download className="w-4 h-4 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
<button
onClick={() => setShowAllBelowHeaderAttachments(!showAllBelowHeaderAttachments)}
className="inline-flex items-center px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-md border border-border/50 transition-colors flex-shrink-0"
>
+{effectiveAttachments.length - visibleBelowHeaderCount} {t('attachments').toLowerCase()}
</button>
)}
</div>
{downloadAllButton}
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-foreground truncate max-w-[220px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllBelowHeaderAttachments(false); }}
>
<Download className="w-4 h-4 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
</div>
</>
)}
</div>
</div>
)}
{/* Mobile/Tablet Attachments */}
{effectiveAttachments.length > 0 && (
<div className="lg:hidden bg-background border-b border-border px-4 py-2">
<div className="relative flex items-center gap-1.5 flex-wrap">
{effectiveAttachments.slice(0, 2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
thumbUrl
? "inline-flex flex-col w-44"
: "inline-flex items-center gap-1.5 px-2.5 py-1.5",
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
<img src={thumbUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
)}
<div className={cn(
"flex items-center gap-1.5",
thumbUrl && "px-2 py-1.5 border-t border-border/50 w-full",
)}>
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className={cn(
"text-sm text-foreground truncate",
thumbUrl ? "flex-1 min-w-0" : "max-w-[200px]",
)}>
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
{/* Phones have no hover: the plugin menu stays visible. */}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
>
<Download className="w-4 h-4 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
{effectiveAttachments.length > 2 && (
<button
onClick={() => setShowAllMobileAttachments(!showAllMobileAttachments)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-0.5"
>
+{effectiveAttachments.length - 2} {t('more')}
</button>
)}
{downloadAllButton}
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
<div className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }}
>
<Download className="w-3.5 h-3.5 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
>
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
</div>
</>
)}
</div>
</div>
)}
</>)}
<div className="grow shrink-0 flex flex-col">
{/* Email Body */}
<div className={cn(
"email-content-wrapper overflow-x-auto relative",
// Folded, the body stays mounted so unfolding does not reload it.
(mvOpenFolded || mvAwaitingConversation) && "hidden",
// Mountain View indents the body under the sender's name, as Gmail does.
mvViewer && "lg:ps-[60px]",
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
)}
style={isDark ? { backgroundColor: '#121212' } : undefined}>
{isBodyLoading ? (
<div
className="space-y-3 px-6 py-4 animate-pulse"
style={{ minHeight: `${lastBodyHeightRef.current}px` }}
>
<div className="h-2 bg-muted/15 rounded w-full"></div>
<div className="h-2 bg-muted/15 rounded w-5/6"></div>
<div className="h-2 bg-muted/15 rounded w-4/6"></div>
<div className="h-2 bg-muted/15 rounded w-full"></div>
<div className="h-2 bg-muted/15 rounded w-3/4"></div>
</div>
) : effectiveEmailContent.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: '100px', colorScheme: emailColorScheme(isDark, emailHasNativeDarkMode) }}
onLoad={handleIframeLoad}
/>
) : (
<div
ref={setPlainBodyEl}
className="email-content-text text-foreground"
dir="auto"
data-plugin-source="email-body"
data-email-id={email?.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)}
dangerouslySetInnerHTML={plainBodyHtml}
style={{
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
fontSize: '14px',
lineHeight: '1.6',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
/>
)}
{!isBodyLoading && (
<ReaderAboveQuoteSlot
root={effectiveEmailContent.isHtml ? (iframeReady ? iframeRef.current?.contentDocument?.body ?? null : null) : plainBodyEl}
frame={effectiveEmailContent.isHtml ? iframeRef.current : null}
extraProps={pluginEmailProps}
version={effectiveEmailContent.html}
/>
)}
</div>
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
{/* Mountain View: the conversation's later messages, folded. */}
{mvAfter.length > 0 && (
<div className="mt-4 border-b border-border" data-mv-conversation-after="">
<MvConversationRun emails={mvAfter} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} />
</div>
)}
{/* Mountain View: a reply being written in place. */}
{mvViewer && mvInlineComposer && (
<div className="px-6 lg:ps-[80px] pt-6 pb-6" data-mv-inline-reply="" ref={(el) => el?.scrollIntoView({ block: 'nearest' })}>
{mvInlineComposer}
</div>
)}
{/* Mountain View: Reply / Reply all / Forward as buttons under the message. */}
{mvReplyRow && !mvAwaitingConversation && !(mvViewer && mvInlineComposer) && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
<div
className={cn("pb-6 pt-2", isMobile ? "grid grid-cols-3 gap-2 px-4" : "flex flex-wrap items-center gap-3 px-6 lg:ps-[80px] pt-6")}
data-mv-reply-row=""
>
<Button variant="outline" size="sm" className={cn("rounded-full gap-2", isMobile ? "min-w-0 px-2 text-xs" : "px-5")} onClick={() => onReply?.()}>
<Reply className="w-4 h-4" />
{t('reply')}
</Button>
<Button variant="outline" size="sm" className={cn("rounded-full gap-2", isMobile ? "min-w-0 px-2 text-xs" : "px-5")} onClick={onReplyAll}>
<ReplyAll className="w-4 h-4" />
{t('reply_all')}
</Button>
<Button variant="outline" size="sm" className={cn("rounded-full gap-2", isMobile ? "min-w-0 px-2 text-xs" : "px-5")} onClick={onForward}>
<Forward className="w-4 h-4" />
{t('forward')}
</Button>
</div>
)}
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
name={activeAccount?.displayName || currentUserName || "You"}
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
size="lg"
className="shadow-sm w-10 h-10"
disableFavicon
fallbackColor={activeAccount?.avatarColor}
/>
</div>
<div className="flex-1 min-w-0 space-y-3">
<textarea
value={quickReplyText}
onChange={(e) => setQuickReplyText(e.target.value)}
onFocus={() => setIsQuickReplyFocused(true)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void handleSendQuickReply();
}
}}
placeholder={t('quick_reply_placeholder')}
className={cn(
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
"hover:border-accent focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-all",
"resize-none"
)}
rows={isQuickReplyFocused || quickReplyText ? 3 : 2}
disabled={isSendingQuickReply}
/>
{/* Action buttons - show when focused or has text */}
{(isQuickReplyFocused || quickReplyText) && (
<div className="flex items-center justify-between gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="text-xs text-muted-foreground">
{t('characters_count', { count: quickReplyText.length })}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setQuickReplyText("");
setIsQuickReplyFocused(false);
}}
disabled={isSendingQuickReply}
>
{tCommon('cancel')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
onReply?.(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
}}
disabled={isSendingQuickReply}
className="text-muted-foreground"
>
<MoreVertical className="w-4 h-4 me-1" />
{t('more_options')}
</Button>
<Button
size="sm"
onClick={handleSendQuickReply}
disabled={!quickReplyText.trim() || isSendingQuickReply}
>
{isSendingQuickReply ? (
<>
<Loader2 className="w-4 h-4 me-1 animate-spin" />
{t('sending')}
</>
) : (
<>
<Reply className="w-4 h-4 me-1" />
{t('send')}
</>
)}
</Button>
</div>
</div>
)}
</div>
</div>
</div>)}
</div>
</div>
</div>
{/* Email Source Modal - pane-scoped inside a Pro pane so it never
covers the neighbouring split pane. */}
{showSourceModal && email && (
<div
className={cn(
"bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4",
isPaneScoped ? "absolute inset-0" : "fixed inset-0",
)}
onClick={() => setShowSourceModal(false)}
>
<div
className="bg-background rounded-lg shadow-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<Code className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">{t('email_source')}</h2>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={copySourceToClipboard}
className="flex items-center gap-1.5"
>
<Copy className="w-4 h-4" />
{t('copy_source')}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSourceModal(false)}
className="h-10 w-10 lg:h-8 lg:w-8"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Modal Content */}
<div className="flex-1 overflow-auto p-4 bg-muted/30">
<pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-words bg-background border border-border rounded-lg p-4">
{generateEmailSource(email)}
</pre>
</div>
</div>
</div>
)}
</div>
{/* Mobile bottom action bar - pane-scoped inside a Pro pane (the
viewport `sm:hidden` guard would otherwise hide it there, and
`fixed` would span the whole app instead of the pane). */}
{isMobile && !mvMobileViewer && (
<nav className={cn(
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
isPaneScoped ? "absolute bottom-0 left-0 right-0" : "fixed bottom-0 left-0 right-0 sm:hidden",
)}>
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.previous')}
>
<ChevronLeft className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
</button>
{isDraft && onEditDraft ? (
<button
onClick={() => onEditDraft()}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
<EditIcon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
</button>
) : (
<>
<button
onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
</button>
<button
onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')}
>
<ReplyAll className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
</button>
<button
onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')}
>
<Forward className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
</button>
</>)}
<button
onClick={onNavigateNext}
disabled={!onNavigateNext}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.next')}
>
<ChevronRight className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
</button>
</div>
</nav>
)}
<ReaderSelectionMenu menu={selectionMenu} />
</>
{sidePanel}
<>
{bodyMention && (
<RecipientCardPopover
anchor={bodyMention.anchor}
email={bodyMention.email}
name={[...(email?.from || []), ...(email?.to || []), ...(email?.cc || []), ...(email?.replyTo || [])]
.find((r) => r.email.toLowerCase() === bodyMention.email.toLowerCase())?.name || undefined}
onViewContact={handleViewContactSidebar}
onClose={() => setBodyMention(null)}
/>
)}
{/* Contact Detail Sidebar - desktop only */}
{contactSidebarEmail && !isMobileDevice && (
<ContactSidebarPanel
email={contactSidebarEmail}
contact={sidebarContact}
senderName={(() => {
const allRecipients = [...(email?.from || []), ...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || []), ...(email?.replyTo || [])];
return allRecipients.find(r => r.email.toLowerCase() === contactSidebarEmail.toLowerCase())?.name;
})()}
onClose={() => setContactSidebarEmail(null)}
onEditContact={sidebarContact ? () => {
router.push(buildContactsPath({ contactId: sidebarContact.id, editing: true }));
setContactSidebarEmail(null);
} : undefined}
onAddToContacts={(addr, name) => {
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
const client = useAuthStore.getState().client;
const contactData: Partial<ContactCard> = {
emails: { email: { address: addr } },
// `full` feeds the mandatory vCard FN — strict CardDAV clients
// (Apple Contacts) drop cards without it (#430).
...(name ? { name: { components: name.includes(' ')
? [{ kind: 'given' as const, value: name.split(' ')[0] }, { kind: 'surname' as const, value: name.split(' ').slice(1).join(' ') }]
: [{ kind: 'given' as const, value: name }],
isOrdered: true, full: name,
}} : {}),
};
if (client && supportsSync) {
createContact(client, contactData).then((created) => { if (created) toast.success('Contact added'); });
} else {
void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard)
.then((created) => { if (created) toast.success('Contact added'); });
}
}}
/>
)}
{inAppPreview && (
<FilePreviewModal
name={inAppPreview.name}
onClose={() => setInAppPreview(null)}
onDownload={downloadInAppPreview}
getFileContent={getInAppPreviewContent}
/>
)}
</>
</div>
</div>
);
}