"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 { 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<ComposerDraftData | null>(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<QuoteHeader | null>(null);
  const suppressComposerStateSaveSessionRef = useRef<number | null>(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<ThreadGroup | null>(null);
  const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
  const [isLoadingConversation, setIsLoadingConversation] = useState(false);
  const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
  const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
  const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
  const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
  const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  const lastUndoToastSubmissionRef = useRef<string | null>(null);
  const initialMailLoadClientRef = useRef<object | null>(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<typeof setTimeout> | 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<string | null>(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<UnifiedAccountClient[]> => {
    // 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<typeof setTimeout> | 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)`);
    }

    return () => {
      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=<id>` (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<NodeJS.Timeout | null>(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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/\n/g, '<br>');
    const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
      ? appendHtmlSignature(`<div>${escapedBody}</div>`, 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 (
      <div className="flex h-screen items-center justify-center bg-background">
        <div className="text-center">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
          <p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
        </div>
      </div>
    );
  }

  // 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 }) => (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
        value === true && "bg-primary/10 border-primary/30 text-primary",
        value === false && "bg-muted border-border text-muted-foreground line-through",
        value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
      )}
    >
      {icon}
      {label}
    </button>
  );

  if (!isAuthenticated) {
    return null;
  }

  return (
    <DragDropProvider>
      <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
        <AppTopBannerSlot />
        {isRateLimited && rateLimitSecondsLeft !== null && (
          <div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
            <AlertTriangle className="h-3.5 w-3.5" />
            <span>{tCommon('rate_limited_title')}</span>
            <span className="text-amber-700/80 dark:text-amber-300/80">{tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}</span>
          </div>
        )}
        {connectionLost && (
          <div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
            <RotateCcw className="h-3.5 w-3.5 animate-spin" />
            <span>{tCommon('reconnecting')}</span>
          </div>
        )}
        <div className="flex flex-1 overflow-hidden">
        {/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */}
        {!isMobile && !isTablet && !isEmbedded && (
          <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
            <NavigationRail
              collapsed
              quota={quota}
              isPushConnected={isPushConnected}
              onLogout={handleLogout}
              onShowShortcuts={() => setShowShortcutsModal(true)}
              onManageApps={handleManageApps}
              onInlineApp={handleInlineApp}
              onCloseInlineApp={closeInlineApp}
              activeAppId={inlineApp?.id ?? null}
            />
          </div>
        )}

        {inlineApp && (
          <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
        )}

        {/* 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 && (
          <div
            className={cn(
              "inset-0 bg-black/50 z-40",
              isEmbedded ? "absolute" : "fixed lg:hidden"
            )}
            onClick={() => 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). */}
        <div
          className={cn(
            "flex-shrink-0 h-full z-50",
            !isResizing && "transition-[width] duration-300",
            isEmbedded
              ? (isMobile || isTablet
                  ? cn(
                      "absolute inset-y-0 left-0 w-72 pt-[env(safe-area-inset-top)]",
                      "transform transition-transform duration-300 ease-in-out",
                      !sidebarOpen && "-translate-x-full"
                    )
                  : "relative translate-x-0")
              : cn(
                  // Mobile/Tablet: fixed overlay
                  "max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
                  "max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
                  !sidebarOpen && "max-lg:-translate-x-full",
                  // Desktop: normal flow
                  "lg:relative lg:translate-x-0"
                ),
            inlineApp && "hidden"
          )}
          style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 48 : sidebarWidth } : undefined}
        >
          <ErrorBoundary fallback={SidebarErrorFallback}>
            <Sidebar
              mailboxes={mailboxes}
              selectedMailbox={selectedMailbox}
              selectedKeyword={selectedKeyword}
              scheduledTotal={scheduledTotal}
              showScheduledMailbox={delayedSendSupported}
              crossAccountActive={crossAccountActive}
              showCrossUnread={showCrossUnread}
              showCrossStarred={showCrossStarred}
              showCrossAll={showCrossAll}
              crossUnreadCount={crossUnreadCount}
              onMailboxSelect={handleMailboxSelect}
              onTagSelect={handleTagSelect}
              onUnreadFilterClick={handleUnreadFilterClick}
              onMarkFolderRead={handleMarkFolderRead}
              onMarkFolderTreeRead={handleMarkFolderTreeRead}
              onMarkAllFoldersRead={handleMarkAllFoldersRead}
              onEmptyFolder={handleEmptyFolderFromContextMenu}
              onCreateSubfolder={handleCreateSubfolderFromContextMenu}
              onCreateFolder={handleCreateFolderFromContextMenu}
              onRenameFolder={handleRenameFolderFromContextMenu}
              onDeleteFolder={handleDeleteFolderFromContextMenu}
              onImportEmail={handleImportEmailFromContextMenu}
              onRefreshMailboxes={handleRefreshMailboxes}
              onCompose={() => {
                startFreshComposerSession();
                setComposerMode('compose');
                setShowComposer(true);
                if (isMobile) {
                  setSidebarOpen(false);
                  setActiveView('viewer');
                }
              }}
              onSidebarClose={() => setSidebarOpen(false)}
              multiAccountMode={isEmbedded}
              accountMailboxes={accountMailboxes}
              viewingAccountId={viewingAccountId}
              onAccountMailboxSelect={handleAccountMailboxSelect}
            />
          </ErrorBoundary>
        </div>

        {/* Sidebar resize handle (desktop only, hidden when collapsed) */}
        {!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && (
          <ResizeHandle
            onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
            onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)}
            onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
            onDoubleClick={resetSidebarWidth}
          />
        )}

        {/* Main Content Area */}
        <div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
          <div className={cn("flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
          {/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
          <div
            className={cn(
              "relative flex flex-col bg-background",
              isHorizontalMailLayout
                ? (shouldHideHorizontalViewerPane ? "md:w-full md:min-h-0" : "md:w-full md:h-auto")
                : "h-full border-e border-border",
              // Mobile: full width, hidden when viewing email
              "max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
              isMobile && activeView !== "list" && "max-md:hidden",
              // Tablet/Desktop: fixed width with collapse animation
              !isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-e-0" : "md:flex-shrink-0"),
              isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
              isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
              !isHorizontalMailLayout && "md:shadow-sm",
              !isResizing && "transition-all duration-200 ease-out",
              shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0"
            )}
            style={
              isMobile
                ? undefined
                : isHorizontalMailLayout
                  ? (!shouldHideHorizontalViewerPane ? { height: emailListHeight } : undefined)
                  : (!shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined)
            }
          >
            {/* Mobile Header for List View */}
            <MobileHeader
              title={currentMailboxName}
            />

            {/* Search Bar + Inline Advanced Filters */}
            <div className="border-b border-border bg-background">
              <div className="px-3 h-14 flex items-center">
                <div className="flex items-center gap-1.5 w-full">
                  {/* Select / Select All toggle */}
                  <button
                    type="button"
                    onClick={() => {
                      if (selectedEmailIds.size > 0) {
                        if (selectedEmailIds.size === activeEmails.length) {
                          clearSelection();
                        } else {
                          selectAllEmails();
                        }
                      } else if (activeEmails.length > 0) {
                        const currentId = selectedEmail?.id;
                        const target = currentId && activeEmails.some((e) => e.id === currentId)
                          ? currentId
                          : activeEmails[0].id;
                        toggleEmailSelection(target);
                      }
                    }}
                    disabled={isScheduledView}
                    className={cn(
                      "flex-shrink-0 p-2 rounded-md transition-colors",
                      selectedEmailIds.size > 0
                        ? "bg-primary/10 text-primary"
                        : "text-muted-foreground hover:text-foreground hover:bg-muted"
                    )}
                    title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
                  >
                    {selectedEmailIds.size > 0 ? (
                      <CheckSquare className="w-4 h-4" />
                    ) : (
                      <Square className="w-4 h-4" />
                    )}
                  </button>
                  <form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1">
                    <Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
                    <Input
                      type="text"
                      placeholder={t("sidebar.search_placeholder_hint")}
                      value={searchQuery}
                      onChange={(e) => setSearchQuery(e.target.value)}
                      className={cn("ps-9 h-9", searchQuery && "pe-8")}
                      data-search-input
                      data-tour="search-input"
                      disabled={isScheduledView}
                      title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
                    />
                    {searchQuery && (
                      <button
                        type="button"
                        onClick={handleClearSearch}
                        className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
                        aria-label={t("sidebar.clear_search")}
                      >
                        <X className="w-4 h-4" />
                      </button>
                    )}
                  </form>
                  <button
                    type="button"
                    onClick={toggleAdvancedSearch}
                    disabled={isScheduledView}
                    className={cn(
                      "relative flex-shrink-0 p-2 rounded-md transition-colors",
                      isScheduledView && "opacity-50 cursor-not-allowed",
                      isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
                        ? "bg-primary/10 text-primary"
                        : "text-muted-foreground hover:text-foreground hover:bg-muted"
                    )}
                    title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
                  >
                    <Filter className="w-4 h-4" />
                    {!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
                      <span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
                        {activeFilterCount(searchFilters)}
                      </span>
                    )}
                  </button>
                </div>
              </div>

              {/* Filter Area */}
              {isAdvancedSearchOpen && (
                <div className="px-3 pb-3 space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
                  {/* Quick toggle filters + clear */}
                  <div className="flex items-center justify-between">
                    <div className="flex items-center gap-2 flex-wrap">
                      <ToggleChip
                        icon={<Paperclip className="w-3.5 h-3.5" />}
                        label={t("advanced_search.has_attachment")}
                        value={searchFilters.hasAttachment}
                        onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
                      />
                      <ToggleChip
                        icon={<Star className="w-3.5 h-3.5" />}
                        label={t("advanced_search.starred")}
                        value={searchFilters.isStarred}
                        onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
                      />
                      <ToggleChip
                        icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
                        label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
                        value={searchFilters.isUnread}
                        onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
                      />
                    </div>
                    <div className="flex items-center gap-1">
                      <Button variant="ghost" size="sm" onClick={() => { clearSearchFilters(); setShowAdvancedFields(false); if (client) advancedSearch(client); }} className="h-7 px-2 text-xs text-muted-foreground">
                        <RotateCcw className="w-3 h-3 me-1" />
                        {t("advanced_search.clear")}
                      </Button>
                    </div>
                  </div>

                  {/* "More" expand for advanced fields */}
                  <button
                    type="button"
                    onClick={() => setShowAdvancedFields(!showAdvancedFields)}
                    className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
                  >
                    <ChevronDown className={cn("w-3.5 h-3.5 transition-transform", showAdvancedFields && "rotate-180")} />
                    <span>{t("advanced_search.title")}</span>
                  </button>

                  {/* Advanced fields */}
                  {showAdvancedFields && (
                    <div className="space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
                      <div className="grid grid-cols-2 gap-2">
                        <div>
                          <label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.from")}</label>
                          <Input
                            value={searchFilters.from}
                            onChange={(e) => { setSearchFilters({ from: e.target.value }); handleAdvancedSearchDebounced(); }}
                            placeholder={t("advanced_search.from_placeholder")}
                            className="h-8 text-sm"
                          />
                        </div>
                        <div>
                          <label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.to")}</label>
                          <Input
                            value={searchFilters.to}
                            onChange={(e) => { setSearchFilters({ to: e.target.value }); handleAdvancedSearchDebounced(); }}
                            placeholder={t("advanced_search.to_placeholder")}
                            className="h-8 text-sm"
                          />
                        </div>
                      </div>

                      <div>
                        <label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.subject")}</label>
                        <Input
                          value={searchFilters.subject}
                          onChange={(e) => { setSearchFilters({ subject: e.target.value }); handleAdvancedSearchDebounced(); }}
                          placeholder={t("advanced_search.subject_placeholder")}
                          className="h-8 text-sm"
                        />
                      </div>

                      <div>
                        <label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.body")}</label>
                        <Input
                          value={searchFilters.body}
                          onChange={(e) => { setSearchFilters({ body: e.target.value }); handleAdvancedSearchDebounced(); }}
                          placeholder={t("advanced_search.body_placeholder")}
                          className="h-8 text-sm"
                        />
                      </div>

                      {/* Folder selector */}
                      <div>
                        <label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
                        <select
                          value={selectedMailbox || ""}
                          onChange={(e) => { handleMailboxSelect(e.target.value); }}
                          className="w-full h-8 text-sm rounded-md border border-input bg-background px-3 text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
                        >
                          <option value="">{t("advanced_search.all_folders")}</option>
                          {mailboxes.map((mb) => (
                            <option key={mb.id} value={mb.id}>
                              {mb.name}
                            </option>
                          ))}
                        </select>
                      </div>

                      <div className="grid grid-cols-2 gap-2">
                        <div>
                          <label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.date_after")}</label>
                          <Input
                            type="date"
                            value={searchFilters.dateAfter}
                            onChange={(e) => { setSearchFilters({ dateAfter: e.target.value }); handleAdvancedSearch(); }}
                            className="h-8 text-sm"
                          />
                        </div>
                        <div>
                          <label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.date_before")}</label>
                          <Input
                            type="date"
                            value={searchFilters.dateBefore}
                            onChange={(e) => { setSearchFilters({ dateBefore: e.target.value }); handleAdvancedSearch(); }}
                            className="h-8 text-sm"
                          />
                        </div>
                      </div>
                    </div>
                  )}
                </div>
              )}
            </div>

            {(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
              <div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
                {activeHasMore
                  ? t("advanced_search.results_found_more", { count: activeEmails.length })
                  : t("advanced_search.results_found", { count: activeEmails.length })}
              </div>
            )}

            {isScheduledView && !activeIsLoading && (
              <div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
                {t('email_list.scheduled_count', { count: scheduledTotal })}
              </div>
            )}

            <div className="flex-1 min-h-0 flex flex-col">
            {/* Plugin-registered category tabs (Gmail-style). Renders nothing
                unless an enabled plugin registered tabs via api.tabs.set. */}
            {!isScheduledView && <MessageListTabs />}
            <WelcomeBanner />

            <ErrorBoundary fallback={EmailListErrorFallback}>
              <EmailList
                emails={activeEmails}
                selectedEmailId={selectedEmail?.id}
                isLoading={activeIsLoading}
                hasMore={activeHasMore}
                isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
                isScheduledView={isScheduledView}
                onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
                onCancelScheduled={async (email) => {
                  if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
                }}
                onCancelScheduledForEdit={async (email) => {
                  if (!client) return;
                  const restored = await cancelScheduledEmailForEdit(client, email);
                  if (email.isSmimeScheduled) {
                    setComposerMode('compose');
                    setPendingDraft(null);
                  } else if (restored) {
                    await handleEditDraft(restored);
                    return;
                  }
                  setShowComposer(true);
                  if (isMobile) setActiveView('viewer');
                }}
                onRescheduleScheduled={async (email) => {
                  const delayedUntil = promptForRescheduleDelayedUntil();
                  if (delayedUntil && client && email.emailSubmissionId && email.scheduledIdentityId) {
                    await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil);
                  }
                }}
                onEmailSelect={handleEmailSelect}
                onEmailDoubleClick={isEmbedded ? ((email) => {
                  useProTabStore.getState().openEmailTab({
                    accountId: email.accountId ?? '',
                    emailId: email.id,
                    mailboxId: selectedMailbox,
                    title: email.subject?.trim() || t('email_composer.new_message'),
                  });
                }) : undefined}
                onOpenConversation={handleOpenConversation}
                // Context menu handlers
                onReply={(email) => {
                  selectEmail(email);
                  handleReply();
                }}
                onReplyAll={(email) => {
                  selectEmail(email);
                  handleReplyAll();
                }}
                onForward={(email) => {
                  selectEmail(email);
                  handleForward();
                }}
                onForwardAsAttachment={(email) => {
                  selectEmail(email);
                  handleForwardAsAttachment(email);
                }}
                onMarkAsRead={async (email, read) => {
                  if (client) {
                    await markAsRead(client, email.id, read);
                  }
                }}
                onToggleStar={async (email) => {
                  if (client) {
                    await toggleStar(client, email.id);
                  }
                }}
                onTogglePinned={async (email) => {
                  await handleTogglePinned(email);
                }}
                onDelete={async (email) => {
                  await handleDelete(email);
                }}
                onArchive={async (email) => {
                  await handleArchive(email);
                }}
                onSetTag={(emailId, color) => {
                  handleSetTag(emailId, color);
                }}
                onMoveToMailbox={async (emailId, mailboxId) => {
                  if (client) {
                    await moveToMailboxCrossAware(client, emailId, mailboxId);
                  }
                }}
                onMarkAsSpam={async (email) => {
                  await handleMarkAsSpam(email);
                }}
                onUndoSpam={async (email) => {
                  await handleUndoSpam(email);
                }}
                onEditDraft={(email) => {
                  handleEditDraft(email);
                }}
                className="flex-1 min-h-0"
              />
            </ErrorBoundary>
            </div>

            {/* Floating Compose Button */}
            <Button
              onClick={() => {
                startFreshComposerSession();
                setComposerMode('compose');
                setShowComposer(true);
                if (isMobile) setActiveView('viewer');
              }}
              className={cn(
                "absolute z-40 rounded-full shadow-lg",
                isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12"
              )}
              aria-label={t('sidebar.compose')}
              title={t('sidebar.compose_hint')}
              data-tour="compose-button"
            >
              <PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
            </Button>
          </div>

          {/* Email list resize handle (desktop only) */}
          {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
            <ResizeHandle
              onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
              onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
              onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
              onDoubleClick={resetEmailListWidth}
            />
          )}
          {!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && (
            <ResizeHandle
              orientation="horizontal"
              onResizeStart={() => { 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 */}
          <div
            className={cn(
              "flex flex-col bg-background flex-1 min-w-0",
              isHorizontalMailLayout ? "min-h-0" : "h-full",
              // Mobile: full screen overlay when active
              "max-md:fixed max-md:inset-0 max-md:z-30",
              "max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
              isMobile && activeView !== "viewer" && "max-md:hidden",
              // Tablet/Desktop: relative
              "md:relative",
              shouldHideViewerPane && "md:hidden",
              shouldHideHorizontalViewerPane && "md:hidden"
            )}
          >
            {/* 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) ? (
              <ErrorBoundary
                fallback={ComposerErrorFallback}
                onReset={() => {
                  setShowComposer(false);
                  setComposerMode('compose');
                  setComposerQuoteHeader(null);
                }}
              >
                <EmailComposer
                  key={composerSessionId}
                  mode={pendingDraft?.mode ?? composerMode}
                  composeFromAccountEmail={
                    useAccountStore
                      .getState()
                      .getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
                  }
                  replyTo={pendingDraft !== null ? 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)}
                  initialDraftText={composerDraftText}
                  initialData={pendingDraft}
                  onSaveState={(data) => {
                    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);
                  }}
                />
              </ErrorBoundary>
            ) : (
            <>
            {/* Pending draft banner */}
            {pendingDraft && (
              <button
                onClick={() => {
                  setShowComposer(true);
                  if (isMobile) setActiveView('viewer');
                }}
                className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start"
              >
                <PenLine className="w-4 h-4 text-primary shrink-0" />
                <div className="flex-1 min-w-0">
                  <span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
                  {pendingDraft.subject && (
                    <span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
                  )}
                </div>
                <X
                  className="w-4 h-4 text-muted-foreground hover:text-foreground shrink-0"
                  onClick={async (e) => {
                    e.stopPropagation();
                    const confirmed = await confirmDialog({
                      title: t('email_composer.discard_draft_title'),
                      message: t('email_composer.discard_draft_confirm'),
                      confirmText: t('email_composer.discard'),
                      variant: "destructive",
                    });
                    if (confirmed) {
                      setPendingDraft(null);
                    }
                  }}
                />
              </button>
            )}
            {/* Mobile Conversation View - shown when thread is selected on mobile */}
            {isMobile && conversationThread ? (
              <ThreadConversationView
                thread={conversationThread}
                emails={conversationEmails}
                isLoading={isLoadingConversation}
                onBack={handleMobileBack}
                onReply={handleConversationReply}
                onReplyAll={handleConversationReplyAll}
                onForward={handleConversationForward}
                onDownloadAttachment={handleDownloadAttachment}
                onMarkAsRead={async (emailId, read) => {
                  if (client) {
                    await markAsRead(client, emailId, read);
                  }
                }}
              />
            ) : (
              <>
                <ErrorBoundary fallback={EmailViewerErrorFallback}>
                  <EmailViewer
                    email={selectedEmail}
                    isLoading={isLoadingEmail}
                    onReply={handleReply}
                    onReplyAll={handleReplyAll}
                    onForward={handleForward}
                    onForwardAsAttachment={handleForwardAsAttachment}
                    onDelete={() => {
                      // 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}
                  />
                </ErrorBoundary>
              </>
            )}
            </>
            )}
          </div>
          </div>

          {/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
          {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
            <NavigationRail
              orientation="horizontal"
              onManageApps={handleManageApps}
              onInlineApp={handleInlineApp}
              onCloseInlineApp={closeInlineApp}
              activeAppId={inlineApp?.id ?? null}
            />
          )}
        </div>
        </div>

        {/* Keyboard Shortcuts Modal */}
        <KeyboardShortcutsModal
          isOpen={showShortcutsModal}
          onClose={() => setShowShortcutsModal(false)}
        />

        {previewAttachment && (
          <FilePreviewModal
            name={previewAttachment.name}
            onClose={() => setPreviewAttachment(null)}
            onDownload={handlePreviewAttachmentDownload}
            getFileContent={getPreviewAttachmentContent}
          />
        )}

        {/* Screen reader live region for dynamic status announcements */}
        <div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />

        <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
        {pendingMailtoAccountChoice && (
          <ProtocolAccountPicker
            kind="mailto"
            operation={pendingMailtoAccountChoice}
            accounts={getMailtoProtocolAccounts()}
            activeAccountId={activeAccountId}
            isSwitching={isProtocolAccountSwitching}
            onSelect={(accountId) => void openMailtoForAccount(pendingMailtoAccountChoice, accountId)}
            onCancel={() => setPendingMailtoAccountChoice(null)}
          />
        )}
        <ConfirmDialog {...confirmDialogProps} />
        <PromptDialog {...promptDialogProps} />
        <TotpReauthDialog />
      </div>
    </DragDropProvider>
  );
}
