Files
webmail/components/email/email-viewer.tsx
T
Linus Rath cf0eb03c4a feat: show the sender's BIMI logo as the sender icon when the message passes DMARC
Stalwart does no BIMI, so sender icons were only ever favicons, which
anyone controls for any domain. The webmail server now does the
receiver side itself at /api/bimi, and avatars ask it only for
messages whose own Authentication-Results say dmarc=pass for the exact
From domain.

- `lib/bimi/`: DMARC policy check (quarantine or reject, pct=100, the
  parent domain's sp and p for subdomains), the assertion record with
  fallback to the parent domain, mark certificate validation (chain to
  pinned DigiCert, GlobalSign and SSL.com VMC roots, BIMI key purpose,
  validity, SAN, SCTs present, CRL revocation, logo from the logotype
  extension), SVG Tiny PS checks and rendering to PNG, caching
- logos backed only by the DNS record show by default; the admin
  setting "Require a mark certificate" (`BIMI_REQUIRE_CERTIFICATE`)
  limits them to VMC and CMC backed ones
- a check mark beside the sender's name for certificate-backed logos
- avatars try contact photo, BIMI logo, plugin avatar, favicon; the
  sender warning shield still wins, and logos follow the sender icons
  setting and the no-third-party-traffic mode
- the two-label public suffix list moved to `lib/root-domain.ts`

SCT signatures are not verified and only the default selector is used.
2026-10-30 00:12:24 +01:00

6615 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.
This file contains Unicode characters that might be confused with other characters. 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 { attachmentDownloadFilename, attachmentsBundleFilename, 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 { mailLanguage } from "@/lib/mail-language";
import { htmlToReadableText } from "@/lib/html-to-readable-text";
import { pauseAnimatedImages } from "@/lib/animated-images";
import { applyMailTextSize, mailFitOptions } from "@/lib/mail-reading";
import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { attachmentIcon } from "./attachment-chips";
import { ReaderViewDialog } from "./reader-view-dialog";
import { ReadingMask } from "./reading-mask";
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, type FitOptions } 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 } from "@/lib/deep-links";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { Avatar } from "@/components/ui/avatar";
import { ContextMenu } from "@/components/ui/context-menu";
import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu";
import { EmailContextMenu } from "./email-context-menu";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
import { useCopyToAccount, useEmailExport, usePrintRequestStore } from "@/hooks/use-message-file-actions";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import { useComposingSurface } from "@/hooks/use-composing-surface";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } 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 { useAccountTags, useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
import { getEmailTagIds, emailKeyFor } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndicatorDomain } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
import { ViewSourceDialog } from "./view-source-dialog";
import { MoveTargetList } from "./move-target-list";
import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog";
import { usePolicyStore } from "@/stores/policy-store";
import { ScheduleSendDialog } from "./schedule-send-dialog";
import {
Reply,
ReplyAll,
Forward,
Trash2,
Archive,
Star,
MoreVertical,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Download,
Mail,
MailOpen,
Loader2,
Printer,
FileText,
FileArchive,
File,
Eye,
Shield,
Image,
Tag,
X,
Check,
AlertTriangle,
Minus,
ShieldCheck,
ShieldAlert,
Copy,
Brain,
Keyboard,
Phone,
Building,
MapPin,
StickyNote,
PanelRightClose,
PanelRightOpen,
Send,
FolderInput,
Sun,
Upload,
Moon,
PlayCircle,
PenSquare,
CalendarClock,
CalendarX,
Maximize2,
Minimize2,
Type,
Volume2,
BookOpen,
ArrowLeft,
Pencil,
} 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 { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
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, type EmailBlobContext } from "@/stores/email-store";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { ExternalSenderChip } from "./external-sender-chip";
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, useMessageActionTarget, type MessageActionTarget } from "@/components/plugins/plugin-actions";
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { RulesPanel, useRulesAvailability } from "./rules-menu";
import type { QuickRuleTarget } from "@/lib/filters/quick-rule-target";
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";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useSenderWarning } from "@/hooks/use-sender-warning";
import { BrandVerifiedBadge } from "@/components/email/brand-verified-badge";
/** The More menu's two drill-downs: a folder list and a tag list. */
type MoreMenuSub = MessageSubmenuKey;
/** The reader toolbar's overflow items, by priority, as entries of the message's action list. */
const TOOLBAR_PRIORITY_ACTIONS: Record<string, MessageActionKey> = {
'1': 'reply',
'2': 'reply_all',
'3': 'forward',
'4': 'archive',
'5': 'move',
'6': 'tag',
'7': 'spam',
'8': 'read',
'9': 'print',
'11': 'extra:view_mode',
};
const NO_ACTION_KEYS: ReadonlySet<MessageActionKey> = new Set();
/** 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;
/** Resolves false when nothing was sent (the user went back), so the text stays. */
onQuickReply?: (body: string) => Promise<boolean | void>;
/** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
onSaveQuickReplyDraft?: (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;
/** Pin or unpin the message; pinned mail floats to the top of the list. */
onTogglePinned?: () => 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;
/** Stop a scheduled message from going out; it goes back to Drafts. */
onCancelScheduledSend?: () => 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;
}
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">
<RecipientPopover
name={r.name}
email={r.email}
displayLabel={isMe ? t('recipient_me') : undefined}
onViewContact={onViewContact}
className="text-sm"
/>
{index < visible.length - 1 && <span className="text-muted-foreground">,</span>}
</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-hidden"
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')}
>
<Pencil 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>
<IconButton
label={t('copy')}
onClick={() => handleCopy(e.address)}
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 shrink-0 text-muted-foreground"
>
<Copy className="w-3.5 h-3.5" />
</IconButton>
</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>
<IconButton
label={t('copy')}
onClick={() => handleCopy(p.number)}
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 shrink-0 text-muted-foreground"
>
<Copy className="w-3.5 h-3.5" />
</IconButton>
</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 tSidebar = useTranslations('sidebar');
const sender = email.from?.[0];
const senderWarning = useSenderWarning(email);
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" senderWarning={senderWarning} brandDomain={brandIndicatorDomain(email)} />
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span dir="auto" className={cn("text-sm truncate", unread ? "font-bold" : "font-semibold")}>
{sender?.name || sender?.email || ''}
</span>
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[0.6875rem] font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3 h-3" />
{email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')}
</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 tSidebar = useTranslations('sidebar');
const externalContent = useConversationExternalContent();
const sender = email.from?.[0];
const senderWarning = useSenderWarning(email);
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" senderWarning={senderWarning} brandDomain={brandIndicatorDomain(email)} />
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span dir="auto" className="text-sm font-semibold truncate">{sender?.name || sender?.email || ''}</span>
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[0.6875rem] font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3 h-3" />
{email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')}
</span>
)}
{!compact && sender?.name && sender.email && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} className="self-center" />
<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('reply')}
>
<Reply className="w-5 h-5" />
</button>
)}
</div>
{email.isScheduled && (
<div className={cn("border-b border-border bg-primary/10 py-2 flex items-center gap-2 text-primary text-xs font-medium", compact ? "px-4" : "ps-[80px] pe-6")}>
<CalendarClock className="w-3.5 h-3.5 shrink-0" />
<span>
{t('scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })}
</span>
</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">{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[] = [];
interface ReaderMoreMenuProps {
menu: UseContextMenuReturn<null>;
options: MessageActionOptions;
label: string;
mailboxes: Mailbox[];
selectedMailbox: string;
onMoveToMailbox?: (mailboxId: string) => void;
onSetTag?: (tagId: string | null) => void;
}
/** The reader's ⋮ on a desktop: the row's context menu, anchored to the button (R6). */
function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMoveToMailbox, onSetTag }: ReaderMoreMenuProps) {
const { email } = options;
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
return (
<EmailContextMenu
email={email}
position={menu.contextMenu.position}
isOpen
onClose={menu.closeContextMenu}
menuRef={menu.menuRef}
label={label}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={options.currentMailboxRole}
onReply={options.onReply}
onReplyAll={options.onReplyAll}
onForward={options.onForward}
onForwardAsAttachment={options.onForwardAsAttachment}
onEditDraft={options.onEditDraft}
onArchive={options.onArchive}
onDelete={options.onDelete}
onMarkAsRead={options.onMarkAsRead}
onToggleStar={options.onToggleStar}
onTogglePinned={options.onTogglePinned}
onRemoveFromFolder={options.onRemoveFromFolder}
onMarkAsSpam={options.onMarkAsSpam}
onUndoSpam={options.onUndoSpam}
onMoveToMailbox={options.canMove ? onMoveToMailbox : undefined}
onSetTag={onSetTag}
copyTargets={copyTargets}
onCopyToAccount={(accountId, mailboxId) => void copyToAccount([email], accountId, mailboxId)}
onExport={options.onExport}
onPrint={options.onPrint}
onViewSource={options.onViewSource}
omit={options.omit}
extras={options.extras}
rowPlugins={false}
/>
);
}
const PANEL_ROW_CLASS = "w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3 disabled:opacity-50";
interface ReaderMorePanelBodyProps {
options: MessageActionOptions;
sub: MoreMenuSub | null;
openSub: (sub: MoreMenuSub) => void;
close: () => void;
entryRefs: React.MutableRefObject<Record<MoreMenuSub, HTMLButtonElement | null>>;
moveTree: MailboxNode[];
moveTargetIds: Set<string>;
onMoveToMailbox?: (mailboxId: string) => void;
currentTagIds: string[];
sortedTagIds: string[];
/** The message's account when it has shared tags (useEmailTagScope). */
tagScope: TagScope | null;
onToggleTag: (tagId: string) => void;
/** Where filter rules go; null hides Rules (shared account, no Sieve). */
rulesTarget: QuickRuleTarget | null;
pluginContext: Record<string, unknown>;
/** The message the plugin entries are about (useMessageActionTarget). */
pluginTarget: MessageActionTarget;
}
/**
* The phone's More panel: the message's action list (R6) as 44px rows, its
* submenus as sub-views of the panel (#779).
*/
function ReaderMorePanelBody({
options,
sub,
openSub,
close,
entryRefs,
moveTree,
moveTargetIds,
onMoveToMailbox,
currentTagIds,
sortedTagIds,
tagScope,
onToggleTag,
rulesTarget,
pluginContext,
pluginTarget,
}: ReaderMorePanelBodyProps) {
const { email } = options;
const { tagColor } = useKeywordFormat(tagScope);
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
const entries = useMessageActionList({
...options,
canCopyToAccount: copyTargets.length > 0,
rules: !!rulesTarget,
});
if (sub === 'move') {
return moveTree.length > 0 && onMoveToMailbox ? (
<MoveTargetList
variant="panel"
tree={moveTree}
targetIds={moveTargetIds}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); close(); }}
/>
) : null;
}
if (sub === 'copy_to_account') {
return (
<>
{copyTargets.map((target) => {
const { tree, targetIds } = buildMoveTargets(target.mailboxes.filter((m) => !m.isShared && m.role !== 'drafts'), {});
return (
<div key={target.accountId}>
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground truncate" title={target.label}>{target.label}</div>
<MoveTargetList
variant="panel"
tree={tree}
targetIds={targetIds}
onPick={(mailboxId) => { void copyToAccount([email], target.accountId, mailboxId); close(); }}
/>
</div>
);
})}
</>
);
}
if (sub === 'tag') {
return <TagPicker touch selectedIds={currentTagIds} onToggle={onToggleTag} scope={tagScope} />;
}
if (sub === 'rules') {
return rulesTarget ? <RulesPanel email={email} target={rulesTarget} variant="mobile" onDone={close} /> : null;
}
return (
<>
{entries.map((entry, index) => {
if (entry.kind === 'separator') return <div key={`sep-${index}`} className="h-px bg-border my-1" role="separator" />;
const Icon = entry.icon;
if (entry.kind === 'submenu') {
return (
<button
key={entry.key}
ref={(el) => { entryRefs.current[entry.key] = el; }}
role="menuitem"
aria-haspopup="menu"
data-testid={entry.key === 'rules' ? 'viewer-rules' : `panel-${entry.key}`}
onClick={() => openSub(entry.key)}
className={PANEL_ROW_CLASS}
>
<Icon className="w-5 h-5" />
<span className="flex-1">{entry.label}</span>
{entry.key === 'tag' && currentTagIds.length > 0 && (
<span className="flex -space-x-1 me-1" aria-hidden>
{sortedTagIds.slice(0, 3).map((tagId) => (
<span key={tagId} className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)} />
))}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
}
return (
<button
key={entry.key}
role="menuitem"
disabled={entry.disabled}
data-testid={`panel-${entry.key}`}
onClick={() => { entry.run(); close(); }}
className={cn(PANEL_ROW_CLASS, entry.destructive && "text-destructive")}
>
<Icon className="w-5 h-5" />
{entry.label}
</button>
);
})}
<PluginPanelEntries
surface="reader-actions"
context={pluginContext}
target={pluginTarget}
mailboxRole={options.currentMailboxRole ?? null}
onRan={close}
/>
</>
);
}
export function EmailViewer({
email,
blobSource,
isLoading = false,
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onDelete,
onArchive,
onToggleStar,
onMarkAsRead,
onSetTag,
onDownloadAttachment,
onQuickReply,
onSaveQuickReplyDraft,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
onRemoveFromFolder,
onTogglePinned,
onBack,
onNavigateNext,
onNavigatePrev,
mvPosition,
mvFolderName,
mvConversation,
mvConversationPending,
onLoadConversationEmail,
onMarkConversationEmailRead,
onReplyConversationEmail,
mvInlineComposer,
onShowShortcuts,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
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 tContextMenu = useTranslations('context_menu');
const tWelcome = useTranslations('welcome');
const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled);
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
// No third-party traffic (#903): remote content never loads, whatever the
// policy or trust says, and nothing offers to load it.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const tThirdParty = useTranslations('third_party');
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);
// A message in a group account is tagged with that account's shared tags.
const tagScope = useEmailTagScope(email);
const emailKeywords = useAccountTags(tagScope).definitions;
const { sortTagIds } = useKeywordFormat(tagScope);
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 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 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 phone's bottom action bar: toasts sit above it.
const bottomBarRef = useBottomControls(isMobile && !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 simpleMode = useSettingsStore((state) => state.simpleMode);
// Simple mode always names the toolbar's buttons in words.
const showToolbarLabels = (showToolbarLabelsSetting || simpleMode) && 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;
const filesFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
// The blob must be reachable from the Files account's session: the
// login's own mail, or a shared or group account of the same login, whose
// blob is copied over (Blob/copy). Another login's mail can't be.
const canSaveToFiles = filesFeatureEnabled && !!client && blobClient === client && !!client.supportsFiles?.();
// 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(tNotifications('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;
}
};
// "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);
// The quick reply's Send button shows once it is focused or has text.
useComposingSurface(quickReplyEnabled && (isQuickReplyFocused || quickReplyText.length > 0));
// An automatic sign-out keeps typed quick-reply text as a draft; text on
// its way out as a reply needs none. A reply still waiting on the
// "Send outside your organisation?" question hasn't gone out: the question
// is answered with Back and the text kept as a draft.
const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft });
quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft };
const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft;
useEffect(() => {
if (!hasQuickReplyDraft) return;
return registerComposeFlush(async () => {
const { text, sending, save } = quickReplyDraftRef.current;
if (!text.trim() || !save) return;
if (sending) {
const confirm = useExternalRecipientConfirmStore.getState();
if (!confirm.pending) return;
confirm.cancelAll();
}
await save(text);
});
}, [hasQuickReplyDraft]);
const handleSendQuickReply = async () => {
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
try {
const sent = await onQuickReply(quickReplyText);
// Back on a pre-send question keeps what the user typed.
if (sent !== false) {
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);
// "Save to Files" for an attachment: where the Files app is on and the
// message is in the login's own account, whose files hold the blob too.
const [saveToFilesTarget, setSaveToFilesTarget] = useState<SaveableAttachment | null>(null);
// Reschedule picks the new time in the composer's Schedule send dialog.
const [rescheduleOpen, setRescheduleOpen] = 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);
// The toolbar's Move and Tag menus and, on a desktop, the ⋮ menu: the
// ContextMenu, as every menu (R6). The phone keeps its More panel.
const moveMenu = useContextMenu<null>();
const tagMenu = useContextMenu<null>();
const readerMenu = useContextMenu<null>();
const moveMenuOpen = moveMenu.contextMenu.isOpen;
const tagMenuOpen = tagMenu.contextMenu.isOpen;
// What the toolbar shows when the ⋮ opens; the menu leaves those out.
const [readerMenuOmit, setReaderMenuOmit] = useState<ReadonlySet<MessageActionKey>>(NO_ACTION_KEYS);
const moreMenuRef = useRef<HTMLDivElement>(null);
const moreButtonRef = 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, copy_to_account: 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); }, []);
// The phone panel renders away from its trigger (an off-canvas panel), so
// focus has to be driven explicitly (#720).
const { menuRef: mobileMoreRef, onKeyDown: onMobileMoreKeyDown } = useMenuNavigation<HTMLDivElement>({
open: moreMenuOpen && isMobile,
onClose: closeMoreMenu,
triggerRef: moreButtonRef,
});
// 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 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 readerActionTarget = useMessageActionTarget(email);
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],
);
// Close dropdown menus on click outside
useEffect(() => {
if (!moreMenuOpen) 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);
}
}
// 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);
}
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('blur', handleWindowBlur);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('blur', handleWindowBlur);
};
}, [moreMenuOpen, mobileMoreRef]);
// Close dropdowns when email changes
const closeMoveMenu = moveMenu.closeContextMenu;
const closeTagMenu = tagMenu.closeContextMenu;
const closeReaderMenu = readerMenu.closeContextMenu;
useEffect(() => {
setMoreMenuOpen(false);
closeTagMenu();
closeMoveMenu();
closeReaderMenu();
}, [email?.id, closeTagMenu, closeMoveMenu, closeReaderMenu]);
// 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);
setPlainViewOverride(null);
setReaderViewOpen(false);
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]);
// 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;
if (remoteBlockedByOrg) return true;
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, remoteBlockedByOrg]);
// 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 decodedEmailContent = 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]);
// HTML mail as plain text: Settings > Accessibility for every message, or
// the reader's per-message switch. The message's own text/plain part when
// it has one, otherwise a readable conversion that keeps link targets and
// image alt text.
const mailViewMode = useSettingsStore((state) => state.mailViewMode);
const [plainViewOverride, setPlainViewOverride] = useState<boolean | null>(null);
const plainView = plainViewOverride ?? mailViewMode === 'plain';
const hasHtmlBody = decodedEmailContent.isHtml;
// The decoded body as plain text: the text part, else the HTML converted.
const readableText = useCallback(() => {
let text: string | null = null;
if (decodedEmailContent === emailContent && email) {
const textPart = email.textBody?.[0];
if (textPart?.partId && textPart.partId !== email.htmlBody?.[0]?.partId && (textPart.type || 'text/plain').toLowerCase() === 'text/plain') {
text = email.bodyValues?.[textPart.partId]?.value ?? null;
}
}
return text?.trim() ? text : htmlToReadableText(decodedEmailContent.html);
}, [decodedEmailContent, emailContent, email]);
const effectiveEmailContent = useMemo(() => {
if (!plainView || !decodedEmailContent.isHtml) return decodedEmailContent;
return {
html: collapsePlainTextQuotes(plainTextToSafeHtml(readableText()), { show: t('show_quoted_text'), hide: t('hide_quoted_text') }),
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}, [plainView, decodedEmailContent, readableText, t]);
// Reader view: the message full screen in the app's own look.
const [readerViewOpen, setReaderViewOpen] = useState(false);
// Reading mask: dims the body except a band that follows pointer and caret.
const readingMaskOn = useSettingsStore((state) => state.readingMask);
const bodyWrapRef = useRef<HTMLDivElement>(null);
const readerViewHtml = useMemo(() => {
if (!readerViewOpen) return '';
return decodedEmailContent.isHtml ? plainTextToSafeHtml(readableText()) : decodedEmailContent.html;
}, [readerViewOpen, decodedEmailContent, readableText]);
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) {
toast.error(tFiles('download_error'));
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);
const omitted = effectiveAttachments.length - added;
if (omitted > 0) toast.warning(t('download_all_partial', { count: omitted }));
} catch (error) {
console.error('Failed to download attachments:', error);
toast.error(tFiles('download_error'));
} finally {
setIsDownloadingAll(false);
}
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]);
// 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.
// Settings > Accessibility > "Fit wide mail" does it on any screen.
const fitBodyToWidthRef = useRef<FitOptions>({ enabled: 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);
// Text size, minimum size and the other reading settings for the body. A
// ref for the iframe setup, which runs from observers wired once per
// document; the srcDoc changes with frameKey, so a new setting rebuilds it.
const reading = useMailReading();
const readingRef = useRef(reading);
readingRef.current = reading;
fitBodyToWidthRef.current = mailFitOptions(isMobile, reading.fitWidth);
const [emailViewDarkOverride, setEmailViewDarkOverride] = useState<boolean | null>(null);
const isDark = emailViewDarkOverride !== null
? emailViewDarkOverride
: (emailAlwaysLightMode ? false : resolvedTheme === 'dark');
// Our dark recolouring stays out of a forced-colours mode (Windows contrast
// themes): the browser maps the mail to the user's colours itself, and
// inline colours written by our pass would only fight it. With the mail
// shown in the app's own colours there is nothing left to recolour either.
const recolorDark = isDark && !reading.forcedColors && !reading.overrideStyles;
const appFontSources = reading.fontSources.join(' ');
// The body's language (Content-Language or the HTML's own lang), so a
// screen reader switches voice for it; none when the mail doesn't say.
const bodyLanguage = useMemo(() => mailLanguage(email, email ? getRenderableHtmlBody(email) : null), [email]);
const frameSender = email?.from?.[0];
const frameTitle = email?.subject
? t('frame_title', { subject: email.subject })
: t('frame_title_from', { sender: frameSender?.name || frameSender?.email || t('unknown_sender') });
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 = recolorDark ? emailDarkModeCss() : '';
const colorScheme = emailColorScheme(recolorDark, 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, appFontSources ? appFontSources.split(' ') : []);
return `<!DOCTYPE html>
<html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} data-reading="${reading.frameKey}" class="${recolorDark ? 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,
and the trailing <style> repeats it: a mail's own body { overflow-y: scroll }
would otherwise put a second scrollbar inside the frame, next to the
reader's. 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: 0.875rem; 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, recolorDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}body{overflow-y:hidden!important}</style>${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources]);
// 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);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
const getReadAloudRoot = useCallback(
() => (effectiveEmailContent.isHtml ? iframeRef.current?.contentDocument?.body ?? null : plainBodyEl),
[effectiveEmailContent.isHtml, plainBodyEl],
);
const readAloud = useReadAloud({ getRoot: getReadAloudRoot, lang: bodyLanguage, resetKey: effectiveEmailContent.isHtml ? emailIframeSrcDoc : effectiveEmailContent.html });
// 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'),
});
// Text size and minimum size (Settings > Accessibility), before the
// height wiring so the first measurement is already at the new size.
// With the app's styles in charge, sizes come from that stylesheet
// and the pass only takes out justified text.
applyMailTextSize(doc, {
factor: readingRef.current.textFactor,
minPx: readingRef.current.minFontSize,
unjustify: readingRef.current.overrideStyles,
sizesFromCss: readingRef.current.overrideStyles,
});
// Animated images as a still frame with a Play button. Before the
// link wiring below: its click listener has to see the button first.
if (readingRef.current.pauseImages) {
pauseAnimatedImages(doc, { play: t('play_animation'), pause: t('pause_animation') });
}
// 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 (recolorDark) {
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, 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
}
}, [recolorDark, 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; the row's menu exports the same way.
const exportEmail = useEmailExport();
const handleExportEmail = async () => {
if (!email) return;
// With the Archive action off a stored 'archive' keeps the message.
await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined });
};
// 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. A print asked for from
// the list's row menu brings its own window, opened in that click.
const handlePrint = (openedWindow?: Window) => {
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 = openedWindow ?? 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>${escapeHtml(t('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();
};
// Print from the list's row menu: the window waits for this message's body.
const printRequest = usePrintRequestStore((state) => state.request);
const printReady = !!email && !isLoading && !!email.bodyValues && printRequest?.key === emailKeyFor(email);
const handlePrintRef = useRef(handlePrint);
handlePrintRef.current = handlePrint;
useEffect(() => {
if (!printReady || !printRequest) return;
usePrintRequestStore.getState().setRequest(null);
if (!printRequest.win.closed) handlePrintRef.current(printRequest.win);
}, [printReady, printRequest]);
// 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
pane="sidePanel"
value={detailSidebarWidth}
min={200}
max={500}
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"
/>
<h2 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h2>
<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 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>
<h2 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h2>
<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"];
// What the toolbar shows right now: the ⋮ menu and the phone panel leave
// those out. Delete and Edit draft are always on the toolbar; on a phone
// the bottom bar has the replies.
const toolbarShownActions = (): ReadonlySet<MessageActionKey> => {
const shown = new Set<MessageActionKey>(['delete', 'edit_draft']);
toolbarRef.current?.querySelectorAll<HTMLElement>('[data-overflow-item]').forEach((item) => {
const key = TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? ''];
if (key && item.getClientRects().length > 0) shown.add(key);
});
if (isMobile && !mvMobileViewer) {
shown.add('reply');
shown.add('reply_all');
shown.add('forward');
}
return shown;
};
const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev);
// The reader's own entries after the message's: the view toggle, Import
// (into the open folder) and, with a keyboard, the shortcuts.
const readerExtras: MessageActionEntry[] = [
...(readAloud.supported && !isBodyLoading
? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }]
: []),
...(!isBodyLoading
? [{ kind: 'item' as const, key: 'extra:reader_view' as const, icon: BookOpen, label: t('reader_view'), run: () => { readAloud.stop(); setReaderViewOpen(true); } }]
: []),
...(hasHtmlBody
? [{ kind: 'item' as const, key: 'extra:plain_text' as const, icon: plainView ? Type : FileText, label: plainView ? t('show_formatted') : t('show_as_plain_text'), run: () => setPlainViewOverride(!plainView) }]
: []),
...(effectiveEmailContent.isHtml
? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }]
: []),
{ kind: 'item', key: 'extra:import', icon: Upload, label: t('import_email'), run: handleImportEmail },
...(onShowShortcuts && !isMobile
? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }]
: []),
];
const readerActionOptions: MessageActionOptions = {
email,
currentMailboxRole,
canMove: moveTree.length > 0 && !!onMoveToMailbox,
canRemoveFromFolder: !!onRemoveFromFolder,
onReply: onReply ? () => onReply() : undefined,
onReplyAll,
onForward,
onForwardAsAttachment,
onEditDraft,
onArchive,
onDelete,
onMarkAsRead: (read) => onMarkAsRead?.(email.id, read),
onToggleStar,
onTogglePinned,
onRemoveFromFolder,
onMarkAsSpam,
onUndoSpam,
onExport: () => void handleExportEmail(),
onPrint: () => handlePrint(),
onViewSource: () => setShowSourceModal(true),
omit: readerMenuOmit,
extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)),
};
// 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')}
>
<ArrowLeft 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 whitespace-nowrap">{t('send_now')}</span>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setRescheduleOpen(true);
}}
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 whitespace-nowrap">{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')}>
<Pencil className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
</Button>
{onCancelScheduledSend && !email.isSmimeScheduled && (
<Button variant="ghost" size="sm" onClick={onCancelScheduledSend} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={t('cancel_scheduled_send')} data-testid="cancel-scheduled-send">
<CalendarX className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{t('cancel_scheduled_send')}</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')}
>
<Pencil 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 whitespace-nowrap">{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 whitespace-nowrap">{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 whitespace-nowrap">{t('forward')}</span>}
</Button>
</>)}
<PluginSlot name="toolbar-actions" />
{/* Phones get these in the More panel. */}
{!isMobile && email && (
<PluginActions
surface="reader-actions"
context={readerActionContext}
target={readerActionTarget}
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-[0.625rem] 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-[0.625rem] leading-tight sm:text-sm">{t('delete')}</span>}
</Button>
{/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && (
<div data-overflow-item data-overflow-priority="5" className={cn("relative", mvViewer && "order-6")}>
<Button
variant="ghost"
size="sm"
onMouseDown={(e) => { if (moveMenuOpen) e.stopPropagation(); }}
onClick={(e) => {
if (moveMenuOpen) closeMoveMenu();
else { setMoreMenuOpen(false); moveMenu.openContextMenuFrom(e.currentTarget, null, 'end'); }
}}
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-[0.625rem] leading-tight sm:text-sm">{t('move')}</span>}
</Button>
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
<div className="w-56 max-h-72 overflow-y-auto">
<MoveTargetList
tree={moveTree}
targetIds={moveTargetIds}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); closeMoveMenu(); }}
/>
</div>
</ContextMenu>
</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 className="relative">
<Button
variant="ghost"
size="sm"
onMouseDown={(e) => { if (tagMenuOpen) e.stopPropagation(); }}
onClick={(e) => {
if (tagMenuOpen) closeTagMenu();
else { setMoreMenuOpen(false); tagMenu.openContextMenuFrom(e.currentTarget, null, 'end'); }
}}
className="h-8 gap-1.5 px-2"
title={t('set_tag')}
aria-label={t('set_tag')}
aria-haspopup="menu"
aria-expanded={tagMenuOpen}
>
<Tag className="w-4 h-4" />
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:text-sm">{t('tag')}</span>}
</Button>
<ContextMenu ref={tagMenu.menuRef} isOpen={tagMenuOpen} position={tagMenu.contextMenu.position} onClose={closeTagMenu} label={t('tag')}>
<div className="w-56">
<TagPicker
selectedIds={currentTagIds}
scope={tagScope}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
</ContextMenu>
</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-emerald-50 dark:hover:bg-emerald-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-emerald-600 dark:text-emerald-400")} />
) : (
<ShieldAlert className={cn("h-4 w-4", !mvViewer && "text-red-600 dark:text-red-400")} />
)}
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:text-sm">{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}</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-[0.625rem] 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 whitespace-nowrap">{t('print')}</span>}
</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: the message's action list (R6). On a desktop the ContextMenu,
on a phone the More panel. */}
<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={isMobile ? moreMenuOpen : readerMenu.contextMenu.isOpen}
aria-controls={isMobile && moreMenuOpen ? moreMenuId : undefined}
onMouseDown={(e) => { if (!isMobile && readerMenu.contextMenu.isOpen) e.stopPropagation(); }}
onClick={(e) => {
closeTagMenu();
closeMoveMenu();
if (isMobile) {
if (!moreMenuOpen) setReaderMenuOmit(toolbarShownActions());
setMoreMenuOpen(!moreMenuOpen);
setMoreMenuSlideEnabled(true);
setMoreMenuSub(null);
return;
}
if (readerMenu.contextMenu.isOpen) {
closeReaderMenu();
return;
}
setReaderMenuOmit(toolbarShownActions());
readerMenu.openContextMenuFrom(e.currentTarget, null, 'end');
}}
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
{interfaceLayout !== 'mountain-view' && <span className="text-[0.625rem] leading-tight sm:hidden">{t('more_actions')}</span>}
</Button>
{readerMenu.contextMenu.isOpen && !isMobile && (
<ReaderMoreMenu
menu={readerMenu}
options={readerActionOptions}
label={t('more_actions')}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMoveToMailbox={onMoveToMailbox}
onSetTag={onSetTag ? (tagId) => onSetTag(email.id, tagId) : undefined}
/>
)}
</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 end-0 w-72" : "fixed inset-y-0 end-0 w-72 sm:hidden",
"transform",
moreMenuSlideEnabled && "transition-transform duration-300 ease-in-out",
"flex flex-col",
moreMenuOpen ? "translate-x-0" : "translate-x-full rtl:-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"
>
<ArrowLeft 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>
)}
<IconButton
size="icon-lg"
role="menuitem"
label={tCommon('close')}
data-testid="more-panel-close"
onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="-me-2"
>
<X className="w-5 h-5" />
</IconButton>
</div>
<div ref={mobileSubListRef} className="flex-1 overflow-y-auto py-2">
{/* Mounted from the first open on, so it doesn't cost a message
open and the panel isn't empty while it slides out. */}
{moreMenuSlideEnabled && (
<ReaderMorePanelBody
options={readerActionOptions}
sub={moreMenuSub}
openSub={setMoreMenuSub}
close={closeMoreMenu}
entryRefs={moreEntryRefs}
moveTree={moveTree}
moveTargetIds={moveTargetIds}
onMoveToMailbox={onMoveToMailbox}
currentTagIds={currentTagIds}
sortedTagIds={sortedTagIds}
tagScope={tagScope}
onToggleTag={(tagId) => onSetTag?.(email.id, tagId)}
rulesTarget={rulesAvailability === 'available' ? rulesTarget ?? null : null}
pluginContext={readerActionContext}
pluginTarget={readerActionTarget}
/>
)}
</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')}
>
<ArrowLeft className="w-5 h-5" />
</Button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
{/* h2 under the app's h1 ("Inbox – Mail"); the reader's
blocks below are h3 and h4, so no level is skipped. */}
<h2 dir="auto" className={cn(
"text-foreground break-words min-w-0",
mvViewer ? "text-[1.375rem] leading-7 font-normal" : "text-lg lg:text-xl font-bold tracking-tight"
)}>
{email.subject || t('no_subject')}
</h2>
{/* 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 && (
<IconButton
onClick={onToggleStar}
label={isStarred ? t('unstar') : t('star')}
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
aria-pressed={!!isStarred}
data-testid="subject-star"
className="flex-shrink-0 -my-1"
>
<Star className={cn(
"w-4 h-4 lg:w-5 lg:h-5 transition-colors",
isStarred ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40"
)} />
</IconButton>
)}
{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}
scope={tagScope}
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">
<bdi>{formatFileSize(email.size)}</bdi>
</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"
senderWarning={senderVerification ? senderVerificationLabel : undefined}
brandDomain={brandIndicatorDomain(email)}
/>
</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>
)}
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<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('unstar') : t('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('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-primary hover:underline text-sm"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
</>
)}
{email.cc && email.cc.length > 0 && (
<>
<span className="text-muted-foreground">|</span>
<span>{t('cc')}:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-primary hover:underline text-sm"
>
{t('more_count', { count: email.cc.length - 2 })}
</button>
)}
</>
)}
{email.bcc && email.bcc.length > 0 && (
<>
<span className="text-muted-foreground">|</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 { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name);
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={cn("w-3.5 h-3.5 flex-shrink-0", fileIconClass)} />
<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-[0.625rem] text-muted-foreground flex-shrink-0">
<bdi>{formatFileSize(attachment.size)}</bdi>
</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",
)}>
<IconButton
label={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
>
<Download />
</IconButton>
{opensPreview && (
<IconButton
label={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
>
<Eye />
</IconButton>
)}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</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 { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name);
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={cn("w-3.5 h-3.5 flex-shrink-0", fileIconClass)} />
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[0.625rem] text-muted-foreground ms-auto flex-shrink-0">
<bdi>{formatFileSize(attachment.size)}</bdi>
</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">
<IconButton
label={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllBesideAttachments(false); }}
>
<Download />
</IconButton>
{opensPreview && (
<IconButton
label={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
>
<Eye />
</IconButton>
)}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</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"
senderWarning={senderVerification ? senderVerificationLabel : undefined}
brandDomain={brandIndicatorDomain(email)}
/>
</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>
)}
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<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">|</span>
<span>{t('cc')}:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-primary hover:underline text-sm"
>
{t('more_count', { count: email.cc.length - 2 })}
</button>
)}
</>
)}
<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">
<bdi>{formatFileSize(email.size)}</bdi>
</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-[0.6875rem] font-semibold text-muted-foreground 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-[0.6875rem]", status.color)}>
{translateAuthResult(result)}
</span>
{extra && (
<>
<span className="text-muted-foreground">·</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"
/>
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
{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-[0.6875rem]",
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">·</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')}>
<bdi>{formatFileSize(email.size)}</bdi>
{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>
{/* The toolbar has these from sm up; on the phone it has no room. */}
<div className="flex flex-wrap items-center gap-2 sm:hidden">
{canCancelScheduled && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setRescheduleOpen(true);
}}
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">
<Pencil className="w-3.5 h-3.5" />
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
{onCancelScheduledSend && !email.isSmimeScheduled && (
<Button size="sm" variant="outline" onClick={onCancelScheduledSend} className="gap-1.5">
<CalendarX className="w-3.5 h-3.5" />
{t('cancel_scheduled_send')}
</Button>
)}
</>
)}
</div>
</div>
</div>
)}
{/* Draft Banner: a scheduled message has its own. */}
{isDraft && !isScheduled && (
<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>
</div>
</div>
)}
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{(senderVerification ||
(!plainView && hasBlockedContent && (remoteBlockedByOrg || (!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-[0.6875rem] font-semibold 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 */}
{!plainView && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
<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-[0.6875rem] font-semibold text-muted-foreground">
External Content
</div>
<div className="text-sm font-medium text-foreground break-words">
{remoteBlockedByOrg ? tThirdParty('remote_content_blocked') : t('external_content_warning')}
</div>
</div>
{!remoteBlockedByOrg && (
<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-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
>
{effectiveAttachments.map((attachment) => {
const { icon: FileIcon } = attachmentIcon(attachment.type, attachment.name);
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">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
</div>
);
})}
</div>
{effectiveAttachments
.slice(0, visibleBelowHeaderCount ?? effectiveAttachments.length)
.map((attachment) => {
const { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name);
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={cn("w-4 h-4 flex-shrink-0", fileIconClass)} />
<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">
<bdi>{formatFileSize(attachment.size)}</bdi>
</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')}
aria-label={t('download_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
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')}
aria-label={t('preview_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<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 { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name);
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={cn("w-4 h-4 flex-shrink-0", fileIconClass)} />
<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">
<bdi>{formatFileSize(attachment.size)}</bdi>
</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')}
aria-label={t('download_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
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')}
aria-label={t('preview_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<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 { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name);
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={cn("w-4 h-4 flex-shrink-0", fileIconClass)} />
<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">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
{/* Phones have no hover: the plugin menu stays visible. */}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<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')}
aria-label={t('download_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
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')}
aria-label={t('preview_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
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 { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name);
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={cn("w-3.5 h-3.5 flex-shrink-0", fileIconClass)} />
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[0.625rem] text-muted-foreground ms-auto flex-shrink-0">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<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">
<IconButton
label={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }}
>
<Download />
</IconButton>
{opensPreview && (
<IconButton
label={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
>
<Eye />
</IconButton>
)}
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
</div>
</>
)}
</div>
</div>
)}
</>)}
<div className="grow shrink-0 flex flex-col">
{/* Email Body */}
<div ref={bodyWrapRef} 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}>
<ReadAloudBar state={readAloud.state} onPause={readAloud.pause} onResume={readAloud.resume} onStop={readAloud.stop} />
{readingMaskOn && !isBodyLoading && (
<ReadingMask
containerRef={bodyWrapRef}
frame={effectiveEmailContent.isHtml && iframeReady ? iframeRef.current : null}
bandHeight={Math.round(14 * reading.textFactor * 1.7 * 3)}
/>
)}
{readerViewOpen && (
<ReaderViewDialog
isOpen
onClose={() => setReaderViewOpen(false)}
title={email?.subject || t('no_subject')}
description={frameSender ? (frameSender.name ? `${frameSender.name} <${frameSender.email}>` : frameSender.email) : undefined}
html={readerViewHtml}
lang={bodyLanguage}
/>
)}
{!isBodyLoading && plainView && hasHtmlBody && (
<div className="flex flex-wrap items-center gap-x-2 px-6 pt-3 text-sm text-muted-foreground" data-testid="plain-view-note">
<span>{t('shown_as_plain_text')}</span>
<button type="button" className="text-primary underline underline-offset-2" onClick={() => setPlainViewOverride(false)}>
{t('show_formatted')}
</button>
</div>
)}
{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={frameTitle}
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '100px', colorScheme: emailColorScheme(recolorDark, emailHasNativeDarkMode) }}
onLoad={handleIframeLoad}
/>
) : (
<div
ref={setPlainBodyEl}
className="email-content-text text-foreground"
dir="auto"
lang={bodyLanguage ?? undefined}
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: reading.plainFontSize(14),
...(reading.comfortableWidth && { maxWidth: '80ch' }),
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-hidden 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>
{/* Mounted only while open: the reader renders on every message it opens. */}
{isScheduled && rescheduleOpen && (
<ScheduleSendDialog
isOpen={rescheduleOpen}
onClose={() => setRescheduleOpen(false)}
title={t('reschedule_send')}
supported={!!client?.hasDelayedSend()}
maxDelayedSend={client?.getMaxDelayedSend() ?? 0}
initialDate={email?.scheduledSendAt ? new Date(email.scheduledSendAt) : undefined}
onSchedule={(delayedUntil) => {
setRescheduleOpen(false);
void rescheduleScheduled(delayedUntil);
}}
/>
)}
{canSaveToFiles && client && saveToFilesTarget && (
<SaveToFilesDialog attachment={saveToFilesTarget} client={client} blobAccountId={blobAccountId} onClose={() => setSaveToFilesTarget(null)} />
)}
{/* View source: the raw message the server holds. */}
{showSourceModal && email && <ViewSourceDialog
isOpen={showSourceModal && !!email}
onClose={() => setShowSourceModal(false)}
blobId={email?.blobId}
client={blobSource ? blobSource.client : client}
accountId={blobSource?.accountId}
onDownload={() => void handleExportEmail()}
/>}
</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 ref={bottomBarRef} className={cn(
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
isPaneScoped ? "absolute bottom-0 inset-x-0" : "fixed bottom-0 inset-x-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-[0.625rem] 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')}
>
<Pencil className="w-5 h-5" />
<span className="text-[0.625rem] 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"
>
<Reply className="w-5 h-5" />
<span className="text-[0.625rem] 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"
>
<ReplyAll className="w-5 h-5" />
<span className="text-[0.625rem] 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"
>
<Forward className="w-5 h-5" />
<span className="text-[0.625rem] 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-[0.625rem] 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(t('contact_added')); });
} else {
void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard)
.then((created) => { if (created) toast.success(t('contact_added')); });
}
}}
/>
)}
{inAppPreview && (
<FilePreviewModal
name={inAppPreview.name}
onClose={() => setInAppPreview(null)}
onDownload={downloadInAppPreview}
getFileContent={getInAppPreviewContent}
/>
)}
</>
</div>
</div>
);
}