"use client"; import { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; import { MessageListTabs } from "@/components/email/message-list-tabs"; import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; import { useAccountStore } from "@/stores/account-store"; import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useContactStore } from "@/stores/contact-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore } from "@/stores/ui-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { usePromptDialog } from "@/hooks/use-prompt-dialog"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge"; import { cn } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import { ErrorBoundary, SidebarErrorFallback, EmailListErrorFallback, EmailViewerErrorFallback, ComposerErrorFallback, } from "@/components/error"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { PromptDialog } from "@/components/ui/prompt-dialog"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { DragDropProvider } from "@/contexts/drag-drop-context"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIdentitySync } from "@/hooks/use-identity-sync"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useProTabStore } from "@/stores/pro-tab-store"; import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes"; import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { AiAskButton } from "@/components/ai/ai-ask-button"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useThemeStore } from "@/stores/theme-store"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { getEffectiveLocale } from '@/i18n/detect-locale'; import type { QuoteHeader } from "@/lib/plugin-types"; const SCHEDULED_MAILBOX_ID = '__scheduled__'; export default function Home() { const t = useTranslations(); const tCommon = useTranslations('common'); const tQuote = useTranslations('quote_header'); const { appName } = useConfig(); const mailLayout = useSettingsStore((state) => state.mailLayout); const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); // Plugin-resolved quote header for the next reply/forward composer open. // Cleared on close so a subsequent "compose new" doesn't reuse stale state. const [composerQuoteHeader, setComposerQuoteHeader] = useState(null); const suppressComposerStateSaveSessionRef = useRef(null); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showAdvancedFields, setShowAdvancedFields] = useState(false); // Column resize state (disable transitions during drag) const [isResizing, setIsResizing] = useState(false); const dragStartWidth = useRef(0); // Mobile conversation view state const [conversationThread, setConversationThread] = useState(null); const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null); const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); const lastUndoToastSubmissionRef = useRef(null); const initialMailLoadClientRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); const multiAccountIdentities = useProMultiAccountIdentities(); useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(); const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('email_viewer.reschedule_prompt')); if (!value) return null; const time = new Date(value).getTime(); if (!Number.isFinite(time)) { toast.error(t('email_composer.schedule_send_invalid')); return null; } if (time <= Date.now()) { toast.error(t('email_composer.schedule_send_future')); return null; } if (!client?.hasDelayedSend()) { toast.error(t('email_composer.schedule_send_unsupported')); return null; } const maxDelayedSend = client.getMaxDelayedSend(); if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { toast.error(t('email_composer.schedule_send_too_late')); return null; } return new Date(time).toISOString(); }, [client, t]); // Load trusted senders address book when feature is enabled useEffect(() => { if (trustedSendersAddressBook && client && !trustedSendersLoaded) { loadTrustedSendersBook(client); } }, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]); useEffect(() => { if (!isRateLimited || !rateLimitUntil) { setRateLimitSecondsLeft(null); return; } const updateCountdown = () => { const seconds = Math.max(1, Math.ceil((rateLimitUntil - Date.now()) / 1000)); setRateLimitSecondsLeft(seconds); }; updateCountdown(); const timer = setInterval(updateCountdown, 1000); return () => clearInterval(timer); }, [isRateLimited, rateLimitUntil]); // Plugin hooks: window-level lifecycle + selection + service-worker messages. // One effect because the listeners share a registration / cleanup window. useEffect(() => { if (typeof window === 'undefined') return; const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); }; const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); }; const onOnline = () => { appLifecycleHooks.onOnline.emit(); }; const onOffline = () => { appLifecycleHooks.onOffline.emit(); }; let selectionTimer: ReturnType | null = null; const onSelectionChange = () => { if (selectionTimer) clearTimeout(selectionTimer); selectionTimer = setTimeout(() => { const sel = document.getSelection(); const text = sel?.toString() ?? ''; if (!text) return; const anchorNode = sel?.anchorNode as Node | null; const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE ? anchorNode as Element : anchorNode?.parentElement) ?? null; let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other'; let emailId: string | undefined; if (anchorEl) { if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) { source = 'email-body'; const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null; emailId = idEl?.dataset.emailId; } else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) { source = 'composer'; } else if (anchorEl.closest('[data-plugin-source="task-detail"]')) { source = 'task-detail'; } else if (anchorEl.closest('[data-plugin-source="event-detail"]')) { source = 'event-detail'; } } uiHooks.onTextSelectionChange.emit({ text, source, emailId }); }, 150); }; const onSwMessage = (e: MessageEvent) => { const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null; if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') { toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data }); } }; window.addEventListener('focus', onFocus); window.addEventListener('blur', onBlur); window.addEventListener('online', onOnline); window.addEventListener('offline', onOffline); document.addEventListener('selectionchange', onSelectionChange); if (typeof navigator !== 'undefined' && navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', onSwMessage); } return () => { window.removeEventListener('focus', onFocus); window.removeEventListener('blur', onBlur); window.removeEventListener('online', onOnline); window.removeEventListener('offline', onOffline); document.removeEventListener('selectionchange', onSelectionChange); if (selectionTimer) clearTimeout(selectionTimer); if (typeof navigator !== 'undefined' && navigator.serviceWorker) { navigator.serviceWorker.removeEventListener('message', onSwMessage); } }; }, []); // Plugin hooks: route navigation. Tracks Next.js pathname transitions. const pathname = usePathname(); const prevPathnameRef = useRef(null); useEffect(() => { if (!pathname) return; const from = prevPathnameRef.current; if (from === pathname) return; if (from !== null) { routerHooks.onRouteLeave.emit({ path: from }); routerHooks.onNavigate.emit({ path: pathname, from }); } routerHooks.onRouteEnter.emit({ path: pathname }); prevPathnameRef.current = pathname; }, [pathname]); // Mobile/tablet responsive hooks const { isMobile, isTablet } = useDeviceDetection(); const isEmbedded = useIsEmbedded(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(); const { emails, mailboxes, selectedEmail, selectedMailbox, quota, isPushConnected, newEmailNotification, selectEmail, selectMailbox, selectedEmailIds, selectAllEmails, clearSelection, toggleEmailSelection, fetchMailboxes, fetchEmails, fetchQuota, sendEmail, deleteEmail, markAsRead, toggleStar, setEmailKeywordsLocal, moveToMailbox, moveToMailboxCrossAware, moveThreadToMailbox, searchEmails, searchQuery, setSearchQuery, isLoading, isLoadingEmail, setLoadingEmail, setPushConnected, handleStateChange, clearNewEmailNotification, markAsSpam, undoSpam, searchFilters, isAdvancedSearchOpen, setSearchFilters, clearSearchFilters, toggleAdvancedSearch, advancedSearch, selectedKeyword, selectKeyword, hasMoreEmails, fetchTagCounts, fetchEmailContent, isUnifiedView, unifiedRole, scheduledEmails, scheduledTotal, scheduledHasMore, isLoadingScheduled, isScheduledView, setScheduledView, fetchScheduledEmails, loadMoreScheduledEmails, cancelScheduledEmail, cancelScheduledEmailForEdit, rescheduleScheduledEmail, refreshScheduledMetadata, cancelUndoSend, clearPendingUndoSend, pendingUndoSend, fetchUnifiedEmails: fetchUnifiedEmailsAction, fetchCrossView: fetchCrossViewAction, refreshUnifiedCounts, refreshCrossCounts, crossUnreadCount, exitUnifiedView, emptyMailbox, markMailboxAsRead, createMailbox, renameMailbox, deleteMailbox, batchDelete, batchArchive, batchMarkAsRead, batchMarkAsSpam, batchUndoSpam, accountMailboxes, viewingAccountId, selectAccountMailbox, setViewingAccount, refreshCurrentMailbox, } = useEmailStore(); // Load recent recipients (from the Sent folder) for compose autocomplete. // Runs once when the Sent mailbox is known; the store guards against reloads. useEffect(() => { const sent = mailboxes.find((m) => m.role === 'sent'); if (client && sent) { loadRecentRecipients(client, sent.originalId || sent.id); } }, [client, mailboxes, loadRecentRecipients]); // Pro shell: populate per-account mailbox cache so the sidebar can render // every connected account Thunderbird-style. useProMultiAccountMailboxes(); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const delayedSendSupported = client?.hasDelayedSend() ?? true; // Cross-account "All accounts" views: a sub-feature of the unified mailbox, so // they require Unified Mailbox to be enabled, plus the admin gate and the // per-user setting. Hooks are called unconditionally. const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled')); const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled')); const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled')); const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView); const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView); const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView); const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView; const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView; const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView; const activeEmails = isScheduledView ? scheduledEmails : emails; const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount); const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled')); const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( () => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","), [accounts], ); // Cross-account is "active" when the user opted in, the admin allows it, and // more than one account is connected. Drives the sidebar header label: the // old "All accounts" when spanning accounts, else "Unified Mailbox". const crossAccountActive = unifiedCrossAccount && unifiedCrossAccountGate && accounts.filter((a) => a.isConnected).length > 1; // Builds the populated UnifiedAccountClient[] used by the unified-view // effects and one-shot actions in this page. Reads the settings at call time // so the latest toggle values are always honored. When the cross-account // sub-option is off, the unified mailbox stays within the active account // boundary (its own + shared folders); when on, it spans every login account. const buildPopulatedUnifiedAccounts = useCallback(async (): Promise => { // Cross-account scope requires both the per-user opt-in and the admin // capability gate; otherwise stay within the active account boundary. const crossAccount = useSettingsStore.getState().unifiedCrossAccount && usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled'); return buildUnifiedAccountClients({ includeGroup: useSettingsStore.getState().includeGroupInUnified, scopeToClientAccountId: crossAccount ? undefined : (useAccountStore.getState().activeAccountId ?? undefined), }); }, []); const getMailtoProtocolAccounts = useCallback(() => { const connectedClients = useAuthStore.getState().getAllConnectedClients(); return useAccountStore.getState().accounts.filter((account) => account.isConnected && connectedClients.has(account.id) ); }, []); // Browser back / forward integration. The restore handler reads the // latest values from a ref so we don't have to recreate the callback on // every render (and so the popstate listener is never stale). const navRestoreStateRef = useRef({ client, emails, mailboxes, selectedMailbox, selectedEmailId: selectedEmail?.id ?? null, conversationThreadId: null as string | null, }); navRestoreStateRef.current.client = client; navRestoreStateRef.current.emails = activeEmails; navRestoreStateRef.current.mailboxes = mailboxes; navRestoreStateRef.current.selectedMailbox = selectedMailbox; navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null; navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null; const handleNavRestore = useCallback(async (state: NavSnapshot) => { const ctx = navRestoreStateRef.current; // Restore sidebar overlay state. setSidebarOpen(state.sidebarOpen); // Restore composer visibility. if (!state.composerOpen) { setShowComposer(false); } // Derive the mobile view from the saved snapshot. The view is a // function of which content the user is looking at: an email, a // thread, the composer, or the bare list. const derivedView: "list" | "viewer" = state.emailId || state.threadId || state.composerOpen ? "viewer" : "list"; setActiveView(derivedView); // Restore mailbox selection. selectMailbox clears the current email, // which is fine because we re-apply the saved email below. if (state.mailboxId === SCHEDULED_MAILBOX_ID) { if (!ctx.client?.hasDelayedSend()) { setScheduledView(false); selectEmail(null); return; } setScheduledView(true); selectMailbox(SCHEDULED_MAILBOX_ID); selectEmail(null); if (ctx.client) { try { await fetchScheduledEmails(ctx.client); } catch (error) { debug.error('Failed to fetch scheduled emails on history restore:', error); } } } else if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) { setScheduledView(false); selectMailbox(state.mailboxId); if (ctx.client) { try { await fetchEmails(ctx.client, state.mailboxId); } catch (error) { debug.error('Failed to fetch emails on history restore:', error); } } } // Restore conversation thread (mobile only). We can clear it directly, // but reopening requires the thread group; if the user pressed forward // to return to a thread, we silently skip - back navigation always works. if ((state.threadId ?? null) !== ctx.conversationThreadId) { if (state.threadId === null) { setConversationThread(null); setConversationEmails([]); } } // Restore email selection. if (state.emailId !== ctx.selectedEmailId) { if (state.emailId === null) { selectEmail(null); } else { // Try the in-memory list first; the existing useEffect will fetch // body content if it's missing. const found = ctx.emails.find(e => e.id === state.emailId); if (found) { selectEmail(found); } else if (ctx.client) { // Email isn't in the current list (e.g. mailbox just changed). // Fetch it directly. try { const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const fullEmail = await ctx.client.getEmail(state.emailId, accountId); if (fullEmail) selectEmail(fullEmail); } catch (error) { debug.error('Failed to fetch email on history restore:', error); } } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useBrowserNavigation({ mailboxId: selectedMailbox, emailId: selectedEmail?.id ?? null, threadId: conversationThread?.threadId ?? null, composerOpen: showComposer, sidebarOpen, onRestore: handleNavRestore, enabled: isAuthenticated && mailboxes.length > 0, }); // Keyboard shortcuts handlers const keyboardHandlers = useMemo(() => ({ onNextEmail: () => { if (activeEmails.length === 0) return; const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1; const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex; if (nextIndex >= 0 && nextIndex < activeEmails.length) { handleEmailSelect(activeEmails[nextIndex]); } }, onPreviousEmail: () => { if (activeEmails.length === 0) return; const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length; const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0; if (prevIndex >= 0 && prevIndex < activeEmails.length) { handleEmailSelect(activeEmails[prevIndex]); } }, onOpenEmail: () => { // Email is already opened when selected }, onCloseEmail: () => { selectEmail(null); if (isMobile) { setActiveView("list"); } if (isTablet) { setTabletListVisible(true); } }, onReply: () => { if (isScheduledView) return; if (selectedEmail) handleReply(); }, onReplyAll: () => { if (isScheduledView) return; if (selectedEmail) handleReplyAll(); }, onForward: () => { if (isScheduledView) return; if (selectedEmail) handleForward(); }, onToggleStar: () => { if (isScheduledView) return; if (selectedEmail) handleToggleStar(); }, onArchive: async () => { if (isScheduledView) return; if (selectedEmailIds.size > 0 && client) { try { await batchArchive(client); } catch (error) { console.error("Failed to batch archive:", error); } } else if (selectedEmail) { handleArchive(); } }, onDelete: async () => { if (isScheduledView) return; if (selectedEmailIds.size > 0 && client) { const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; const isInJunk = currentMailbox?.role === 'junk'; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; const permanent = isInTrash || (isInJunk && permanentlyDeleteJunk); const confirmed = await confirmDialog({ title: permanent ? t('email_list.permanent_delete_confirm_title') : t('email_list.batch_actions.delete_confirm_title'), message: permanent ? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size }) : t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }), confirmText: permanent ? t('email_list.permanent_delete') : t('email_list.batch_actions.delete'), variant: "destructive", }); if (!confirmed) return; try { await batchDelete(client, permanent); } catch (error) { console.error("Failed to batch delete:", error); } } else if (selectedEmail) { handleDelete(); } }, onMarkAsUnread: async () => { if (isScheduledView) return; if (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, false); } else if (selectedEmail) { await markAsRead(client, selectedEmail.id, false); } }, onMarkAsRead: async () => { if (isScheduledView) return; if (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, true); } else if (selectedEmail) { await markAsRead(client, selectedEmail.id, true); } }, onToggleSpam: async () => { if (isScheduledView) return; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); // Marking your own outgoing mail as spam makes no sense - the toolbar // and menus hide the action in Sent/Drafts/Scheduled, so the shortcut // is a no-op there too. if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return; const isInJunk = currentMailbox?.role === 'junk'; if (selectedEmailIds.size > 0 && client) { const ids = Array.from(selectedEmailIds); try { if (isInJunk) { await batchUndoSpam(client, ids); } else { await batchMarkAsSpam(client, ids); } } catch (error) { console.error("Failed to batch toggle spam:", error); } } else if (selectedEmail) { if (isInJunk) { handleUndoSpam(); } else { handleMarkAsSpam(); } } }, onCompose: () => { startFreshComposerSession(); setComposerMode('compose'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }, onFocusSearch: () => { if (isScheduledView) return; const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement; if (searchInput) { searchInput.focus(); searchInput.select(); } }, onShowHelp: () => { setShowShortcutsModal(true); }, onRefresh: async () => { if (client && selectedMailbox) { if (selectedMailbox === SCHEDULED_MAILBOX_ID) { await fetchScheduledEmails(client); } else { await fetchEmails(client, selectedMailbox); } } }, onSelectAll: () => { if (isScheduledView) return; selectAllEmails(); }, onDeselectAll: () => { clearSelection(); }, // eslint-disable-next-line react-hooks/exhaustive-deps }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]); // Initialize keyboard shortcuts useKeyboardShortcuts({ enabled: isAuthenticated && !showComposer, emails: activeEmails, selectedEmailId: selectedEmail?.id, selectionCount: selectedEmailIds.size, handlers: keyboardHandlers, }); // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh mail data via JMAP instead of reloading the page. useRefreshGesture({ enabled: isAuthenticated && !!client, onRefresh: async () => { if (!client) return; const state = useEmailStore.getState(); if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) { await Promise.all([ state.fetchMailboxes(client), state.fetchScheduledEmails(client), ]); } else { await state.fetchMailboxes(client); await state.refreshScheduledMetadata(client); await (state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client)); } }, }); useEffect(() => { if (!delayedSendSupported && isScheduledView) { setScheduledView(false); } }, [delayedSendSupported, isScheduledView, setScheduledView]); useEffect(() => { if (!pendingUndoSend) return; const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime(); if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) { clearPendingUndoSend(); return; } const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now()); return () => clearTimeout(timer); }, [clearPendingUndoSend, pendingUndoSend]); // Update page title based on context useEffect(() => { let title = appName; if (showComposer) { // Composing email const modeText = { compose: t('email_composer.new_message'), reply: t('email_composer.reply'), replyAll: t('email_composer.reply_all'), forward: t('email_composer.forward'), }[composerMode] || t('email_composer.new_message'); title = `${modeText} - ${appName}`; } else if (selectedEmail) { // Reading email const subject = selectedEmail.subject || t('email_viewer.no_subject'); title = `${subject} - ${appName}`; } else if (selectedMailbox && mailboxes.length > 0) { // Mailbox view const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); if (mailbox) { const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)); const unreadCount = mailbox.unreadEmails || 0; title = unreadCount > 0 ? `${mailboxName} (${unreadCount}) - ${appName}` : `${mailboxName} - ${appName}`; } } document.title = title; }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); // When this page is rendered inside the Pro shell as the Mail tab body, // we hoist every "show composer" intent into its own Pro tab and reset // the in-page state so the inline composer never appears in the Mail tab. // This makes the Pro composer behave like Thunderbird's pop-out window. useEffect(() => { if (!isEmbedded || !showComposer) return; // pendingDraft.replyTo, when set, was built by the opener (e.g. // handleForwardAsAttachment) with intent that must survive the hop into // the Pro tab - mirrors the same precedence the non-embedded render path // uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo // : ...}`). Building fresh from selectedEmail unconditionally here would // silently drop that intent (e.g. the synthetic message/rfc822 // attachment "Forward as attachment" stages), falling back to a normal // quoted forward instead. const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? { from: selectedEmail.from, replyToAddresses: selectedEmail.replyTo, to: selectedEmail.to, cc: selectedEmail.cc, bcc: selectedEmail.bcc, subject: selectedEmail.subject, ...getQuoteBodies(selectedEmail), receivedAt: selectedEmail.receivedAt, attachments: selectedEmail.attachments, messageId: selectedEmail.messageId, inReplyTo: selectedEmail.inReplyTo, references: selectedEmail.references, quoteHeaderHtml: composerQuoteHeader?.html, quoteHeaderText: composerQuoteHeader?.text, quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote, } : undefined); const effectiveMode = pendingDraft?.mode ?? composerMode; const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? ''; let title = t('email_composer.new_message'); if (baseSubject) { if (effectiveMode === 'reply' || effectiveMode === 'replyAll') { title = buildReplySubject(baseSubject, t('email_composer.prefix.reply')); } else if (effectiveMode === 'forward') { title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward')); } else { title = baseSubject; } } useProTabStore.getState().openComposeTab({ sessionId: composerSessionId + 1, mode: effectiveMode, replyTo, initialDraftText: composerDraftText, initialData: pendingDraft, sourceEmailId: selectedEmail?.id ?? null, title, }); setComposerSessionId((s) => s + 1); setShowComposer(false); setComposerDraftText(""); setPendingDraft(null); setComposerQuoteHeader(null); // We only react to the rising edge of `showComposer` here; the other // variables read above are captured-but-stale-safe because the next // open will fire a fresh effect with new values. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEmbedded, showComposer]); // Check auth on mount – skip when already authenticated so that navigating // between routes doesn't retrigger checkAuth's transient `{ client: null, // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { const state = useAuthStore.getState(); if (state.isAuthenticated && state.client) { setInitialCheckDone(true); return; } checkAuth().finally(() => { setInitialCheckDone(true); }); }, [checkAuth]); // Initialize plugins on mount (re-activates enabled plugins after refresh) // Also syncs server-managed plugins and themes to the client useEffect(() => { usePluginStore.getState().initializePlugins(); useThemeStore.getState().syncServerThemes(); }, []); // Hydrate persisted column widths from localStorage useEffect(() => { try { const stored = localStorage.getItem("column-widths"); if (stored) { const parsed = JSON.parse(stored); if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth); if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth); if (parsed.emailListHeight) setEmailListHeight(parsed.emailListHeight); } } catch { /* ignore parse errors */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Redirect to login if not authenticated useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } redirectToLogin(); } }, [initialCheckDone, isAuthenticated, authLoading]); const openMailtoDraft = useCallback((pending: ParsedMailto) => { const body = useSettingsStore.getState().plainTextMode ? pending.body : plainTextToComposerBody(pending.body); if (showComposer) { suppressComposerStateSaveSessionRef.current = composerSessionId; } setComposerSessionId((id) => id + 1); setPendingDraft({ to: pending.to.join(", "), cc: pending.cc.join(", "), bcc: pending.bcc.join(", "), subject: pending.subject, body, showCc: pending.cc.length > 0, showBcc: pending.bcc.length > 0, selectedIdentityId: null, subAddressTag: "", mode: "compose", draftId: null, }); setComposerMode("compose"); setShowComposer(true); if (isMobile) setActiveView("viewer"); }, [composerSessionId, isMobile, setActiveView, showComposer]); const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => { setIsProtocolAccountSwitching(true); try { if (useAuthStore.getState().activeAccountId !== accountId) { await switchAccount(accountId); } setPendingMailtoAccountChoice(null); openMailtoDraft(pending); } finally { setIsProtocolAccountSwitching(false); } }, [openMailtoDraft, switchAccount]); const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => { const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { setPendingMailtoAccountChoice(pending); return; } const accountId = protocolAccounts[0]?.id ?? activeAccountId; if (accountId) { void openMailtoForAccount(pending, accountId); return; } openMailtoDraft(pending); }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); useEffect(() => { if (!isAuthenticated || !client) return; const openPendingMailto = () => { const pending = consumePendingMailto(); if (pending) handleMailtoProtocolRequest(pending); }; openPendingMailto(); return subscribeToPendingMailto(openPendingMailto); }, [isAuthenticated, client, handleMailtoProtocolRequest]); // Fallback fetch for paths that didn't go through login()'s prefetch // (notably checkAuth on page refresh). Settings pages can also prefill // mailboxes without emails, so bootstrap emails once per client when the // mail route mounts even if mailbox data is already present. useEffect(() => { if (!isAuthenticated || !client) { initialMailLoadClientRef.current = null; return; } if (initialMailLoadClientRef.current === client) return; initialMailLoadClientRef.current = client; let retryTimer: ReturnType | null = null; let cancelled = false; const loadData = async (attempt = 1) => { try { const needsMailboxes = useEmailStore.getState().mailboxes.length === 0; await Promise.all([ needsMailboxes ? fetchMailboxes(client) : Promise.resolve(), fetchQuota(client) ]); const state = useEmailStore.getState(); const selectedMailboxId = state.selectedMailbox; if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) { const delay = Math.min(1000 * attempt, 5000); debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`); retryTimer = setTimeout(() => loadData(attempt + 1), delay); return; } await refreshScheduledMetadata(client); // Fetch emails for the selected mailbox after scheduled metadata is // available. If the list is already populated (an account switch // restored a cached snapshot, or login prefetched it), refresh in the // background so the visible mail doesn't flash a loading overlay; only // a genuine empty first load shows the skeleton. const background = state.emails.length > 0; if (selectedMailboxId) { await fetchEmails(client, selectedMailboxId, { background }); } else { await fetchEmails(client, undefined, { background }); } fetchTagCounts(client); } catch (error) { console.error('Error loading email data:', error); initialMailLoadClientRef.current = null; } }; loadData(); return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer); }; }, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]); // Push notifications: set up once per CONNECTED client and tear down when the // clients go away (logout or account switch). Kept separate from the fetch // effect above so it still runs when data was prefetched at login time. // // We bind every connected login, not just the active one: background accounts // must drive the unified-section counters too. The active client keeps the // full handler (current list / scheduled / calendar / filters); background // logins only re-project the unified counts by rebuilding the unified scope // (which refreshes every account's cached mailbox list), since their changes // never touch the active `mailboxes`. (#281 background push) useEffect(() => { if (!isAuthenticated || !client) return; const clients = useAuthStore.getState().getAllConnectedClients(); const cleanups: Array<() => void> = []; for (const [accId, c] of clients) { try { if (accId === activeAccountId) { c.onStateChange((change) => handleStateChange(change, c)); } else { c.onStateChange(() => { buildPopulatedUnifiedAccounts() .then((built) => { refreshCrossCounts(built); refreshUnifiedCounts(built); }) .catch(() => { /* per-account fetch failures surface elsewhere */ }); }); } c.setupPushNotifications(); cleanups.push(() => c.closePushNotifications()); } catch (error) { debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error); } } if (cleanups.length > 0) { setPushConnected(true); debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`); } // CATCH-UP for the desktop shell's local search index. The index's normal // trigger is a push StateChange (stores/email-store.ts's handleStateChange), // but nothing was pushed while the app was closed - and the polling // transport has no signal for contacts or files at all (client.ts's // buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/ // SieveScript only). So backfill a bounded recent window once per session, // after push is wired. Fire-and-forget; a no-op outside Electron. // // RETRIED, not one-shot. The first attempt races the login flow's own // POST /api/auth/stalwart-context (stores/auth-store.ts's // syncStalwartAuthContext) - if the index route is hit before that cookie // is minted it 401s, and a single silent attempt would leave the index // empty until the next app restart with nothing telling anyone why (this // exact silence hid the packaged app's missing-SESSION_SECRET bug against // a real mailbox). requestIndex() already distinguishes the permanent // cases (404/503 -> unavailable) from the retryable ones, so retrying is // cheap and self-limiting. const catchUpRetryDelaysMs = [4000, 20000, 60000]; let catchUpCancelled = false; let catchUpTimer: ReturnType | undefined; const runCatchUp = async (attempt: number) => { if (catchUpCancelled) return; try { const { catchUpIndex } = await import('@/lib/mail-index-client'); const result = await catchUpIndex( useAccountStore.getState().getActiveAccount()?.cookieSlot, ); if (!result.ok && !result.unavailable && attempt + 1 < catchUpRetryDelaysMs.length) { catchUpTimer = setTimeout(() => void runCatchUp(attempt + 1), catchUpRetryDelaysMs[attempt + 1]); return; } } catch { /* the index is optional */ } // The offline REPLICA's launch catch-up. Same reasoning as the index's, // plus one of its own: a `/changes` cursor cannot tell us about anything // that happened while the process was dead, so a cycle at launch is what // drains the backlog. One cycle is bounded, so a first sync of a large // mailbox needs several - `chainSync` runs them with a hard cap. // // Sequenced AFTER the index (including its retries) rather than in // parallel: both write the same SQLite file, and although `busy_timeout` // makes concurrent writers safe, there is no reason to spend the // contention during first paint. if (catchUpCancelled) return; try { const { chainSync } = await import('@/lib/offline-replica-client'); await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot }); } catch { /* the replica is optional */ } }; // Deliberately after the initial mailbox fetch settles: the catch-up is a // background nicety and must not compete with first paint. catchUpTimer = setTimeout(() => void runCatchUp(0), catchUpRetryDelaysMs[0]); return () => { catchUpCancelled = true; clearTimeout(catchUpTimer); cleanups.forEach((fn) => fn()); }; }, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]); // Keep unified mailbox counts in sync when the feature is enabled and more // than one account is connected. Runs whenever the set of connected accounts // or the primary account's mailboxes change (a proxy for "something worth // recounting happened"). The Pro shell always renders the unified mailbox // regardless of the user setting, so refresh when embedded too. useEffect(() => { const anyCross = showCrossUnread || showCrossStarred || showCrossAll; if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return; if (!isAuthenticated || !client) return; buildPopulatedUnifiedAccounts().then((built) => { const hasGroupEntry = built.some((b) => b.isShared); if (anyCross) refreshCrossCounts(built); if (built.length < 2 && !hasGroupEntry && !isEmbedded) return; refreshUnifiedCounts(built); }); }, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]); // System-notification click handler. The push SW navigates the user back // here with `?email=` (specific email it built the toast from) or // `?openLatestUnread=1` (generic "New mail" toast - happens when the // preview API failed). We resolve those params once after the inbox has // finished loading and open the right message, then strip the params so a // refresh doesn't re-open it. const notificationParamHandledRef = useRef(false); useEffect(() => { if (notificationParamHandledRef.current) return; if (!isAuthenticated || !client) return; if (mailboxes.length === 0) return; const params = new URLSearchParams(window.location.search); const emailIdParam = params.get('email'); const openLatestUnread = params.get('openLatestUnread') === '1'; if (!emailIdParam && !openLatestUnread) return; // For the latest-unread case we need the inbox emails loaded; bail and // let the effect re-run once `emails` is populated. if (openLatestUnread && emails.length === 0) return; notificationParamHandledRef.current = true; window.history.replaceState({}, '', window.location.pathname); if (emailIdParam) { setLoadingEmail(true); fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false)); return; } // emails are sorted receivedAt-desc, so the first unread is the newest. const newestUnread = emails.find(e => !e.keywords?.$seen); if (newestUnread) { selectEmail(newestUnread); } }, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]); // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) useEffect(() => { if (!selectedEmail || !client) return; // If the email lacks bodyValues, it was auto-selected from the list and needs full content. // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before // calling selectEmail on the stub), to avoid a duplicate request. if (!selectedEmail.bodyValues && !isLoadingEmail) { const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId ? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId) : undefined; const fetchClient = perAccountClient ?? client; setLoadingEmail(true); fetchEmailContent(fetchClient, selectedEmail.id).finally(() => { setLoadingEmail(false); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmail?.id]); // Handle mark-as-read with delay based on settings useEffect(() => { // Clear any existing timeout when email changes if (markAsReadTimeoutRef.current) { debug.log('email', '[Mark as Read] Clearing previous timeout'); clearTimeout(markAsReadTimeoutRef.current); markAsReadTimeoutRef.current = null; } // Only set timeout if there's a selected email, it's unread, and we have a client if (!selectedEmail || !client || selectedEmail.keywords?.$seen || isScheduledView) { return; } // Get current setting value const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id); if (markAsReadDelay === -1) { // Never mark as read automatically debug.log('email', '[Mark as Read] Never mode - email will stay unread'); } else if (markAsReadDelay === 0) { // Mark as read instantly debug.log('email', '[Mark as Read] Instant mode - marking as read now'); markAsRead(client, selectedEmail.id, true); } else { // Mark as read after delay debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms'); markAsReadTimeoutRef.current = setTimeout(() => { debug.log('email', '[Mark as Read] Timeout fired - marking as read now'); markAsRead(client, selectedEmail.id, true); markAsReadTimeoutRef.current = null; }, markAsReadDelay); } // Cleanup on unmount or when dependencies change return () => { if (markAsReadTimeoutRef.current) { debug.log('email', '[Mark as Read] Cleanup - clearing timeout'); clearTimeout(markAsReadTimeoutRef.current); markAsReadTimeoutRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmail?.id, isScheduledView]); // Handle new email notifications - play sound, and (in the Electron shell) // fire a native OS notification. This effect is the transport-agnostic // "genuinely new unread mail arrived" signal - stores/email-store.ts's // refreshCurrentMailbox() already filters out sends/moves/drafts and only // sets newEmailNotification for a real new top-of-inbox message, and it // fires identically whether the underlying JMAP StateChange arrived over // the WebSocket push connection (lib/jmap/client.ts's connectWebSocket), // SSE, or the polling fallback - no need to duplicate this per transport. useEffect(() => { if (newEmailNotification) { const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState(); if (emailNotificationsEnabled && emailNotificationSound) { playNotificationSound(notificationSoundChoice); } if (emailNotificationsEnabled && isElectronShell()) { // Same fallback text public/sw.js's push handler already uses for // its (also un-translated) system notifications - a native OS // notification body isn't run through next-intl either way, so // matching that existing precedent instead of introducing new // translation keys for a rarely-hit fallback. const sender = newEmailNotification.from?.[0]; const senderName = sender?.name || sender?.email || 'New mail'; const body = newEmailNotification.subject || newEmailNotification.preview || '(no subject)'; void showElectronNotification(senderName, { body, tag: `bulwark-mail:${newEmailNotification.id}`, }); } debug.log('email', 'New email received:', newEmailNotification.subject); clearNewEmailNotification(); } }, [newEmailNotification, clearNewEmailNotification]); // Lock body scroll when sidebar is open on mobile/tablet useEffect(() => { if ((isMobile || isTablet) && sidebarOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isMobile, isTablet, sidebarOpen]); const handleEmailSend = async (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string; htmlBody?: string; draftId?: string; fromEmail?: string; fromName?: string; identityId?: string; envelopeMailFrom?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; delayedUntil?: string; requestReadReceipt?: boolean; }) => { if (!client) return; try { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt }); setShowComposer(false); if (result.filingError) { // The mail went out, but a post-send step (filing to Sent / // removing the old draft) was rejected - warn instead of staying // silent, so a stale draft row is not mistaken for a failed send // and re-sent (#592). const toastInstance = (await import('sonner')).toast; toastInstance.warning(t('email_composer.send_filing_warning')); } if (result.scheduled) { await refreshScheduledMetadata(client); if (isScheduledView) await fetchScheduledEmails(client); return; } // Mark the original email with $answered or $forwarded keyword. Route the // write to the email's own account so the flag lands on shared/group-mailbox // messages instead of being dropped against the reaching account. (#281) if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) { const s = useEmailStore.getState(); const orig = s.emails.find(e => e.id === originalEmailId); const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; const kwClient = kwClientId ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) : client; const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered'; try { await kwClient.setKeyword(originalEmailId, keyword, kwAccountId); } catch (e) { debug.error(`Failed to set ${keyword} keyword:`, e); } } // Refresh the current mailbox to update the UI if (!isScheduledView) { await refreshCurrentMailbox(client); // Re-fetch the replied thread's cross-folder data so the expanded // view shows the newly sent reply without collapsing. if (originalEmailId) { const emailState = useEmailStore.getState(); const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { // Route to the email's own account so shared/group threads refresh // from the right server, not the active one. (#281) const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId ? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client) : client; const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId ? repliedEmail.sourceAccountId : client.getAccountId(); const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId); if (fullEmails.length > 0) { useEmailStore.setState((state) => { const c = new Map(state.threadEmailsCache); c.set(repliedEmail.threadId!, fullEmails); return { threadEmailsCache: c }; }); } } } } } catch (error) { console.error("Failed to send email:", error); } }; const handleDiscardDraft = async (draftId: string) => { if (!client) return; try { await client.deleteEmail(draftId); } catch (error) { console.error("Failed to discard draft:", error); } }; // Build the quote header for a reply/forward open, running it through the // emailHooks.onBuildQuoteHeader transform so plugins can replace it. Stores // the result in composerQuoteHeader; the render site spreads it into // EmailComposer.replyTo. Errors fall back to the composer's built-in // header (state set to null). const prepareComposerQuoteHeader = useCallback(async ( email: Email | null, mode: 'reply' | 'replyAll' | 'forward', ) => { if (!email) { setComposerQuoteHeader(null); return; } try { const replyTargets = (email.replyTo?.length ? email.replyTo : email.from ?? []).filter(r => r.email).map(r => r.email!); const newTo = mode === 'reply' ? replyTargets : mode === 'replyAll' ? [...replyTargets, ...(email.to ?? []).filter(r => r.email).map(r => r.email!)] : []; const newCc = mode === 'replyAll' ? (email.cc ?? []).filter(r => r.email).map(r => r.email!) : []; const header = await buildQuoteHeader({ mode, email: { from: email.from, to: email.to, cc: email.cc, subject: email.subject, receivedAt: email.receivedAt, }, newTo, newCc, locale: getEffectiveLocale(), timeFormat: useSettingsStore.getState().timeFormat, unknownLabel: tCommon('unknown'), labels: { formatReplyLine: (vars) => tQuote('reply_line', vars), forwardedSeparator: tQuote('forwarded_separator'), fromLabel: tQuote('from_label'), dateLabel: tQuote('date_label'), subjectLabel: tQuote('subject_label'), }, }); setComposerQuoteHeader(header); } catch (err) { console.warn('[quote-header] plugin transform failed; using default', err); setComposerQuoteHeader(null); } }, [tCommon, tQuote]); // Force a clean composer remount on every fresh entry point so prior // compose state can't bleed into the new session (#329 C). The composer is // keyed on composerSessionId, and pendingDraft would otherwise pin the // composer to a stale draft from a discarded reply. const startFreshComposerSession = useCallback(() => { setComposerSessionId(id => id + 1); setPendingDraft(null); }, []); const handleReply = async (draftText?: string) => { if (selectedEmail) { const ok = await emailHooks.onBeforeReply.intercept({ originalEmailId: selectedEmail.id, originalEmail: emailToReadView(selectedEmail), mode: 'reply' as const, }); if (!ok) return; await prepareComposerQuoteHeader(selectedEmail, 'reply'); } else { setComposerQuoteHeader(null); } startFreshComposerSession(); setComposerDraftText(draftText || ""); setComposerMode('reply'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleEditDraft = async (email?: Email) => { if (!client) return; const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string' ? email : selectedEmail; let draft = draftCandidate; if (!draft) return; // The email list only fetches limited properties (no bodyValues/htmlBody/bcc). // Fetch the full email so the composer gets all draft content. if (!draft.bodyValues) { const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const fullDraft = await client.getEmail(draft.id, accountId); if (!fullDraft) return; draft = fullDraft; } draft = await emailHooks.onBeforeEditDraft.transform(draft); const bodyText = draft.bodyValues ? Object.values(draft.bodyValues).map(v => v.value).join('\n') : ''; // A plain-text-only draft lists its text/plain part under htmlBody // (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is. const draftHtmlPart = draft.htmlBody?.[0]; const htmlBody = draftHtmlPart?.partId && (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html') && draft.bodyValues?.[draftHtmlPart.partId] ? draft.bodyValues[draftHtmlPart.partId].value : undefined; // Restore the identity the draft was composed with. Match the saved From // (name + address) against the same list the composer renders — the flat // cross-account list when multi-account is on — so a draft from a non-active // account, or one of two identities sharing an address, is restored rather // than reset to the default. const composerIdentities = multiAccountIdentities.enabled ? multiAccountIdentities.allIdentities : identities; const matchedIdentityId = findDraftIdentityId(composerIdentities, draft.from?.[0]); // Increment session ID to force the composer to remount with fresh state, // even if it was already open (e.g. right-clicking a draft while composing). setComposerSessionId(id => id + 1); setPendingDraft({ to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '', cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '', bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '', subject: draft.subject || '', body: htmlBody || bodyText, showCc: (draft.cc?.length || 0) > 0, showBcc: (draft.bcc?.length || 0) > 0, selectedIdentityId: matchedIdentityId, subAddressTag: '', mode: 'compose', draftId: draft.id, }); setComposerMode('compose'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; useEffect(() => { if (!pendingUndoSend || !client) return; if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return; lastUndoToastSubmissionRef.current = pendingUndoSend.submissionId; const pending = pendingUndoSend; const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000; toast.success(t('email_viewer.scheduled_send_created'), { duration: undoDurationMs, secondaryAction: (pending.emailId && pending.identityId) ? { label: t('email_viewer.send_now'), onClick: () => { void (async () => { try { await client.rescheduleEmailSubmission( pending.submissionId, pending.emailId!, pending.identityId!, new Date(Date.now() + 1000).toISOString(), ); clearPendingUndoSend(); if (isScheduledView) await fetchScheduledEmails(client); } catch (error) { console.error('Failed to send now:', error); } })(); }, } : undefined, action: { label: t('email_viewer.undo_send'), onClick: () => { void (async () => { try { const restored = await cancelUndoSend(client, pending); if (restored && !pending.isSmime) { await handleEditDraft(restored); } if (isScheduledView) await fetchScheduledEmails(client); } catch (error) { console.error('Failed to undo scheduled send:', error); } })(); }, }, }); const timer = setTimeout(() => { const current = useEmailStore.getState().pendingUndoSend; if (current?.submissionId === pending.submissionId) { clearPendingUndoSend(); } }, undoDurationMs); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]); const handleReplyAll = async () => { if (selectedEmail) { const ok = await emailHooks.onBeforeReplyAll.intercept({ originalEmailId: selectedEmail.id, originalEmail: emailToReadView(selectedEmail), mode: 'reply-all' as const, }); if (!ok) return; await prepareComposerQuoteHeader(selectedEmail, 'replyAll'); } else { setComposerQuoteHeader(null); } startFreshComposerSession(); setComposerMode('replyAll'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleForward = async () => { if (selectedEmail) { const ok = await emailHooks.onBeforeForward.intercept({ originalEmailId: selectedEmail.id, originalEmail: emailToReadView(selectedEmail), mode: 'forward' as const, }); if (!ok) return; await prepareComposerQuoteHeader(selectedEmail, 'forward'); } else { setComposerQuoteHeader(null); } startFreshComposerSession(); setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; // Forward the original message as a message/rfc822 attachment instead of // inline-quoted text - e.g. for reporting spam to an upstream gateway // that expects the raw original as an attachment, or preserving exact // formatting/headers the recipient needs to see untouched. Reuses the // same attachment-carry-forward mechanism native Forward already uses // for a forwarded message's own attachments (see the `attachments` // useState initializer in email-composer.tsx) - we just add one more // synthetic entry representing the whole original message, referenced // by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are // account-scoped, not per-email). Skips prepareComposerQuoteHeader // entirely, so the body starts blank instead of quoting the original. // Takes an explicit `email` (defaulting to selectedEmail), same pattern // handleDelete uses just below, rather than always reading selectedEmail // from this closure - callers that just called selectEmail(email) and // invoke this synchronously in the same tick would otherwise see the // PRE-update value (the Zustand store updates immediately, but this // render's selectedEmail closure doesn't until the next render), // forwarding the previously selected message or no-op'ing on an // unselected row. See the list context-menu wiring below. const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => { if (!email) return; // Same filename options "Export as .eml" uses (see emailFilenameOptions // in email-viewer.tsx), so the two actions produce consistent filenames // for the same message rather than the synthetic attachment silently // ignoring the user's configured naming template. const { emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators, } = useSettingsStore.getState(); const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), { template: emailDownloadTemplate, spaceReplacement: filenameSpaceReplacement, lowercase: filenameLowercase, stripDiacritics: filenameStripDiacritics, collapseSeparators: filenameCollapseSeparators, }); if (!payload) return; const ok = await emailHooks.onBeforeForward.intercept({ originalEmailId: email.id, originalEmail: emailToReadView(email), mode: 'forward' as const, }); if (!ok) return; startFreshComposerSession(); setPendingDraft({ to: "", cc: "", bcc: "", subject: payload.subject, body: "", showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: "", mode: "forward", draftId: null, replyTo: { subject: email.subject, attachments: [payload.attachment], }, }); setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; // In unified view the trash destination and current-folder check must come // from the email's own account, not the active one. The owning account's // mailbox list is cached under its JMAP id (`sourceAccountId`). (#281) const actionMailboxes = isUnifiedView && emailToDelete.sourceAccountId ? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes) : mailboxes; // Check if we're currently in the trash or junk folder. In unified view the // "current folder" is the unified role within the email's account. const currentMailbox = isUnifiedView ? (actionMailboxes.find(m => m.role === unifiedRole && emailToDelete.mailboxIds?.[m.id]) ?? actionMailboxes.find(m => m.role === unifiedRole)) : mailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; const isInJunk = currentMailbox?.role === 'junk'; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; if (isInTrash || (isInJunk && permanentlyDeleteJunk)) { // In trash or junk with permanent delete enabled: confirm before permanently deleting const confirmed = await confirmDialog({ title: t('email_list.permanent_delete_confirm_title'), message: t('email_list.permanent_delete_confirm_message'), confirmText: t('email_list.permanent_delete'), variant: "destructive", }); if (!confirmed) return; try { await deleteEmail(client, emailToDelete.id, true); } catch (error) { console.error("Failed to permanently delete email:", error); } } else { // Not in trash: always move to trash (in the email's own account). Scope // the trash lookup to the email's account: for a shared/group source every // mailbox in the list is `isShared`, so we match by accountId instead of // excluding shared (otherwise no trash is found and the delete fails). (#281) const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined; const matchesScope = (m: Mailbox) => sourceAccountId ? m.accountId === sourceAccountId : !m.isShared; const trashMailbox = actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ?? actionMailboxes.find(m => { if (!matchesScope(m)) return false; const lower = m.name.toLowerCase(); return lower.includes('trash') || lower.includes('deleted'); }); if (trashMailbox) { try { await moveToMailbox(client, emailToDelete.id, trashMailbox.id); } catch (error) { console.error("Failed to move email to trash:", error); const { toast } = await import('sonner'); toast.error(error instanceof Error ? error.message : 'Failed to move email to trash'); } } else { const { toast } = await import('sonner'); toast.error('Trash mailbox not found - cannot move email to trash'); } } }; const handleArchive = async (emailToArchive: Email | null = selectedEmail) => { if (!client || !emailToArchive) return; // In unified view the archive folder (and any year/month subfolders we // create) must live in the email's own account, reached through the login it // is reachable via (`sourceClientAccountId`) and routed to its owning JMAP // account (`sourceAccountId`). For personal sources these resolve to the // account itself, so behavior is unchanged. (#281) const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined; const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined; const archiveClient = archiveClientId ? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client) : client; // Read fresh mailboxes from the store – batch archive calls this in a loop, // and each iteration needs to see folders created by prior iterations. const readMailboxes = () => { const s = useEmailStore.getState(); return archiveAccountId ? (s.accountMailboxes[archiveAccountId] ?? s.mailboxes) : s.mailboxes; }; const refreshMailboxes = () => archiveAccountId ? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId) : fetchMailboxes(client); const currentMailboxes = readMailboxes(); const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive"); if (!archiveMailbox) return; const { archiveMode } = useSettingsStore.getState(); try { if (archiveMode === 'single') { await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id); } else { const emailDate = new Date(emailToArchive.receivedAt); const year = emailDate.getFullYear().toString(); const month = (emailDate.getMonth() + 1).toString().padStart(2, '0'); const archiveId = archiveMailbox.originalId || archiveMailbox.id; let yearMailbox = currentMailboxes.find( m => m.name === year && m.parentId === archiveId ); if (!yearMailbox) { yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId); await refreshMailboxes(); } if (archiveMode === 'year') { await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); } else { const yearId = yearMailbox.originalId || yearMailbox.id; const afterYear = readMailboxes(); let monthMailbox = afterYear.find( m => m.name === month && m.parentId === yearId ); if (!monthMailbox) { monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId); await refreshMailboxes(); } await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id); } } if (conversationThread?.threadId === emailToArchive.threadId) { setConversationThread(null); setConversationEmails([]); } void refreshMailboxes(); } catch (error) { console.error("Failed to archive email:", error); } }; const handleToggleStar = async () => { if (!client || !selectedEmail) return; try { await toggleStar(client, selectedEmail.id); } catch (error) { console.error("Failed to toggle star:", error); } }; const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => { if (!client || !emailToMark) return; const emailId = emailToMark.id; try { await markAsSpam(client, emailId); const toastInstance = (await import('sonner')).toast; toastInstance.success(t('email_viewer.spam.toast_success'), { action: { label: t('email_viewer.spam.toast_undo'), onClick: async () => { try { await undoSpam(client, emailId); toastInstance.success(t('notifications.email_moved')); } catch (_error) { console.error("Failed to undo spam:", _error); toastInstance.error(t('email_viewer.spam.error')); } }, }, duration: 5000, }); } catch (_error) { console.error("Failed to mark as spam:", _error); const toastInstance = (await import('sonner')).toast; toastInstance.error(t('email_viewer.spam.error')); } }; const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => { if (!client || !emailToRestore) return; try { await undoSpam(client, emailToRestore.id); const toastInstance = (await import('sonner')).toast; toastInstance.success(t('email_viewer.spam.toast_not_spam_success')); } catch (_error) { console.error("Failed to restore email:", _error); const toastInstance = (await import('sonner')).toast; toastInstance.error(t('email_viewer.spam.error_not_spam')); } }; const handleTogglePinned = async (emailToPin: Email) => { if (!client) return; try { const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin; const isPinned = email.keywords?.['$pinned'] === true; // JMAP keywords are a set of present keys - drop the key to unpin // rather than writing a false value. const keywords = { ...email.keywords }; if (isPinned) { delete keywords['$pinned']; } else { keywords['$pinned'] = true; } // Same unified-view routing as tags: write to the email's own // account via the login it is reachable through. (#281) const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; const pinClient = pinClientId ? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client) : client; await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId); // Patch in place so the icon flips immediately, then refetch the first // page so the mail floats/sinks per the server's pinned-first sort. // Skip the refetch where that sort does not apply (unified views) or // where it would replace a tag-filtered list (refreshCurrentMailbox // fetches by folder only). setEmailKeywordsLocal(email.id, keywords); if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) { void refreshCurrentMailbox(client); } } catch (error) { console.error("Failed to toggle pin:", error); } }; const handleSetTag = async (emailId: string, tagId: string | null) => { if (!client) return; try { // Remove any existing tag keywords const email = emails.find(e => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; if (tagId === null) { // Remove all tag keywords Object.keys(keywords).forEach(key => { if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { keywords[key] = false; } }); } else { // Both prefixes name the same tag when read, so taking one off has to // clear whichever spellings are actually set. const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] .filter(key => keywords[key]); if (activeKeys.length > 0) { activeKeys.forEach(key => { keywords[key] = false; }); } else { // Add the tag without disturbing others keywords[KEYWORD_PREFIX + tagId] = true; } } // In unified view route the write to the email's own account, reached // through the login it is reachable via (`sourceClientAccountId`) and // applied to its owning JMAP account (`sourceAccountId`). For personal // sources these resolve to the account itself, so behavior is unchanged. // Without this, tags on shared/group-mailbox messages are written to the // reaching account and silently dropped by the server. (#281) const tagClientId = isUnifiedView ? email.sourceClientAccountId : undefined; const tagAccountId = isUnifiedView ? email.sourceAccountId : undefined; const tagClient = tagClientId ? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client) : client; // Update email keywords via JMAP await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId); // Patch the email in place so the list keeps its scroll/pagination state // instead of being reset to the first page by a full refetch. setEmailKeywordsLocal(emailId, keywords); // Refresh tag counts fetchTagCounts(client); } catch (error) { console.error("Failed to set tag:", error); } }; // Whenever the global active account changes, drop any non-active viewing // override so we don't leave the email list pointed at a now-stale id. useEffect(() => { if (viewingAccountId && viewingAccountId === activeAccountId) { setViewingAccount(null); } }, [activeAccountId, viewingAccountId, setViewingAccount]); // Pro sidebar: user clicked a folder under a specific account group. // accountId === null means the active account; non-null means a viewing // override that fetches via that account's JMAP client. const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => { const viewingClient = accountId ? useAuthStore.getState().getClientForAccount(accountId) ?? client : client; selectAccountMailbox(accountId, mailboxId); selectEmail(null); if (isMobile) { setSidebarOpen(false); setActiveView("list"); } if (isTablet) { setTabletListVisible(true); } if (viewingClient) { // Keep an active search applied when switching folders (#553); the // store actions resolve the viewing account's client internally. if (!isFilterEmpty(searchFilters)) { await advancedSearch(viewingClient); } else if (searchQuery) { await searchEmails(viewingClient, searchQuery); } else { await fetchEmails(viewingClient, mailboxId); } } }; const handleMailboxSelect = async (mailboxId: string) => { if (mailboxId === SCHEDULED_MAILBOX_ID) { if (!delayedSendSupported) { setScheduledView(false); return; } if (isUnifiedView) exitUnifiedView(); setScheduledView(true); selectMailbox(mailboxId); selectEmail(null); clearSelection(); if (isMobile) { setSidebarOpen(false); setActiveView("list"); } if (isTablet) { setTabletListVisible(true); } if (client) await fetchScheduledEmails(client); return; } if (isUnifiedMailboxId(mailboxId)) { setScheduledView(false); const role = UNIFIED_ROLE_BY_ID[mailboxId]; if (!role) return; selectMailbox(mailboxId); selectEmail(null); if (isMobile) { setSidebarOpen(false); setActiveView("list"); } if (isTablet) { setTabletListVisible(true); } const populated = await buildPopulatedUnifiedAccounts(); // Keep an active search across the switch and re-run it in this view // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. if (client && (!isFilterEmpty(searchFilters) || searchQuery)) { useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null }); if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); } else { await searchEmails(client, searchQuery); } } else { await fetchUnifiedEmailsAction(populated, role); } refreshUnifiedCounts(populated); return; } if (isCrossViewId(mailboxId)) { setScheduledView(false); const view = CROSS_VIEW_BY_ID[mailboxId]; if (!view) return; selectMailbox(mailboxId); selectEmail(null); if (isMobile) { setSidebarOpen(false); setActiveView("list"); } if (isTablet) { setTabletListVisible(true); } const populated = await buildPopulatedUnifiedAccounts(); // Keep an active search across the switch and re-run it in this view // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. if (client && (!isFilterEmpty(searchFilters) || searchQuery)) { useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null }); if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); } else { await searchEmails(client, searchQuery); } } else { await fetchCrossViewAction(populated, view); } refreshCrossCounts(populated); return; } if (isUnifiedView) { exitUnifiedView(); } setScheduledView(false); selectMailbox(mailboxId); selectEmail(null); // Clear selected email when switching mailboxes // On mobile, close sidebar and go to list view if (isMobile) { setSidebarOpen(false); setActiveView("list"); } // On tablet, show the list again if (isTablet) { setTabletListVisible(true); } if (client) { // If there's an active search, re-run it in the new mailbox. Advanced // filters must go through advancedSearch (which also includes the text // query) — falling back to fetchEmails would silently drop them while // the UI still shows them as active (#553). if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); } else if (searchQuery) { await searchEmails(client, searchQuery); } else { await fetchEmails(client, mailboxId); } } }; const handleTagSelect = async (keywordId: string | null) => { setScheduledView(false); selectKeyword(keywordId); // On mobile, close sidebar and go to list view if (isMobile) { setSidebarOpen(false); setActiveView("list"); } // On tablet, show the list again if (isTablet) { setTabletListVisible(true); } if (client) { await fetchEmails(client); } }; const handleUnreadFilterClick = async (mailboxId: string) => { const isTogglingOff = selectedMailbox === mailboxId && searchFilters.isUnread === true; // Select the mailbox if not already selected if (selectedMailbox !== mailboxId) { selectMailbox(mailboxId); selectEmail(null); } // On mobile, close sidebar and go to list view if (isMobile) { setSidebarOpen(false); setActiveView("list"); } // On tablet, show the list again if (isTablet) { setTabletListVisible(true); } if (isTogglingOff) { // Disable the unread filter and show all emails clearSearchFilters(); if (client) { await fetchEmails(client, mailboxId); } } else { // Enable unread filter clearSearchFilters(); setSearchFilters({ isUnread: true }); if (client) { await advancedSearch(client); } } }; const tCtxMenu = t; const handleMarkFolderRead = async (mailboxId: string) => { if (!client) return; try { const count = await markMailboxAsRead(client, mailboxId); await fetchMailboxes(client); if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId); if (count > 0) { toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count })); } else { toast.success(tCtxMenu('mailbox_context_menu.toast_already_read')); } } catch { toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read')); } }; const handleMarkFolderTreeRead = async (mailboxId: string) => { if (!client) return; const collectIds = (rootId: string): string[] => { const ids: string[] = [rootId]; const stack = [rootId]; while (stack.length > 0) { const current = stack.pop()!; for (const mb of mailboxes) { if (mb.parentId === current) { ids.push(mb.id); stack.push(mb.id); } } } return ids; }; try { const ids = collectIds(mailboxId); let total = 0; for (const id of ids) { total += await markMailboxAsRead(client, id); } await fetchMailboxes(client); if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox); if (total > 0) { toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total })); } else { toast.success(tCtxMenu('mailbox_context_menu.toast_already_read')); } } catch { toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read')); } }; const handleMarkAllFoldersRead = async () => { if (!client) return; const confirmed = await confirmDialog({ title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'), message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'), confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'), variant: "default", }); if (!confirmed) return; try { const total = await client.markAllAsRead(); await fetchMailboxes(client); if (selectedMailbox) await fetchEmails(client, selectedMailbox); if (total > 0) { toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total })); } else { toast.success(tCtxMenu('mailbox_context_menu.toast_already_read')); } } catch { toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read')); } }; const handleEmptyFolderFromContextMenu = async (mailboxId: string) => { if (!client) return; const mailbox = mailboxes.find(mb => mb.id === mailboxId); if (!mailbox) return; const confirmed = await confirmDialog({ title: tCtxMenu('email_list.empty_folder.confirm_title'), message: tCtxMenu('email_list.empty_folder.confirm_message'), confirmText: tCtxMenu('email_list.empty_folder.confirm_button'), variant: "destructive", }); if (!confirmed) return; try { await emptyMailbox(client, mailboxId); toast.success(tCtxMenu('mailbox_context_menu.toast_emptied')); } catch { toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty')); } }; const handleCreateSubfolderFromContextMenu = async (parentId: string) => { if (!client) return; const name = await promptDialog({ title: tCtxMenu('mailbox_context_menu.new_subfolder'), message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'), placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'), confirmText: tCtxMenu('mailbox_context_menu.create'), }); if (!name) return; try { await createMailbox(client, name, parentId); toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created')); } catch { toast.error(tCtxMenu('mailbox_context_menu.toast_error_create')); } }; const handleCreateFolderFromContextMenu = async () => { if (!client) return; const name = await promptDialog({ title: tCtxMenu('mailbox_context_menu.new_folder'), message: tCtxMenu('mailbox_context_menu.prompt_new_folder'), placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'), confirmText: tCtxMenu('mailbox_context_menu.create'), }); if (!name) return; try { await createMailbox(client, name); toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created')); } catch { toast.error(tCtxMenu('mailbox_context_menu.toast_error_create')); } }; const handleRenameFolderFromContextMenu = async (mailboxId: string) => { if (!client) return; const mailbox = mailboxes.find(mb => mb.id === mailboxId); if (!mailbox) return; const name = await promptDialog({ title: tCtxMenu('mailbox_context_menu.rename'), message: tCtxMenu('mailbox_context_menu.prompt_rename'), placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'), defaultValue: mailbox.name, confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'), }); if (!name || name === mailbox.name) return; try { await renameMailbox(client, mailboxId, name); toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed')); } catch { toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename')); } }; const handleDeleteFolderFromContextMenu = async (mailboxId: string) => { if (!client) return; const mailbox = mailboxes.find(mb => mb.id === mailboxId); if (!mailbox) return; const confirmed = await confirmDialog({ title: tCtxMenu('mailbox_context_menu.delete_confirm_title'), message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }), confirmText: tCtxMenu('mailbox_context_menu.delete_folder'), variant: "destructive", }); if (!confirmed) return; try { await deleteMailbox(client, mailboxId); toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted')); } catch (err: unknown) { const jmapType = (err as Error & { jmapType?: string })?.jmapType; if (jmapType === 'mailboxHasChild') { toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children')); } else if (jmapType === 'mailboxHasEmail') { toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email')); } else { toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete')); } } }; const handleImportEmailFromContextMenu = (mailboxId: string) => { if (!client) return; const mailbox = mailboxes.find(mb => mb.id === mailboxId); if (!mailbox) return; const targetMailboxId = mailbox.originalId || mailbox.id; 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; let emails; try { emails = await expandImportableEmails(files); } catch { toast.error(t('notifications.import_email_error')); return; } let imported = 0; let failed = 0; for (const { blob } of emails) { try { await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }); imported++; } catch { failed++; } } if (imported > 0) { toast.success(t('notifications.import_email_success')); if (selectedMailbox) await fetchEmails(client, selectedMailbox); } if (failed > 0 || (imported === 0 && emails.length === 0)) { toast.error(t('notifications.import_email_error')); } }; input.click(); }; const handleRefreshMailboxes = async () => { if (!client) return; try { await fetchMailboxes(client); if (selectedMailbox) await fetchEmails(client, selectedMailbox); } catch { // silent } }; const handleLogout = logout; const handleSearch = async (query: string) => { if (!client) return; setSearchQuery(query); if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); } else { await searchEmails(client, query); } }; const handleClearSearch = async () => { setSearchQuery(""); clearSearchFilters(); if (!client) return; // In unified view the active "mailbox" is a virtual role or cross view, so // refresh via the unified fan-out instead of fetchEmails. if (isUnifiedView) { const populated = await buildPopulatedUnifiedAccounts(); const role = useEmailStore.getState().unifiedRole; const cross = useEmailStore.getState().crossView; if (role) { await fetchUnifiedEmailsAction(populated, role); } else if (cross) { await fetchCrossViewAction(populated, cross); } return; } if (selectedMailbox) { await fetchEmails(client, selectedMailbox); } }; const handleAdvancedSearch = async () => { if (!client) return; await advancedSearch(client); }; const advancedSearchDebounceRef = useRef(null); const handleAdvancedSearchDebounced = useCallback(() => { if (advancedSearchDebounceRef.current) { clearTimeout(advancedSearchDebounceRef.current); } advancedSearchDebounceRef.current = setTimeout(() => { if (client) advancedSearch(client); }, 300); }, [client, advancedSearch]); useEffect(() => { return () => { if (advancedSearchDebounceRef.current) { clearTimeout(advancedSearchDebounceRef.current); } }; }, []); // Blobs are scoped per JMAP account. In the unified/All-Mail view the open // message may belong to another login (route to its client) or to a delegated // shared account (same client, but the owner's accountId in the download URL). // Resolve both from the email's source so attachments on cross-account // messages can be viewed/downloaded instead of 404ing against the active // account. const resolveBlobSource = useCallback((email: typeof selectedEmail) => { const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined; const blobClient = clientAccountId ? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client) : client; const accountId = isUnifiedView ? email?.sourceAccountId : undefined; return { blobClient, accountId, clientAccountId }; }, [isUnifiedView, client]); const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => { const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail); if (!blobClient) return; try { const { mailAttachmentAction } = useSettingsStore.getState(); if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { setPreviewAttachment({ blobId, name, type, accountId, clientAccountId }); return; } await blobClient.downloadBlob(blobId, name, type, accountId); } catch (error) { console.error("Failed to download attachment:", error); } }; const previewBlobClient = useCallback(() => { const id = previewAttachment?.clientAccountId; return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client; }, [previewAttachment, client]); const handlePreviewAttachmentDownload = useCallback(async () => { const c = previewBlobClient(); if (!c || !previewAttachment) return; await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId); }, [previewBlobClient, previewAttachment]); const getPreviewAttachmentContent = useCallback(async () => { const c = previewBlobClient(); if (!c || !previewAttachment) { throw new Error('No attachment selected'); } const blob = await c.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId); return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream', }; }, [previewBlobClient, previewAttachment]); const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; // Quick reply follows the same addressing rules as the composer: Reply-To // over From, and for our own messages in a thread the original recipients // instead of ourselves (#703). const ownIdentityEmails = identities.map(i => i.email).filter(Boolean); const replySource = { from: selectedEmail.from, replyToAddresses: selectedEmail.replyTo, to: selectedEmail.to, cc: selectedEmail.cc, }; const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to .map(r => r.email) .filter((email): email is string => Boolean(email)); if (recipients.length === 0) { throw new Error("No sender email found"); } const primaryIdentity = identities[0]; const autoSelectReplyIdentity = useSettingsStore.getState().autoSelectReplyIdentity; // Decide the sending identity and (for domain-catch-all) an optional // header From override that matches the address the message was sent to. // Our own message keeps the identity it was sent from - the recipients are // the other party, so resolving from them would send as their address. // When the setting is off, fall through to primary-identity behavior. const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails) ? findDraftIdentityId(identities, selectedEmail.from?.[0]) : null; const resolved: ReplyFromResolution | null = !autoSelectReplyIdentity ? null : selfSentIdentityId ? { identityId: selfSentIdentityId } : resolveReplyFrom(identities, { to: selectedEmail.to, cc: selectedEmail.cc, bcc: selectedEmail.bcc, }); const sendingIdentity = resolved ? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity) : primaryIdentity; const headerFromEmail = resolved?.overrideEmail || sendingIdentity?.email; const headerFromName = resolved?.overrideName || sendingIdentity?.name || undefined; const envelopeMailFrom = resolved?.overrideEmail ? sendingIdentity?.email : undefined; // Append signature from the sending identity (fall back to primary // when the reply-from lives on the same identity but a different alias). const separator = useSettingsStore.getState().signatureSeparatorEnabled; const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator }); // When the identity has an HTML signature, send a matching HTML body so the // signature keeps its formatting; appendPlainTextSignature would otherwise // flatten it to plain text. Text-only identities keep the plain-text-only // behavior (htmlBody stays undefined). const escapedBody = body .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
'); const finalHtmlBody = sendingIdentity?.htmlSignature?.trim() ? appendHtmlSignature(`
${escapedBody}
`, sendingIdentity, { separator }) : undefined; const originalEmailId = selectedEmail.id; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; let delayedUntil: string | undefined; if (sendDelaySeconds > 0) { if (!client.hasDelayedSend()) { const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm')); if (!confirmed) return; } else { delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); } } // RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234). const threading = computeReplyThreadingHeaders({ messageId: selectedEmail.messageId, references: selectedEmail.references, }); // Send reply with just the body text const result = await sendEmail( client, recipients, buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')), finalBody, undefined, undefined, sendingIdentity?.id, headerFromEmail, undefined, headerFromName, finalHtmlBody, undefined, threading?.inReplyTo, threading?.references, delayedUntil, envelopeMailFrom, ); if (result.scheduled) { await refreshScheduledMetadata(client); return; } // Mark the original email as answered. Route the write to the email's own // account so the flag lands on shared/group-mailbox messages instead of // being dropped against the reaching account. (#281) { const s = useEmailStore.getState(); const orig = s.emails.find(e => e.id === originalEmailId); const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; const kwClient = kwClientId ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) : client; try { await kwClient.setKeyword(originalEmailId, '$answered', kwAccountId); } catch (e) { debug.error('Failed to set $answered keyword:', e); } } // Refresh emails to show the sent reply await refreshCurrentMailbox(client); // Re-fetch the replied thread's cross-folder data so the expanded // view shows the newly sent reply without collapsing. const emailState = useEmailStore.getState(); const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { // Route to the email's own account so shared/group threads refresh from // the right server, not the active one. (#281) const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId ? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client) : client; const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId ? repliedEmail.sourceAccountId : client.getAccountId(); const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId); if (fullEmails.length > 0) { useEmailStore.setState((state) => { const c = new Map(state.threadEmailsCache); c.set(repliedEmail.threadId!, fullEmails); return { threadEmailsCache: c }; }); } } }; // Show loading state while checking auth if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) { return (

{t("common.loading")}

); } // Get current mailbox name for mobile header const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : (() => { const mb = mailboxes.find(m => m.id === selectedMailbox); return mb ? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)) : "Inbox"; })(); const isFocusedMailLayout = mailLayout === 'focus'; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout; const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; // Handle email selection with mobile view switching const handleEmailSelect = async (email: { id: string }) => { if (!client || !email) return; // If composing, suspend the composer (unmount will trigger onSaveState) if (showComposer) { setShowComposer(false); } // Find the list-level email for metadata (accountId, scheduled flags, etc.) // but don't select it yet — wait for the full fetch to avoid a toolbar flash // caused by rendering with the stub (no bodyValues) then re-rendering with the full email. const listEmail = activeEmails.find(e => e.id === email.id); setLoadingEmail(true); // On mobile, switch to viewer if (isMobile) { setActiveView("viewer"); } // On tablet, hide the list to maximize viewer space if (isTablet) { setTabletListVisible(false); } // Fetch the full content try { // In unified view each email carries its source reference: the login it is // reachable through (`sourceClientAccountId`) and its owning JMAP account // (`sourceAccountId`). Resolve both so we fetch from the server that actually // owns it — works uniformly for personal and shared/group sources, since for // personal the owning account equals the client's primary (no-op). (#281) const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined; const perAccountClient = sourceClientId ? useAuthStore.getState().getClientForAccount(sourceClientId) : undefined; const fetchClient = perAccountClient ?? client; const accountId = isUnifiedView ? listEmail?.sourceAccountId : (() => { // Non-unified: shared folders on the active client still need their // owner accountId passed explicitly. const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); return mailbox?.isShared ? mailbox.accountId : undefined; })(); const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { if (listEmail?.isScheduled) { fullEmail.scheduledSendAt = listEmail.scheduledSendAt; fullEmail.emailSubmissionId = listEmail.emailSubmissionId; fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId; fullEmail.scheduledUndoStatus = listEmail.scheduledUndoStatus; fullEmail.isScheduled = true; fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled; } // Re-stamp the source reference so later actions on the open email // resolve to the right account (the fetched object lacks these). if (isUnifiedView && listEmail) { fullEmail.accountId = listEmail.accountId; fullEmail.accountLabel = listEmail.accountLabel; fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId; fullEmail.sourceAccountId = listEmail.sourceAccountId; } selectEmail(fullEmail); // Mark-as-read logic is now handled by useEffect } } catch (error) { console.error('Failed to fetch email content:', error); } finally { setLoadingEmail(false); } }; // Handle back navigation from viewer on mobile. // Reset to list state directly. We can't just call window.history.back() // because the nav hook pushes a new entry for every email the user opens, // so history.back() would pop to the previous email rather than the list. // The OS / hardware back button is still wired through popstate → handleNavRestore. const handleMobileBack = () => { if (conversationThread) { setConversationThread(null); setConversationEmails([]); } selectEmail(null); if (isTablet) { setTabletListVisible(true); } setActiveView("list"); }; // Navigate to next/previous email in the list const selectedEmailIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1; const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1 ? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1]) : undefined; const handleNavigatePrev = selectedEmailIndex > 0 ? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1]) : undefined; // Handle opening conversation view on mobile const handleOpenConversation = async (thread: ThreadGroup) => { if (!client) return; setConversationThread(thread); setIsLoadingConversation(true); setActiveView("viewer"); try { // In unified/aggregate views the thread may belong to another (possibly // shared/group) account. Route the fetch to the login it's reachable // through (`sourceClientAccountId`) and pass its owning JMAP account // (`sourceAccountId`) so the thread loads from the right server instead of // the active one (which doesn't have it → empty/body-less). (#281) const ref = thread.emails?.[0]; const threadClient = isUnifiedView && ref?.sourceClientAccountId ? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client) : client; const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined; const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId); // Re-stamp the source reference so conversation actions (reply/move/…) // resolve to the right account; the fetched objects don't carry it. if (isUnifiedView && ref) { for (const e of emails) { e.accountId = ref.accountId; e.accountLabel = ref.accountLabel; e.sourceClientAccountId = ref.sourceClientAccountId; e.sourceAccountId = ref.sourceAccountId; } } setConversationEmails(emails); } catch (error) { console.error('Failed to fetch thread emails:', error); // Fall back to thread.emails setConversationEmails(thread.emails); } finally { setIsLoadingConversation(false); } }; // Handle reply from conversation view const handleConversationReply = async (email: Email) => { selectEmail(email); await prepareComposerQuoteHeader(email, 'reply'); startFreshComposerSession(); setComposerMode('reply'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleConversationReplyAll = async (email: Email) => { selectEmail(email); await prepareComposerQuoteHeader(email, 'replyAll'); startFreshComposerSession(); setComposerMode('replyAll'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleConversationForward = async (email: Email) => { selectEmail(email); await prepareComposerQuoteHeader(email, 'forward'); startFreshComposerSession(); setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => ( ); if (!isAuthenticated) { return null; } return (
{isRateLimited && rateLimitSecondsLeft !== null && (
{tCommon('rate_limited_title')} {tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}
)} {connectionLost && (
{tCommon('reconnecting')}
)}
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */} {!isMobile && !isTablet && !isEmbedded && (
setShowShortcutsModal(true)} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} activeAppId={inlineApp?.id ?? null} />
)} {inlineApp && ( )} {/* Mobile/Tablet Sidebar Overlay Backdrop. When embedded in a Pro pane the viewport is desktop-wide, so the `lg:hidden` viewport-variant alone wouldn't gate this overlay; scope to the pane via `absolute` so the backdrop stays inside the pane instead of covering the whole window. */} {(isMobile || isTablet) && sidebarOpen && !inlineApp && (
setSidebarOpen(false)} /> )} {/* Sidebar - overlay on mobile/tablet, in-flow on desktop. When embedded, overlay-mode is driven by pane-aware JS rather than viewport-variants (which still see the full window). */}
{ startFreshComposerSession(); setComposerMode('compose'); setShowComposer(true); if (isMobile) { setSidebarOpen(false); setActiveView('viewer'); } }} onSidebarClose={() => setSidebarOpen(false)} multiAccountMode={isEmbedded} accountMailboxes={accountMailboxes} viewingAccountId={viewingAccountId} onAccountMailboxSelect={handleAccountMailboxSelect} />
{/* Sidebar resize handle (desktop only, hidden when collapsed) */} {!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && ( { dragStartWidth.current = sidebarWidth; setIsResizing(true); }} onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)} onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }} onDoubleClick={resetSidebarWidth} /> )} {/* Main Content Area */}
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */} {/* Email list resize handle (desktop only) */} {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && ( { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }} onDoubleClick={resetEmailListWidth} /> )} {!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && ( { dragStartWidth.current = emailListHeight; setIsResizing(true); }} onResize={(delta) => setEmailListHeight(dragStartWidth.current + delta)} onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }} onDoubleClick={resetEmailListHeight} /> )} {/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
{/* Inline Composer - shown in viewer pane. In Pro/embedded mode the composer is hoisted into its own Pro tab (see the effect below), so we never render it inline. */} {(showComposer && !isEmbedded) ? ( { setShowComposer(false); setComposerMode('compose'); setComposerQuoteHeader(null); }} > { if (suppressComposerStateSaveSessionRef.current === composerSessionId) { suppressComposerStateSaveSessionRef.current = null; return; } setPendingDraft(data); }} onSend={async (data) => { await handleEmailSend(data); setPendingDraft(null); }} onScheduledSendCreated={async () => { if (client) { await refreshScheduledMetadata(client); if (isScheduledView) await fetchScheduledEmails(client); } setShowComposer(false); setPendingDraft(null); }} onClose={() => { setShowComposer(false); setComposerMode('compose'); setComposerDraftText(""); setPendingDraft(null); setComposerQuoteHeader(null); if (isMobile) { setActiveView('list'); } }} onDiscardDraft={(draftId) => { handleDiscardDraft(draftId); setPendingDraft(null); }} /> ) : ( <> {/* Pending draft banner */} {pendingDraft && ( )} {/* Mobile Conversation View - shown when thread is selected on mobile */} {isMobile && conversationThread ? ( { if (client) { await markAsRead(client, emailId, read); } }} /> ) : ( <> { // Deleting the open message returns to the list (Gmail-style), // not the next email — unless the user turned the setting off. // Deselect first so the store's remove-and-advance sees no // selection and doesn't auto-open the next message. const target = selectedEmail; if (useSettingsStore.getState().returnToListAfterAction) { handleMobileBack(); } handleDelete(target); }} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} onSetTag={handleSetTag} onMarkAsSpam={() => handleMarkAsSpam()} onUndoSpam={() => handleUndoSpam()} onMarkAsRead={async (emailId, read) => { if (client) { await markAsRead(client, emailId, read); // Marking the open message unread returns to the list // (Gmail-style, gated on returnToListAfterAction). Staying // in the reading pane would just re-mark it read on view. if (!read && useSettingsStore.getState().returnToListAfterAction) { handleMobileBack(); } } }} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} onBack={handleMobileBack} onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} onShowShortcuts={() => setShowShortcutsModal(true)} onEditDraft={handleEditDraft} onCancelScheduled={async () => { if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id); }} onCancelScheduledForEdit={async () => { if (!client || !selectedEmail) return; const restored = await cancelScheduledEmailForEdit(client, selectedEmail); if (selectedEmail.isSmimeScheduled) { setComposerMode('compose'); setShowComposer(true); return; } if (restored) await handleEditDraft(restored); }} onRescheduleScheduled={async (delayedUntil) => { if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) { await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, delayedUntil); } }} onCompose={() => { startFreshComposerSession(); setComposerMode('compose'); setShowComposer(true); }} currentUserEmail={client?.getUsername()} currentUserName={client?.getUsername()?.split("@")[0]} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)} mailboxes={mailboxes} selectedMailbox={selectedMailbox} onMoveToMailbox={async (mailboxId) => { if (client && selectedEmail) { await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId); } }} className={isMobile ? "flex-1" : undefined} /> )} )}
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */} {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && ( )}
{/* Keyboard Shortcuts Modal */} setShowShortcutsModal(false)} /> {previewAttachment && ( setPreviewAttachment(null)} onDownload={handlePreviewAttachmentDownload} getFileContent={getPreviewAttachmentContent} /> )} {/* Screen reader live region for dynamic status announcements */}
{pendingMailtoAccountChoice && ( void openMailtoForAccount(pendingMailtoAccountChoice, accountId)} onCancel={() => setPendingMailtoAccountChoice(null)} /> )}
); }