"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 { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; import { useAccountStore } from "@/stores/account-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore } 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 { cn } from "@/lib/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 { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; import { appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { resolveReplyFrom } from "@/lib/reply-identity"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; 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 { useThemeStore } from "@/stores/theme-store"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; export default function Home() { const t = useTranslations(); const tCommon = useTranslations('common'); 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); 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 } | null>(null); const markAsReadTimeoutRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); // 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 { 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, moveThreadToMailbox, searchEmails, searchQuery, setSearchQuery, isLoading, isLoadingEmail, setLoadingEmail, setPushConnected, handleStateChange, clearNewEmailNotification, markAsSpam, undoSpam, searchFilters, isAdvancedSearchOpen, setSearchFilters, clearSearchFilters, toggleAdvancedSearch, advancedSearch, selectedKeyword, selectKeyword, hasMoreEmails, fetchTagCounts, fetchEmailContent, isUnifiedView, fetchUnifiedEmails: fetchUnifiedEmailsAction, refreshUnifiedCounts, exitUnifiedView, emptyMailbox, markMailboxAsRead, createMailbox, renameMailbox, deleteMailbox, batchDelete, batchArchive, batchMarkAsRead, batchMarkAsSpam, batchUndoSpam, } = useEmailStore(); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( () => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","), [accounts], ); const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => { const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected); const clients = useAuthStore.getState().getAllConnectedClients(); const result: UnifiedAccountClient[] = []; for (const account of connected) { const accountClient = clients.get(account.id); if (!accountClient) continue; result.push({ accountId: account.id, accountLabel: account.label || account.email, client: accountClient, mailboxes: [], }); } return result; }, []); const populateUnifiedAccountMailboxes = useCallback( async (list: UnifiedAccountClient[]): Promise => { const populated = await Promise.all( list.map(async (entry) => { try { const mailboxes = await entry.client.getMailboxes(); return { ...entry, mailboxes }; } catch (err) { debug.error('Failed to load mailboxes for unified account', entry.accountId, err); return entry; } }), ); return populated; }, [], ); // 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 = emails; 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 && state.mailboxId !== ctx.selectedMailbox) { 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 (emails.length === 0) return; const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1; const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex; if (nextIndex >= 0 && nextIndex < emails.length) { handleEmailSelect(emails[nextIndex]); } }, onPreviousEmail: () => { if (emails.length === 0) return; const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length; const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0; if (prevIndex >= 0 && prevIndex < emails.length) { handleEmailSelect(emails[prevIndex]); } }, onOpenEmail: () => { // Email is already opened when selected }, onCloseEmail: () => { selectEmail(null); if (isMobile) { setActiveView("list"); } if (isTablet) { setTabletListVisible(true); } }, onReply: () => { if (selectedEmail) handleReply(); }, onReplyAll: () => { if (selectedEmail) handleReplyAll(); }, onForward: () => { if (selectedEmail) handleForward(); }, onToggleStar: () => { if (selectedEmail) handleToggleStar(); }, onArchive: async () => { 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 (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 (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, false); } else if (selectedEmail) { await markAsRead(client, selectedEmail.id, false); } }, onMarkAsRead: async () => { if (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, true); } else if (selectedEmail) { await markAsRead(client, selectedEmail.id, true); } }, onToggleSpam: async () => { const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); 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: () => { setComposerMode('compose'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }, onFocusSearch: () => { const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement; if (searchInput) { searchInput.focus(); searchInput.select(); } }, onShowHelp: () => { setShowShortcutsModal(true); }, onRefresh: async () => { if (client && selectedMailbox) { await fetchEmails(client, selectedMailbox); } }, onSelectAll: () => { selectAllEmails(); }, onDeselectAll: () => { clearSelection(); }, // eslint-disable-next-line react-hooks/exhaustive-deps }), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]); // Initialize keyboard shortcuts useKeyboardShortcuts({ enabled: isAuthenticated && !showComposer, emails, 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(); await Promise.all([ state.fetchMailboxes(client), state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client), ]); }, }); // 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 = mailbox.name; const unreadCount = mailbox.unreadEmails || 0; title = unreadCount > 0 ? `${mailboxName} (${unreadCount}) - ${appName}` : `${mailboxName} - ${appName}`; } } document.title = title; }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); // 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]); // Fallback fetch for paths that didn't go through login()'s prefetch // (notably checkAuth on page refresh). The prefetch in auth-store/login() // populates mailboxes before this effect first runs, so on the post-login // path this block is a no-op. useEffect(() => { if (isAuthenticated && client && mailboxes.length === 0) { let retryTimer: ReturnType | null = null; let cancelled = false; const loadData = async (attempt = 1) => { try { await Promise.all([ fetchMailboxes(client), 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; } if (selectedMailboxId) { await fetchEmails(client, selectedMailboxId); } else { await fetchEmails(client); } fetchTagCounts(client); } catch (error) { console.error('Error loading email data:', error); } }; loadData(); return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer); }; } }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]); // Push notifications: set up once per client and tear down when the client // goes away (logout or account switch). Kept separate from the fetch effect // above so it still runs when data was prefetched at login time. useEffect(() => { if (!isAuthenticated || !client) return; try { client.onStateChange((change) => handleStateChange(change, client)); const pushEnabled = client.setupPushNotifications(); if (pushEnabled) { setPushConnected(true); debug.log('push', '[Push] Push notifications successfully enabled'); } else { debug.log('push', '[Push] Push notifications not available on this server'); } } catch (error) { debug.log('push', '[Push] Failed to setup push notifications:', error); } return () => { client.closePushNotifications(); }; }, [isAuthenticated, client, handleStateChange, setPushConnected]); // 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"). useEffect(() => { if (!enableUnifiedMailbox || !isAuthenticated || !client) return; const built = buildUnifiedAccounts(); if (built.length < 2) return; populateUnifiedAccountMailboxes(built).then((populated) => { refreshUnifiedCounts(populated); }); }, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]); // 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.accountId ? useAuthStore.getState().getClientForAccount(selectedEmail.accountId) : 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) { 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]); // Handle new email notifications - play sound useEffect(() => { if (newEmailNotification) { const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState(); if (emailNotificationsEnabled && emailNotificationSound) { playNotificationSound(notificationSoundChoice); } 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[]; }) => { if (!client) return; try { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; 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.envelopeMailFrom); setShowComposer(false); // Mark the original email with $answered or $forwarded keyword if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { try { await client.setKeyword(originalEmailId, '$answered'); } catch (e) { debug.error('Failed to set $answered keyword:', e); } } else if (originalEmailId && effectiveMode === 'forward') { try { await client.setKeyword(originalEmailId, '$forwarded'); } catch (e) { debug.error('Failed to set $forwarded keyword:', e); } } // Refresh the current mailbox to update the UI await fetchEmails(client, selectedMailbox); } 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); } }; 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; } 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; } const bodyText = draft.bodyValues ? Object.values(draft.bodyValues).map(v => v.value).join('\n') : ''; const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId] ? draft.bodyValues[draft.htmlBody[0].partId].value : undefined; // Try to find the identity that matches the draft's from address to preserve it const draftFromEmail = draft.from?.[0]?.email; const matchedIdentity = draftFromEmail ? identities.find(id => id.email === draftFromEmail) : null; // 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: matchedIdentity?.id ?? null, subAddressTag: '', mode: 'compose', draftId: draft.id, }); setComposerMode('compose'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; 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; } 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; } setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; // Check if we're currently in the trash or junk folder const currentMailbox = 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 const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared) ?? mailboxes.find(m => { if (m.isShared) 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; // 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 currentMailboxes = useEmailStore.getState().mailboxes; 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 client.createMailbox(year, archiveId); await fetchMailboxes(client); } if (archiveMode === 'year') { await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); } else { const yearId = yearMailbox.originalId || yearMailbox.id; const afterYear = useEmailStore.getState().mailboxes; let monthMailbox = afterYear.find( m => m.name === month && m.parentId === yearId ); if (!monthMailbox) { monthMailbox = await client.createMailbox(month, yearId); await fetchMailboxes(client); } await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id); } } if (conversationThread?.threadId === emailToArchive.threadId) { setConversationThread(null); setConversationEmails([]); } void fetchMailboxes(client); } 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 handleSetColorTag = async (emailId: string, color: string | null) => { if (!client) return; try { // Remove any existing label/color tags const email = emails.find(e => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; if (color === null) { // Remove all label/color tags Object.keys(keywords).forEach(key => { if (key.startsWith("$label:") || key.startsWith("$color:")) { keywords[key] = false; } }); } else { const jmapKey = `$label:${color}`; if (keywords[jmapKey] === true) { // Toggle off if already active keywords[jmapKey] = false; } else { // Add the tag without disturbing others keywords[jmapKey] = true; } } // Update email keywords via JMAP await client.updateEmailKeywords(emailId, keywords); // 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 color tag:", error); } }; const handleMailboxSelect = async (mailboxId: string) => { if (isUnifiedMailboxId(mailboxId)) { 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 built = buildUnifiedAccounts(); const populated = await populateUnifiedAccountMailboxes(built); await fetchUnifiedEmailsAction(populated, role); refreshUnifiedCounts(populated); return; } if (isUnifiedView) { exitUnifiedView(); } 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 if (searchQuery) { await searchEmails(client, searchQuery); } else { await fetchEmails(client, mailboxId); } } }; const handleTagSelect = async (keywordId: string | null) => { 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,message/rfc822'; input.multiple = true; input.onchange = async (e) => { const files = Array.from((e.target as HTMLInputElement).files ?? []); if (files.length === 0) return; let imported = 0; let failed = 0; for (const file of files) { try { const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' }); 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) { 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; if (isUnifiedView) return; setSearchQuery(query); if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); } else { await searchEmails(client, query); } }; const handleClearSearch = async () => { setSearchQuery(""); clearSearchFilters(); if (client && selectedMailbox) { await fetchEmails(client, selectedMailbox); } }; const handleAdvancedSearch = async () => { if (!client) return; if (isUnifiedView) return; await advancedSearch(client); }; const advancedSearchDebounceRef = useRef(null); const handleAdvancedSearchDebounced = useCallback(() => { if (advancedSearchDebounceRef.current) { clearTimeout(advancedSearchDebounceRef.current); } advancedSearchDebounceRef.current = setTimeout(() => { if (client && !isUnifiedView) advancedSearch(client); }, 300); }, [client, advancedSearch, isUnifiedView]); useEffect(() => { return () => { if (advancedSearchDebounceRef.current) { clearTimeout(advancedSearchDebounceRef.current); } }; }, []); const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => { if (!client) return; try { const { mailAttachmentAction } = useSettingsStore.getState(); if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { setPreviewAttachment({ blobId, name, type }); return; } await client.downloadBlob(blobId, name, type); } catch (error) { console.error("Failed to download attachment:", error); } }; const handlePreviewAttachmentDownload = useCallback(async () => { if (!client || !previewAttachment) return; await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); }, [client, previewAttachment]); const getPreviewAttachmentContent = useCallback(async () => { if (!client || !previewAttachment) { throw new Error('No attachment selected'); } const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream', }; }, [client, previewAttachment]); const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; const sender = selectedEmail.from?.[0]; if (!sender?.email) { 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. // When the setting is off, fall through to primary-identity behavior. const resolved = autoSelectReplyIdentity ? resolveReplyFrom(identities, { to: selectedEmail.to, cc: selectedEmail.cc, bcc: selectedEmail.bcc, }) : null; 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 finalBody = appendPlainTextSignature(body, sendingIdentity); const originalEmailId = selectedEmail.id; // 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 await sendEmail( client, [sender.email], `Re: ${selectedEmail.subject || "(no subject)"}`, finalBody, undefined, undefined, sendingIdentity?.id, headerFromEmail, undefined, headerFromName, undefined, undefined, threading?.inReplyTo, threading?.references, envelopeMailFrom, ); // Mark the original email as answered try { await client.setKeyword(originalEmailId, '$answered'); } catch (e) { debug.error('Failed to set $answered keyword:', e); } // Refresh emails to show the sent reply await fetchEmails(client, selectedMailbox); }; // Show loading state while checking auth if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) { return (

