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