{t("common.loading")}

); } // Get current mailbox name for mobile header const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "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 && isFocusedMailLayout && !hasViewerContent; 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); } // Show the list stub immediately so subject/sender render without // waiting for the body fetch - avoids the loading flicker. const listEmail = emails.find(e => e.id === email.id); if (listEmail) { selectEmail(listEmail); } 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 own accountId. Use that // account's client so we fetch from the server that actually owns it. const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined; const perAccountClient = emailAccountId ? useAuthStore.getState().getClientForAccount(emailAccountId) : undefined; const fetchClient = perAccountClient ?? client; // For shared folders on the primary client, we still need to pass the // shared account's id. In unified view we use the per-account client // directly, so no explicit accountId is needed. const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = perAccountClient ? undefined : mailbox?.isShared ? mailbox.accountId : undefined; const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { if (emailAccountId) { fullEmail.accountId = emailAccountId; fullEmail.accountLabel = listEmail?.accountLabel; } 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 ? emails.findIndex(e => e.id === selectedEmail.id) : -1; const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1 ? () => handleEmailSelect(emails[selectedEmailIndex + 1]) : undefined; const handleNavigatePrev = selectedEmailIndex > 0 ? () => handleEmailSelect(emails[selectedEmailIndex - 1]) : undefined; // Handle opening conversation view on mobile const handleOpenConversation = async (thread: ThreadGroup) => { if (!client) return; setConversationThread(thread); setIsLoadingConversation(true); setActiveView("viewer"); try { // Fetch complete thread emails const emails = await client.getThreadEmails(thread.threadId); 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 = (email: Email) => { selectEmail(email); setComposerMode('reply'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleConversationReplyAll = (email: Email) => { selectEmail(email); setComposerMode('replyAll'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; const handleConversationForward = (email: Email) => { selectEmail(email); 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 */} {!isMobile && !isTablet && (
setShowShortcutsModal(true)} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} activeAppId={inlineApp?.id ?? null} />
)} {inlineApp && ( )} {/* Mobile/Tablet Sidebar Overlay Backdrop */} {(isMobile || isTablet) && sidebarOpen && !inlineApp && (
setSidebarOpen(false)} /> )} {/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
{ setComposerMode('compose'); setShowComposer(true); if (isMobile) { setSidebarOpen(false); setActiveView('viewer'); } }} onSidebarClose={() => setSidebarOpen(false)} />
{/* 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 && ( { 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 */} {showComposer ? ( { setShowComposer(false); setComposerMode('compose'); }} > setPendingDraft(data)} onSend={async (data) => { await handleEmailSend(data); setPendingDraft(null); }} onClose={() => { setShowComposer(false); setComposerMode('compose'); setComposerDraftText(""); setPendingDraft(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); } }} /> ) : ( <> handleDelete()} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} onSetColorTag={handleSetColorTag} onMarkAsSpam={() => handleMarkAsSpam()} onUndoSpam={() => handleUndoSpam()} onMarkAsRead={async (emailId, read) => { if (client) { await markAsRead(client, emailId, read); } }} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} onBack={handleMobileBack} onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} onShowShortcuts={() => setShowShortcutsModal(true)} onEditDraft={handleEditDraft} onCompose={() => { setComposerMode('compose'); setShowComposer(true); }} currentUserEmail={client?.getUsername()} currentUserName={client?.getUsername()?.split("@")[0]} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} mailboxes={mailboxes} selectedMailbox={selectedMailbox} onMoveToMailbox={async (mailboxId) => { if (client && selectedEmail) { await moveToMailbox(client, selectedEmail.id, mailboxId); } }} className={isMobile ? "flex-1" : undefined} /> )} )}
{/* Bottom Navigation - mobile and tablet */} {(isMobile || isTablet) && activeView !== "viewer" && ( )}
{/* Keyboard Shortcuts Modal */} setShowShortcutsModal(false)} /> {previewAttachment && ( setPreviewAttachment(null)} onDownload={handlePreviewAttachmentDownload} getFileContent={getPreviewAttachmentContent} /> )} {/* Screen reader live region for dynamic status announcements */}
); }