src/App.tsx

import { readNotifications, saveNotifications } from "./notifications";
import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type MouseEvent as ReactMouseEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { inboxTab, inboxTabs, type InboxTab } from "../shared/inbox-tabs";
import type { Draft, Mail, State } from "../shared/types";
import { emptyComposePreferences, type ComposePreferences } from "../shared/mail-features";
import { initialCompose } from "./compose/ComposePreferences";
import { ApiError, coalesceReload } from "./api";
import { applyAppearance, hexColor, readAppearance, resolveScheme, saveAppearance } from "./appearance";
import type { ThemePreview } from "./themes";
import { ComposePanel, sendCompose, type Compose } from "./compose/ComposePanel";
import { DraftPicker } from "./compose/DraftPicker";
import { DraftSession, type DraftProgress } from "./compose/draft-session";
import { ConnectDialog, ProviderChoices, type ImapProvider } from "./dialogs/ConnectDialog";
import { LabelForm } from "./dialogs/LabelForm";
import { ShortcutsDialog } from "./dialogs/ShortcutsDialog";
import { SnoozeDialog } from "./dialogs/SnoozeDialog";
import { TeachDialog } from "./dialogs/TeachDialog";
import { TrashDialog } from "./dialogs/TrashDialog";
import { CommandPalette, type PaletteCommand } from "./dialogs/CommandPalette";
import { interactionActions, matches, read as readInteraction, save as saveInteraction, shortcutLabel } from "./interaction";
import { InboxTabs } from "./mail/InboxTabs";
import { ListEmpty } from "./mail/ListEmpty";
import { MailActions } from "./mail/MailActions";
import { MailFilters } from "./mail/MailFilters";
import { MailList } from "./mail/MailList";
import { Reader, ReaderEmpty } from "./mail/Reader";
import { SenderPanel } from "./mail/SenderPanel";
import { tabName, viewNames, viewOrder, type View } from "./mail/format";
import { customLabels, densityShortcut, filterMail, matchesLabel, markMailRead } from "./mail-ui";
import { reuseMailState } from "./mail-state";
import { compileMailSearch } from "./mail-search";
import { useMailNavigation, type MailLocation } from "./navigation";
import { collectPrivateNames, redactPrivateText } from "./privacy";
import { SettingsPage } from "./settings/SettingsPage";
import { PaneHeader, type Corner } from "./shell/PaneHeader";
import { Sidebar } from "./shell/Sidebar";
import { UpdateNotice } from "./shell/UpdateNotice";
import { ContextMenu, mailMenuRequest, type MenuAction } from "./ui/ContextMenu";
import { Dialog } from "./ui/Dialog";
import { MailPrivacy, PrivacyContext, Redact } from "./ui/Privacy";
import { drawsWindowControls, systemCaption } from "./ui/TrafficLights";
import { useMedia } from "./ui/useMedia";
import { CredentialFallbackNotice } from "./shell/CredentialFallbackNotice";
import { ToastStack, useToasts } from "./ui/Toasts";
import { ArrowLeft, ArrowRight, Close, Eye, EyeOff, PanelRight, Search, SidebarIcon } from "./ui/icons";

// Direction the list pane enters from: tabs slide sideways, mail opens upward.
type Motion = "forward" | "back" | "open" | "close";

const empty: State = {
  accounts: [],
  messages: [],
  labels: [],
  rules: [],
  googleConfigured: false,
  googleClientId: null,
  jevConfigured: false,
  jevProvider: "typesafe",
  aiConfigured: false,
  lastSync: null,
  syncing: false,
};

export function App() {
  const [state, setState] = useState(empty);
  const navigation = useMailNavigation();
  const { page, settingsSection, view, tab, accountFilter, labelFilter, selectedId, query, opened, readFilter, relevanceFilter, sort } = navigation.location;
  const setSelectedId = (selectedId: string | null) =>
    navigation.update((current) => ({ ...current, selectedId }));
  const setOpened = (opened: boolean) =>
    navigation.update((current) => ({ ...current, opened }));
  const [interaction, setInteraction] = useState(readInteraction);
  const shortcut = (action: typeof interactionActions[number]["id"]) => shortcutLabel(interaction.shortcuts[action]);
  const [searching, setSearching] = useState(false);
  const settings = page === "settings";
  const setSettings = (next: boolean | ((current: boolean) => boolean)) => {
    const value = typeof next === "function" ? next(settings) : next;
    navigate({ page: value ? "settings" : "mail", opened: false });
  };
  const setSettingsSection = (settingsSection: MailLocation["settingsSection"]) => navigate({ settingsSection });
  const setLabelFilter = (labelFilter: string) => navigation.update(current => ({ ...current, labelFilter }));
  const [labelMail, setLabelMail] = useState<Mail | null>(null);
  const [contextMenu, setContextMenu] = useState<ReturnType<typeof mailMenuRequest> | null>(null);
  const closeContextMenu = useCallback(() => setContextMenu(null), []);
  const [connecting, setConnecting] = useState<ImapProvider | "choose" | null>(null);
  const [compose, setCompose] = useState<Compose | null>(null);
  const [draftPicker, setDraftPicker] = useState(false);
  const [draftProgress, setDraftProgress] = useState<DraftProgress>({ phase: "idle", message: "Draft saves on this device as you type." });
  const draftSession = useRef<DraftSession | null>(null);
  const draftLock = useRef(false);
  const draftTask = useRef<Promise<void> | undefined>(undefined);
  const [updateInstalling, setUpdateInstalling] = useState(false);
  const sendTask = useRef<Promise<void> | undefined>(undefined);
  const [teach, setTeach] = useState<Mail | null>(null);
  const [snooze, setSnooze] = useState<Mail | null>(null);
  const [trashMail, setTrashMail] = useState<Mail | null>(null);
  const [shortcuts, setShortcuts] = useState(false);
  const [palette, setPalette] = useState(false);
  const [settingsModal, setSettingsModal] = useState(false);
  useEffect(() => { if (updateInstalling) setPalette(false); }, [updateInstalling]);
  const navigationBlocked = !!(compose || draftPicker || teach || snooze || trashMail || shortcuts || labelMail || connecting || updateInstalling || settingsModal);
  const navigate = (patch: Partial<MailLocation>, search = false) => {
    if (navigationBlocked) return false;
    navigation.visit(patch, search);
    return true;
  };
  const travel = (direction: -1 | 1) => {
    if (navigationBlocked || contextMenu || palette) return;
    const active = document.activeElement;
    const restoreFocus = active === document.body || active instanceof HTMLElement && !!active.closest(".row, .message");
    navigation.travel(direction, (target) => {
      const accountExists = target.accountFilter === "all" ||
        state.accounts.some((account) => account.id === target.accountFilter);
      const mailExists = state.messages.some((mail) => mail.id === target.selectedId &&
        state.accounts.some((account) => account.id === mail.accountId));
      const mail = state.messages.find((mail) => mail.id === target.selectedId);
      if (target.opened && mailExists && mail) markRead(mail);
      if (restoreFocus && target.opened !== opened) {
        readerFocusPending.current = target.opened && mailExists;
        listFocusPending.current = !target.opened && opened;
      }
      setSearching(!!target.query);
      return { ...target, accountFilter: accountExists ? target.accountFilter : "all",
        selectedId: mailExists ? target.selectedId : null, opened: target.opened && mailExists };
    });
  };
  useEffect(() => {
    const native = !!window.desktop?.onNavigation &&
      ["win32", "linux"].includes(window.desktop.platform);
    const unsubscribe = native ? window.desktop?.onNavigation((direction) =>
      travel(direction === "back" ? -1 : 1)) : undefined;
    const mouse = (event: MouseEvent) => {
      if (event.button !== 3 && event.button !== 4) return;
      event.preventDefault();
      const element = event.target instanceof Element ? event.target : null;
      const guarded = element?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="dialog"], [role="menu"], [data-update-command], .update-notice, .credential-fallback-notice');
      if (!native && !guarded && event.type === "mouseup") travel(event.button === 3 ? -1 : 1);
    };
    window.addEventListener("mousedown", mouse, true);
    window.addEventListener("mouseup", mouse, true);
    window.addEventListener("auxclick", mouse, true);
    return () => {
      unsubscribe?.();
      window.removeEventListener("mousedown", mouse, true);
      window.removeEventListener("mouseup", mouse, true);
      window.removeEventListener("auxclick", mouse, true);
    };
  });
  const [motion, setMotion] = useState<Motion>("forward");
  // Privacy blurs email addresses; re-enabling it hides revealed ones again.
  const [privacy, setPrivacy] = useState(
    () => localStorage.getItem("privacy") === "on",
  );
  const [revealed, setRevealed] = useState<Set<string>>(() => new Set());
  // Clicking an address toggles just that one; the eye hides every revealed one.
  const toggleAddress = useCallback((address: string) =>
    setRevealed((current) => {
      const next = new Set(current);
      if (!next.delete(address)) next.add(address);
      return next;
    }), []);
  const togglePrivacy = () => {
    localStorage.setItem("privacy", privacy ? "off" : "on");
    if (!privacy) setRevealed(new Set());
    setPrivacy(!privacy);
  };
  const [maximized, setMaximized] = useState(false);
  useEffect(() => window.desktop?.onWindowState(setMaximized), []);
  const [windowSide, setWindowSide] = useState<"left" | "right">(() =>
    localStorage.getItem("window-controls") === "right" ? "right" : "left",
  );
  const placeWindowControls = (side: "left" | "right") => {
    localStorage.setItem("window-controls", side);
    setWindowSide(side);
  };
  const listRef = useRef<HTMLDivElement>(null);
  const readerRef = useRef<HTMLDivElement>(null);
  const readerFocusPending = useRef(false);
  const listFocusPending = useRef(false);
  // The sidebar stays open or closed between launches.
  const [sidebarOpen, setSidebarOpen] = useState(() => localStorage.getItem("sidebar") !== "closed");
  const toggleSidebar = () => {
    localStorage.setItem("sidebar", sidebarOpen ? "closed" : "open");
    setSidebarOpen(!sidebarOpen);
  };
  // The Filters popover; its button stays lit while a filter or sort differs from its default.
  const [filtersOpen, setFiltersOpen] = useState(false);
  const [appearance, setAppearance] = useState(readAppearance);
  const [themePreview, setThemePreview] = useState<ThemePreview | null>(null);
  const prefersLight = useMedia("(prefers-color-scheme: light)", false);
  const scheme = resolveScheme(appearance.mode, prefersLight);
  useLayoutEffect(() => {
    applyAppearance(appearance, scheme, document.documentElement, themePreview);
    // System caption buttons take the pane surface and text color beneath them.
    if (!systemCaption) return;
    const tokens = window.getComputedStyle(document.documentElement);
    const color = hexColor(tokens.getPropertyValue("--surface"));
    const symbolColor = hexColor(tokens.getPropertyValue("--text"));
    if (color && symbolColor) window.desktop?.setOverlay?.({ color, symbolColor });
  }, [appearance, scheme, themePreview]);
  // Panes that need room drop out as the window narrows: the sender panel, then
  // the reader beside the list (mail then opens in place), then the sidebar.
  const roomy = useMedia("(min-width: 760px)");
  const splitRoom = useMedia("(min-width: 980px)");
  const wide = useMedia("(min-width: 1180px)");
  const wider = useMedia("(min-width: 1440px)");
  const split = appearance.reading === "split" && splitRoom;
  const senderRoom = split ? wider : wide;
  const sidebarShown = sidebarOpen && roomy;
  const [senderPanel, setSenderPanel] = useState(() => localStorage.getItem("sender-panel") !== "closed");
  const toggleSenderPanel = () => {
    localStorage.setItem("sender-panel", senderPanel ? "closed" : "open");
    setSenderPanel(!senderPanel);
  };
  const contextShown = !settings && senderPanel && senderRoom;
  const [density, setDensity] = useState<"comfortable" | "compact">(
    "comfortable",
  );
  const [busy, setBusy] = useState(false);
  const [syncing, setSyncing] = useState(false);
  const [notifications, setNotifications] = useState(readNotifications);
  const { toasts, push: pushToast, dismiss: dismissToast } = useToasts(notifications);
  const setNotice = useCallback((text: string) => { if (text) pushToast(text, "error"); }, [pushToast]);
  const arrivals = useRef<number | undefined>(undefined);
  const [loaded, setLoaded] = useState(false);
  const [recent, setRecent] = useState<string[]>([]);
  const csrf = useRef("");
  const pendingReads = useRef(new Set<string>());
  const [reading, setReading] = useState(new Set<string>());
  const trashLock = useRef(false);
  const loadRevision = useRef(0);
  const syncLock = useRef(false);
  const sendLock = useRef(false);
  const searchRef = useRef<HTMLInputElement>(null);
  const api = useCallback(
    async <T,>(url: string, method = "GET", body?: unknown): Promise<T> => {
      const res = await fetch(`/api${url}`, {
        method,
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": csrf.current,
        },
        body: body === undefined ? undefined : JSON.stringify(body),
      });
      const data = (await res.json()) as T & { error?: string };
      if (!res.ok) throw new ApiError(res.status, data.error || "Request failed. Try again.");
      return data;
    },
    [],
  );
  const openCompose = (value: Compose | Draft) => {
    const session = new DraftSession(api, value, (progress) => {
      if (draftSession.current === session) {
        setDraftProgress(progress);
        setCompose(session.content);
      }
    });
    draftSession.current = session;
    setCompose(session.content);
    setDraftProgress({ phase: "idle", message: "Draft saves on this device as you type." });
    session.change(session.content);
  };
  const editCompose = (value: Compose) => {
    const session = draftSession.current;
    if (!session || sendLock.current || draftLock.current || updateInstalling) return;
    if (value.accountId !== session.content.accountId) {
      if (session.dirty) return;
      if (session.stored && !confirm("Keep the existing draft and start a copy from the selected account?")) return;
      openCompose(value);
    } else {
      session.change(value);
      setCompose(session.content);
    }
  };
  const runDraftAction = (task: (session: DraftSession) => Promise<void>) => {
    const session = draftSession.current;
    if (!session || busy || sendLock.current || draftLock.current || updateInstalling) return;
    draftLock.current = true;
    setBusy(true);
    draftTask.current = task(session).catch(() => {
      setNotice("Draft action could not be confirmed. Your text is still open. Check the draft save status before closing.");
    }).finally(() => { draftLock.current = false; setBusy(false); });
  };
  const closeCompose = () => runDraftAction(async (session) => {
    await session.flush();
    draftSession.current = null;
    setCompose(null);
  });
  const reload = useMemo(() => coalesceReload(async () => {
    const revision = ++loadRevision.current;
    const data = await api<State & { csrf: string }>("/state");
    csrf.current = data.csrf;
    if (revision !== loadRevision.current) return data;
    if (data.unreadArrivals !== undefined) {
      if (arrivals.current !== undefined && data.unreadArrivals > arrivals.current) {
        const count = data.unreadArrivals - arrivals.current;
        pushToast(`${count} new unread ${count === 1 ? "email" : "emails"}.`, "info", `arrival:${revision}`);
      }
      arrivals.current = data.unreadArrivals;
    }
    setState((current) => reuseMailState(current, data));
    setSyncing(data.syncing || syncLock.current);
    setLoaded(true);
    return data;
  }), [api, pushToast]);
  const sync = useCallback(async () => {
    if (syncLock.current) return;
    syncLock.current = true;
    setSyncing(true);
    try {
      const result = await api<{ errors: string[] }>("/sync", "POST");
      if (result.errors.length) setNotice(result.errors.join("\n"));
    } catch (error) {
      setNotice(error instanceof Error ? error.message : "Sync failed.");
    } finally {
      syncLock.current = false;
      try { await reload(); }
      catch (error) {
        setSyncing(false);
        setNotice(error instanceof Error ? error.message : "Sync failed.");
      }
    }
  }, [api, reload]);
  useEffect(() => {
    if (!syncing || window.desktop?.onMailChanged) return;
    let stopped = false;
    let timer: ReturnType<typeof setTimeout>;
    const poll = async () => {
      try { await reload(); }
      catch (error) { if (!stopped) setNotice(error instanceof Error ? error.message : "Sync failed."); }
      if (!stopped) timer = setTimeout(() => void poll(), 1000);
    };
    timer = setTimeout(() => void poll(), 1000);
    return () => { stopped = true; clearTimeout(timer); };
  }, [syncing, reload]);
  useEffect(() => {
    const params = new URLSearchParams(location.search);
    if (params.get("error")) {
      setNotice(params.get("error")!);
      setSettings(true);
    }
    if (params.has("connected") || params.has("error"))
      history.replaceState({}, "", "/");
    void reload()
      .then((data) => {
        if (data.accounts.length && !params.has("connected")) void sync();
      })
      .catch((error) => setNotice(error.message));
  }, [reload, sync]);
  useEffect(() => {
    if (window.desktop || !state.accounts.length) return;
    const timer = setInterval(() => void sync(), 300000);
    return () => clearInterval(timer);
  }, [state.accounts.length, sync]);
  useEffect(
    () =>
      window.desktop?.onAccountConnected((error) => {
        if (error) setNotice(error);
        else {
          setConnecting(current => current === "choose" ? null : current);
          void reload()
            .catch((error) => setNotice(error.message));
        }
      }),
    [reload],
  );
  useEffect(
    () =>
      window.desktop?.onMailChanged(() => {
        void reload().catch((error) => setNotice(error.message));
      }),
    [reload],
  );
  useEffect(() => window.desktop?.updates?.onDrain(async () => {
    await sendTask.current;
    await draftTask.current;
    await draftSession.current?.flush();
    // Let the send result update the draft/unload guard before native close.
    await new Promise<void>((resolve) => setTimeout(resolve, 0));
  }), []);
  const showSearch = () => {
    if (settings) setSettings(false);
    setSearching(true);
    requestAnimationFrame(() => searchRef.current?.focus());
  };
  const closeSearch = () => {
    if (navigationBlocked) return;
    setSearching(false);
    navigate({ query: "", opened: false }, true);
  };
  const switchView = (next: View) => {
    if (next !== view)
      setMotion(
        viewOrder.indexOf(next) > viewOrder.indexOf(view) ? "forward" : "back",
      );
    navigate({ view: next, opened: false, selectedId: null });
  };
  const switchTab = (next: InboxTab) => {
    const order = inboxTabs.map(({ id }) => id);
    if (view === "inbox" && next !== tab)
      setMotion(order.indexOf(next) > order.indexOf(tab) ? "forward" : "back");
    navigate({ view: "inbox", tab: next, opened: false, selectedId: null });
  };
  const nextTab = () => {
    const index = inboxTabs.findIndex(({ id }) => id === tab);
    switchTab(inboxTabs[(index + 1) % inboxTabs.length].id);
  };
  const hasListControls = accountFilter !== "all" || labelFilter !== "all" || !!query ||
    readFilter !== "all" || relevanceFilter !== "all" || sort !== "newest";
  const resetListControls = () => {
    if (navigate({ accountFilter: "all", labelFilter: "all", query: "", readFilter: "all",
      relevanceFilter: "all", sort: "newest", selectedId: null, opened: false })) setSearching(false);
  };
  const labels = state.labels.filter((label) =>
    accountFilter === "all" || label.accountId === accountFilter);
  const activeLabel = labels.find((label) => `${label.accountId}:${label.id}` === labelFilter);
  useEffect(() => {
    if (labelFilter !== "all" && !activeLabel) setLabelFilter("all");
  }, [labelFilter, activeLabel]);
  const now = Date.now();
  const isSnoozed = (m: Mail) =>
    m.snoozedUntil !== null && m.snoozedUntil > now;
  const tabs = useMemo(() => new Map(state.messages.map((mail) => [mail.id, inboxTab(mail, state.rules)])),
    [state.messages, state.rules]);
  const tabOf = (mail: Mail) => tabs.get(mail.id) ?? inboxTab(mail, state.rules);
  const search = useMemo(() => compileMailSearch(query, state.accounts), [query, state.accounts]);
  const filtered = filterMail(state.messages, navigation.location, activeLabel, now, undefined, tabOf, search.matches);
  const selected = opened
    ? state.messages.find((m) => m.id === selectedId &&
        state.accounts.some((account) => account.id === m.accountId))
    : filtered.find((m) => m.id === selectedId) || filtered[0];
  const accountOf = (m: Mail) =>
    state.accounts.find((a) => a.id === m.accountId);
  const selectedAccount = selected && accountOf(selected);
  // Inbox total for the sidebar and unread counts for each category tab.
  const inboxMail = state.messages.filter((m) => !m.archived && !isSnoozed(m) &&
    matchesLabel(m, activeLabel) && (accountFilter === "all" || accountFilter === m.accountId));
  const tabUnread = new Map<InboxTab, number>();
  for (const m of inboxMail) if (m.unread) tabUnread.set(tabOf(m), (tabUnread.get(tabOf(m)) ?? 0) + 1);
  const snoozedCount = state.messages.filter(
    (m) => !m.archived && isSnoozed(m) && matchesLabel(m, activeLabel),
  ).length;
  const perform = async (task: () => Promise<unknown>, success?: string) => {
    if (busy) return;
    setBusy(true);
    try {
      await task();
      if (success) {
        pushToast(success, "success");
        try { await reload(); }
        catch { setNotice(`${success} Inbox refresh failed. Refresh the inbox separately.`); }
      } else await reload();
    } catch (error) {
      setNotice(error instanceof Error ? error.message : "Action failed.");
    } finally {
      setBusy(false);
    }
  };
  const changeMessage = (
    mail: Mail,
    action: "read" | "archive" | "snooze" | "unsnooze" | "pin" | "unpin",
    until?: number,
  ) =>
    perform(() =>
      api(`/messages/${encodeURIComponent(mail.id)}`, "PATCH", {
        action,
        until,
      }),
      action === "archive" ? "Email archived." : action === "unsnooze" ? "Email returned to inbox." : undefined,
    );
  const markRead = (mail: Mail) => {
    void markMailRead(mail, pendingReads.current, async () => {
      setReading(new Set(pendingReads.current));
      await api(`/messages/${encodeURIComponent(mail.id)}`, "PATCH", { action: "read" });
      // Ignore state snapshots requested before the provider confirmed this read.
      ++loadRevision.current;
      setState((current) => ({ ...current, messages: current.messages.map((message) =>
        message.id === mail.id && message.accountId === mail.accountId && message.remoteId === mail.remoteId
          ? { ...message, unread: false, labelIds: message.labelIds?.filter((id) => id !== "UNREAD") }
          : message) }));
      void reload().catch(() => setNotice("Email marked as read. Inbox refresh failed. Refresh the inbox separately."));
    }).catch((error: unknown) => setNotice(
      error instanceof Error ? `${error.message} Use Mark as read or reopen the email to retry.`
        : "Could not mark this email as read. Use Mark as read or reopen it to retry.",
    )).finally(() => setReading(new Set(pendingReads.current)));
  };
  const trashUnavailable = (mail: Mail) => mail.archived && accountOf(mail)?.provider !== "gmail" && !mail.messageId
    ? "Move to Trash is unavailable for this archived email because its Message-ID is missing. Move it in your provider."
    : undefined;
  const moveToTrash = async (mail: Mail) => {
    if (busy || syncing || trashLock.current || trashUnavailable(mail)) return;
    trashLock.current = true;
    setBusy(true);
    try {
      await api(`/messages/${encodeURIComponent(mail.id)}`, "PATCH", { action: "trash" });
      ++loadRevision.current;
      setState((current) => ({ ...current, messages: current.messages.filter((message) =>
        !(message.id === mail.id && message.accountId === mail.accountId && message.remoteId === mail.remoteId)) }));
      setTrashMail(null);
      if (selectedId === mail.id) setOpened(false);
      pushToast("Email moved to Trash.", "success");
      try { await reload(); }
      catch { setNotice("Email moved to Trash. Inbox refresh failed. Refresh the inbox separately."); }
    } catch (error) {
      setNotice(`${error instanceof Error ? error.message : "Move to Trash could not be confirmed."} Check your provider before trying again.`);
    } finally {
      trashLock.current = false;
      setBusy(false);
    }
  };
  const openMail = (mail: Mail, context: Partial<MailLocation> = {}) => {
    if (navigationBlocked || settings) return;
    navigation.update((current) => ({ ...current, ...context, selectedId: mail.id, opened: true }));
    setRecent((ids) =>
      [mail.id, ...ids.filter((id) => id !== mail.id)].slice(0, 6),
    );
    markRead(mail);
  };
  const openReaderMail = (mail: Mail | undefined) => {
    if (!mail || navigationBlocked || contextMenu) return;
    readerFocusPending.current = true;
    openMail(mail);
  };
  const jumpTo = (mail: Mail) => {
    openMail(mail, {
      view: mail.archived ? "archive" : isSnoozed(mail) ? "snoozed" : "inbox", tab: tabOf(mail),
      accountFilter: "all", labelFilter: "all", query: "", readFilter: "all", relevanceFilter: "all",
    });
  };
  const startCompose = (reply?: Mail) => {
    if (!state.accounts.length) {
      setConnecting("choose");
      return;
    }
    if (compose || draftPicker || updateInstalling) return;
    const accountId = reply?.accountId ||
      (accountFilter !== "all" ? accountFilter : state.accounts[0].id);
    openCompose(initialCompose(accountId, state.composePreferences ?? emptyComposePreferences, {
      accountId:
        reply?.accountId ||
        (accountFilter !== "all" ? accountFilter : state.accounts[0].id),
      to: reply?.replyTo || reply?.address || "",
      subject: reply
        ? /^re:/i.test(reply.subject)
          ? reply.subject
          : `Re: ${reply.subject}`
        : "",
      body: "",
      inReplyTo: reply?.messageId,
      threadId: reply?.threadId,
      references: reply?.messageId
        ? [
            ...(reply.references || [])
              .filter((v) => v !== reply.messageId)
              .slice(-99),
            reply.messageId,
          ]
        : reply?.references,
    }));
  };
  const archive = (mail: Mail) => {
    void changeMessage(mail, "archive");
    if (selectedId === mail.id) setOpened(false);
  };
  const neighbours = opened && selected
    ? filterMail(state.messages, navigation.location, activeLabel, now, selected.id, tabOf, search.matches) : filtered;
  const neighbourIndex = neighbours.findIndex((mail) => mail.id === selected?.id);
  const previousMail = neighbours[neighbourIndex - 1];
  const nextMail = neighbours[neighbourIndex + 1];
  // One list of actions feeds the native menu on the desktop and the drawn menu elsewhere.
  const mailMenuActions = (mail: Mail): MenuAction[] => [
    { id: "open", label: "Open", key: shortcut("open"), run: () => openMail(mail) },
    { id: "read", label: reading.has(mail.id) ? "Marking as read…" : "Mark as read", disabled: !mail.unread || reading.has(mail.id), run: () => markRead(mail) },
    { id: "pin", label: mail.pinned ? "Unpin email" : "Pin email", disabled: busy, run: () => void changeMessage(mail, mail.pinned ? "unpin" : "pin") },
    { id: "reply", label: "Reply", key: shortcut("reply"), disabled: busy, separator: true, run: () => startCompose(mail) },
    { id: "archive", label: "Archive", key: shortcut("done"), disabled: busy || syncing || mail.archived, separator: true, run: () => archive(mail) },
    { id: "snooze", label: "Snooze", key: shortcut("snooze"), disabled: busy || syncing || mail.archived, run: () => setSnooze(mail) },
    ...(isSnoozed(mail) ? [{ id: "unsnooze", label: "Return to inbox", disabled: busy || syncing, run: () => void changeMessage(mail, "unsnooze") }] : []),
    { id: "trash", label: "Move to Trash", disabled: busy || syncing || !!trashUnavailable(mail), run: () => setTrashMail(mail) },
    { id: "teach", label: "Teach sorting", key: shortcut("teach"), disabled: busy || syncing, separator: true, run: () => setTeach(mail) },
  ];
  const showMailMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>, mail: Mail) => {
    setSelectedId(mail.id);
    const request = mailMenuRequest(event, mail.id);
    const native = window.desktop?.mailMenu;
    if (!native) { setContextMenu(request); return; }
    const actions = mailMenuActions(mail);
    void native(actions.map(({ id, label, key, disabled, separator }) => ({ id, label, enabled: !disabled, accelerator: key, separator })),
      { x: Math.round(request.x), y: Math.round(request.y) })
      .then((chosen) => actions.find((action) => action.id === chosen)?.run());
  };
  const mailMenuKey = (event: ReactKeyboardEvent<HTMLElement>, mail: Mail) => {
    if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
      event.stopPropagation();
      showMailMenu(event, mail);
    }
  };
  useEffect(() => {
    const handler = (event: KeyboardEvent) => {
      if (settingsModal || updateInstalling || draftPicker || contextMenu || event.defaultPrevented || event.isComposing ||
        event.repeat && !["ArrowUp", "ArrowDown"].includes(event.key)) return;
      const target = event.target;
      if (event.key.toLowerCase() === "k" && !event.altKey && !event.shiftKey &&
        (window.desktop?.platform === "darwin" ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey)) {
        if (!navigationBlocked) { event.preventDefault(); setFiltersOpen(false); setPalette((value) => !value); }
        return;
      }
      if (palette) return;
      if (settings && target instanceof HTMLElement && target.closest("[data-interaction-settings]") &&
        event.key === "Escape" && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey &&
        !compose && !teach && !snooze && !trashMail && !shortcuts && !labelMail && !connecting) {
        event.preventDefault(); setSettings(false); return;
      }
      if (target instanceof HTMLElement && (target.isContentEditable ||
        ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) return;
      const nextDensity = densityShortcut(event);
      if (nextDensity && !settings && !compose && !teach && !snooze && !trashMail && !shortcuts && !labelMail && !connecting) {
        event.preventDefault(); setDensity(nextDensity); return;
      }
      if (target instanceof HTMLElement && target.tagName === "BUTTON" && target.getAttribute("role") === "combobox") return;
      if (event.key === "Escape" && !event.ctrlKey && !event.metaKey && !event.altKey) {
        if (compose) return;
        if (trashMail) { if (!trashLock.current) setTrashMail(null); return; }
        setOpened(false); setTeach(null); setSnooze(null); setShortcuts(false);
        setLabelMail(null); setSettings(false);
        return;
      }
      if (compose || teach || snooze || trashMail || shortcuts || labelMail || connecting) return;
      const action = interactionActions.find(({ id }) => matches(event, id, interaction, window.desktop?.platform))?.id;
      if (action === "back" || action === "forward") {
        event.preventDefault(); travel(action === "back" ? -1 : 1); return;
      }
      if (target instanceof HTMLElement && target.closest("#sidebar")) return;
      const inbox = !settings;
      if (inbox && !opened && ["ArrowDown", "ArrowUp"].includes(event.key) &&
        !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey &&
        (!(target instanceof HTMLElement) || !target.closest("button, a, [role=button]") || target.closest(".row"))) {
        event.preventDefault();
        const next = event.key === "ArrowDown" ? nextMail : previousMail;
        if (next) setSelectedId(next.id);
        return;
      }
      if (action === "search") { event.preventDefault(); showSearch(); }
      else if (action === "settings") setSettings((value) => !value);
      else if (action === "help") setShortcuts(true);
      else if (action === "compose") startCompose();
      else if (action === "privacy") togglePrivacy();
      else if (inbox && action === "density") setDensity((value) => value === "comfortable" ? "compact" : "comfortable");
      else if (inbox && action === "important") switchView("inbox");
      else if (inbox && action === "other") nextTab();
      else if (target instanceof HTMLElement && target.closest(".row-actions") &&
        ["next", "previous", "open", "done", "reply", "snooze", "teach"].includes(action ?? "")) return;
      else if (inbox && (action === "next" || action === "previous")) {
        event.preventDefault();
        const next = action === "next" ? nextMail : previousMail;
        if (next) { if (opened) openReaderMail(next); else setSelectedId(next.id); }
      } else if (inbox && action === "open" && selected &&
        (!(target instanceof HTMLElement) || !target.closest("button, a, [role=button]") || target.closest(".row"))) {
        event.preventDefault(); openMail(selected);
      } else if (inbox && action === "done" && selected && !busy) archive(selected);
      else if (inbox && action === "reply" && selected) startCompose(selected);
      else if (inbox && action === "snooze" && selected) setSnooze(selected);
      else if (inbox && action === "teach" && selected) setTeach(selected);
      else if (inbox && !event.ctrlKey && !event.metaKey && !event.altKey &&
        !(target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'))) {
        if (event.key === "Tab" && target === document.body) {
          event.preventDefault(); nextTab();
        } else if (!event.shiftKey && /^[0-9]$/.test(event.key)) {
          const account = state.accounts[Number(event.key) - 1];
          const nextAccount = event.key === "0" ? "all" : account?.id;
          if (nextAccount && nextAccount !== accountFilter) navigate({ accountFilter: nextAccount, labelFilter: "all", selectedId: null, opened: false });
        }
      }
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  });
  useEffect(() => {
    const handle = (e: BeforeUnloadEvent) => {
      if (sendLock.current || draftLock.current || draftSession.current?.dirty || compose?.sendUnknown)
        e.preventDefault();
    };
    window.addEventListener("beforeunload", handle);
    return () => window.removeEventListener("beforeunload", handle);
  }, [compose]);
  const senderMail = selected
    ? state.messages
        .filter((m) => m.address === selected.address)
        .sort((a, b) => b.date - a.date)
    : [];
  const recentMail = recent
    .map((id) => state.messages.find((m) => m.id === id))
    .filter((m): m is Mail => !!m);

  // Pick the entrance once per pane so re-renders never restart its animation.
  // Beside the reader, the list keeps its pane while mail opens and closes.
  const paneKey = opened && selected && !split
    ? `mail:${selected.id}`
    : `list:${view}:${tab}:${accountFilter}:${labelFilter}:${readFilter}:${relevanceFilter}:${sort}`;
  const pane = useRef({ key: paneKey, motion });
  if (pane.current.key !== paneKey)
    pane.current = {
      key: paneKey,
      motion: paneKey.startsWith("mail:")
        ? "open"
        : pane.current.key.startsWith("mail:")
          ? "close"
          : motion,
    };
  const readerKey = opened && selected ? selected.id : null;
  useLayoutEffect(() => {
    if (!opened) {
      if (listFocusPending.current) {
        listFocusPending.current = false;
        document.getElementById(`mail-${selected?.id}`)?.focus({ preventScroll: true });
      }
      return;
    }
    const region = split ? readerRef.current : listRef.current;
    region?.scrollTo({ top: 0, behavior: "instant" });
    if (readerFocusPending.current) {
      readerFocusPending.current = false;
      region?.querySelector<HTMLElement>(".message")?.focus({ preventScroll: true });
    }
  }, [readerKey, opened, split]);
  useEffect(closeContextMenu, [paneKey, readerKey, settings, closeContextMenu]);
  const menuMail = contextMenu && state.messages.find((mail) => mail.id === contextMenu.mailId);
  const privacyContext = useMemo(() => ({ on: privacy, revealed, toggle: toggleAddress,
    accounts: state.accounts, names: collectPrivateNames(state.accounts) }),
  [privacy, revealed, toggleAddress, state.accounts]);
  useEffect(() => {
    if (contextMenu && !menuMail) closeContextMenu();
  }, [contextMenu, menuMail, closeContextMenu]);
  const filtersActive = readFilter !== "all" || relevanceFilter !== "all" ||
    sort !== "newest" || labelFilter !== "all";
  // The header in each window corner carries the window controls. macOS draws its
  // own lights over the top-left header, which only reserves room for them.
  const corner = (pane: "sidebar" | "main" | "reader" | "context"): Corner => {
    const left = sidebarShown ? "sidebar" : "main";
    const right = settings ? "main" : contextShown ? "context" : split ? "reader" : "main";
    if (window.desktop?.platform === "darwin") return pane === left ? "reserve" : null;
    // The system caption buttons sit over the top-right corner.
    if (systemCaption) return pane === right ? "caption" : null;
    if (!drawsWindowControls) return null;
    return windowSide === "left" ? (pane === left ? "left" : null) : (pane === right ? "right" : null);
  };
  const sidebarToggle = !sidebarOpen && roomy && (
    <button
      className="icon"
      id="sidebar-toggle"
      aria-label="Show sidebar"
      aria-expanded={false}
      aria-controls="sidebar"
      onClick={toggleSidebar}
    >
      <SidebarIcon color="currentColor" size={16} />
    </button>
  );
  const privacyButton = (
    <button
      className={privacy ? "icon on" : "icon"}
      aria-label={privacy ? "Show personal information" : "Hide personal information"}
      aria-pressed={privacy}
      title={privacy ? `Personal information hidden (${shortcut("privacy")})` : `Hide personal information (${shortcut("privacy")})`}
      onClick={togglePrivacy}
    >
      {privacy ? <EyeOff color="currentColor" size={15} /> : <Eye color="currentColor" size={15} />}
    </button>
  );
  const senderToggle = senderRoom && (
    <button
      className={senderPanel ? "icon on" : "icon"}
      aria-label="Sender details"
      aria-pressed={senderPanel}
      title={senderPanel ? "Hide sender details" : "Show sender details"}
      onClick={toggleSenderPanel}
    >
      <PanelRight color="currentColor" size={15} />
    </button>
  );
  const actions = selected && (
    <MailActions
      mail={selected}
      opened={opened}
      reading={reading.has(selected.id)}
      busy={busy}
      syncing={syncing}
      snoozed={isSnoozed(selected)}
      trashUnavailable={trashUnavailable(selected)}
      shortcut={shortcut}
      onTrash={() => setTrashMail(selected)}
      onRead={() => markRead(selected)}
      onReply={() => startCompose(selected)}
      onArchive={() => archive(selected)}
      onSnooze={() => setSnooze(selected)}
      onUnsnooze={() => void changeMessage(selected, "unsnooze")}
      onPin={() => void changeMessage(selected, selected.pinned ? "unpin" : "pin")}
      onClose={() => setOpened(false)}
      onPrevious={() => openReaderMail(previousMail)}
      onNext={() => openReaderMail(nextMail)}
      previousDisabled={navigationBlocked || !!contextMenu || !previousMail}
      nextDisabled={navigationBlocked || !!contextMenu || !nextMail}
    />
  );
  const reader = opened && selected && (
    <Reader
      mail={selected}
      provider={selectedAccount?.provider}
      labels={state.labels}
      busy={busy || syncing}
      api={api}
      onLabels={() => setLabelMail(selected)}
      onMenu={showMailMenu}
      onMenuKey={mailMenuKey}
    />
  );
  const historyControls = (
    <nav className="mail-navigation" aria-label="Mail history">
      <button type="button" disabled={navigationBlocked || !!contextMenu || !navigation.canBack} onClick={() => travel(-1)}>
        <ArrowLeft size={15} />Back
      </button>
      <button type="button" disabled={navigationBlocked || !!contextMenu || !navigation.canForward} onClick={() => travel(1)}>
        Forward<ArrowRight size={15} />
      </button>
    </nav>
  );
  const paletteActions = {
    important: () => switchView("inbox"), search: showSearch, compose: () => startCompose(),
    settings: () => setSettings(true), privacy: togglePrivacy, help: () => setShortcuts(true),
    density: () => setDensity((value) => value === "comfortable" ? "compact" : "comfortable"),
  };
  const commands: PaletteCommand[] = [
    ...interactionActions.flatMap(({ id, label }) => id in paletteActions ? [{ id, label, shortcut: shortcut(id),
      run: paletteActions[id as keyof typeof paletteActions] }] : []),
    { id: "archive-view", label: "Show Archive", run: () => switchView("archive") },
    { id: "snoozed-view", label: "Show Snoozed", run: () => switchView("snoozed") },
    { id: "pinned-view", label: "Show pinned emails in this view", run: () => {
      navigate({ page: "mail", query: "is:pinned", selectedId: null, opened: false });
      setSearching(true);
    } },
    { id: "sync", label: "Sync mail", disabled: syncing || !state.accounts.length, run: () => void sync() },
    ...(!settings && selected ? mailMenuActions(selected).map(({ id, label, key, disabled, run }) =>
      ({ id: `mail-${id}`, label, shortcut: key, disabled, run })) : []),
  ];
  return (
    <PrivacyContext.Provider
      value={privacyContext}
    >
      <div
        className={`app${window.desktop ? " desktop" : ""}${privacy ? " private" : ""}`}
        data-platform={window.desktop?.platform}
      >
        <ToastStack toasts={toasts} onDismiss={dismissToast} position={notifications.position} />
        {palette && !updateInstalling && <CommandPalette commands={commands} onClose={() => setPalette(false)} />}
        <div className="app-update-control" style={{ right: systemCaption ? 154 : drawsWindowControls && windowSide === "right" ? 106 : 12 }}>
          <UpdateNotice bridge={window.desktop?.updates} onInstalling={setUpdateInstalling} />
        </div>
        <div className="frame" inert={updateInstalling || palette}>
          <Sidebar
            open={sidebarShown}
            onToggle={toggleSidebar}
            corner={corner("sidebar")}
            maximized={maximized}
            settings={settings}
            mail={{
              view,
              onView: switchView,
              inboxCount: inboxMail.length,
              snoozedCount,
              accounts: state.accounts,
              accountFilter,
              onAccount: (id) => navigate({ accountFilter: id, labelFilter: "all", selectedId: null, opened: false }),
              privacy,
              blocked: navigationBlocked,
              busy,
              onAddAccount: () => setConnecting("choose"),
              onCompose: () => startCompose(),
              onDrafts: () => setDraftPicker(true),
              onSettings: () => setSettings(true),
              onCommands: () => { if (!navigationBlocked) setPalette(true); },
              shortcut,
              syncing,
              lastSync: state.lastSync,
              onSync: () => void sync(),
            }}
            sections={{
              current: settingsSection,
              onSection: setSettingsSection,
              onClose: () => setSettings(false),
            }}
          />
        {settings ? (
          <div className="settings-pane">
            <PaneHeader className="main-head update-reserved" corner={corner("main")} maximized={maximized} lead={sidebarToggle}>
              <h1 className="pane-title">Settings</h1>
              <div className="pane-tools">{privacyButton}</div>
            </PaneHeader>
            <CredentialFallbackNotice mode={window.desktop?.credentialFallback} />
            {historyControls}
            <SettingsPage
              notifications={notifications}
              onNotifications={(value) => {
                setNotifications(value);
                if (!saveNotifications(value)) setNotice("Notification settings could not be saved on this device. Your selection applies until the app closes.");
              }}
              onNotificationPreview={() => pushToast("This is a sample in-app notification.", "info")}
              state={state}
              busy={busy}
              perform={perform}
              api={api}
              onConnectAccount={setConnecting}
              onNotice={(text) => pushToast(text, "info")}
              onComposePreferences={async (value) => {
                const saved = await api<ComposePreferences>("/compose-preferences", "PUT", value);
                setState((current) => ({ ...current, composePreferences: saved }));
                pushToast("Templates and signatures saved.", "success");
              }}
              windowSide={windowSide}
              onWindowSide={placeWindowControls}
              onWindowControls={(controls) => {
                if (compose || sendLock.current) setNotice("Send or discard your draft before switching window controls.");
                else window.desktop?.setWindowControls?.(controls);
              }}
              interaction={interaction}
              onInteraction={(value) => {
                setInteraction(value);
                if (!saveInteraction(value)) setNotice("Interaction settings could not be saved on this device.");
              }}
              section={settingsSection}
              appearance={appearance}
              scheme={themePreview?.scheme ?? scheme}
              onAppearance={(value) => {
                if (!saveAppearance(value)) {
                  setNotice("Appearance could not be saved on this device.");
                  return false;
                }
                setAppearance(value);
                return true;
              }}
              onThemePreview={setThemePreview}
              onModalChange={setSettingsModal}
            />
          </div>
        ) : (
          <main className={split ? "workspace split" : "workspace"}>
            <section className="list-pane">
              <PaneHeader className={!contextShown && !split ? "main-head update-reserved" : "main-head"} corner={corner("main")} maximized={maximized} lead={sidebarToggle}>
                <h1 className="pane-title">{viewNames[view]}</h1>
                <div className="head-search">
                  {searching ? (
                    <div className="search" role="search">
                      <Search color="currentColor" size={14} />
                      <input
                        ref={searchRef}
                        aria-label="Search all synced mail"
                        placeholder="Search mail"
                        aria-describedby="mail-search-help"
                        aria-invalid={!!search.error}
                        value={query}
                        onKeyDown={(e) => {
                          if (e.key === "Escape") closeSearch();
                        }}
                        onChange={(e) => {
                          navigate({ query: e.target.value, opened: false, selectedId: null }, true);
                        }}
                      />
                      <button aria-label="Close search" onClick={closeSearch}>
                        <Close color="currentColor" size={13} />
                      </button>
                    </div>
                  ) : (
                    <button
                      className="search search-closed"
                      aria-label="Search mail"
                      title={`Search (${shortcut("search")})`}
                      onClick={showSearch}
                    >
                      <Search color="currentColor" size={14} />
                      <span>Search mail</span>
                      <kbd>{shortcut("search")}</kbd>
                    </button>
                  )}
                </div>
                <div className="pane-tools">
                  <MailFilters
                    open={filtersOpen}
                    active={filtersActive}
                    location={navigation.location}
                    labels={labels}
                    accounts={state.accounts}
                    blocked={navigationBlocked}
                    showReset={hasListControls}
                    labelName={(name) => privacy ? redactPrivateText(name, collectPrivateNames(state.accounts, opened ? selected || {} : {})) : name}
                    onOpen={setFiltersOpen}
                    onChange={(patch) => navigate({ ...patch, selectedId: null, opened: false })}
                    onReset={resetListControls}
                  />
                  {privacyButton}
                  {!split && senderToggle}
                </div>
              </PaneHeader>
              {historyControls}
              {searching && <p id="mail-search-help" className="search-help" role={search.error ? "alert" : undefined}>
                {search.error || 'Search cached mail in this view: from:, to:, subject:, body:, account:, is:unread, is:pinned, before:YYYY-MM-DD, after:YYYY-MM-DD. Use quotes for phrases and - to exclude.'}
              </p>}
              {view === "inbox" && !query && (
                <InboxTabs tab={tab} unread={tabUnread} onTab={switchTab} />
              )}
              <CredentialFallbackNotice mode={window.desktop?.credentialFallback} />
              {!(split && opened) && actions}
              <div
                ref={listRef}
                className={`list ${density}`}
                aria-label={`${viewNames[view]} mail`}
              >
              <div key={paneKey} className={`pane ${pane.current.motion}`}>
                {reader && !split ? reader : filtered.length ? (
                  <MailList
                    messages={filtered}
                    listRef={listRef}
                    focusSelection={!navigationBlocked && !contextMenu}
                    selectedId={selected?.id}
                    density={density}
                    narrow={split}
                    sort={sort}
                    opening={interaction.opening}
                    accounts={state.accounts}
                    onOpen={openMail}
                    onSelect={setSelectedId}
                    onMenu={showMailMenu}
                    onMenuKey={mailMenuKey}
                    rowActions={(mail) => <MailActions
                      row mail={mail} reading={reading.has(mail.id)} busy={busy} syncing={syncing}
                      snoozed={isSnoozed(mail)} trashUnavailable={trashUnavailable(mail)} shortcut={shortcut}
                      onRead={() => markRead(mail)} onReply={() => startCompose(mail)}
                      onArchive={() => archive(mail)} onTrash={() => setTrashMail(mail)}
                      onSnooze={() => setSnooze(mail)} onUnsnooze={() => void changeMessage(mail, "unsnooze")}
                      onPin={() => void changeMessage(mail, mail.pinned ? "unpin" : "pin")}
                    />}
                  />
                ) : (
                  <ListEmpty
                    loaded={loaded}
                    hasAccounts={state.accounts.length > 0}
                    filtered={hasListControls}
                    title={view === "snoozed" ? "No snoozed mail" : view === "archive" ? "No archived mail" : `Nothing in ${tabName(tab)}`}
                    viewName={viewNames[view]}
                    syncing={syncing}
                    onConnect={() => setConnecting("choose")}
                    onReset={resetListControls}
                    onSync={() => void sync()}
                  />
                )}
              </div>
              </div>
            </section>
            {split && (
              <section className="reader-pane" aria-label="Reading pane">
                <PaneHeader className={contextShown ? "reader-head" : "reader-head update-reserved"} corner={corner("reader")} maximized={maximized}>
                  {opened && actions}
                  <div className="pane-tools">{senderToggle}</div>
                </PaneHeader>
                <div ref={readerRef} className="reader-scroll">
                  <div key={selected && opened ? selected.id : "empty"} className="pane open">
                    {reader || <ReaderEmpty selected={!!selected} openKey={shortcut("open")} />}
                  </div>
                </div>
              </section>
            )}
            {contextShown && (
              <aside className="context" aria-label="Sender context">
                <PaneHeader className="context-head update-reserved" corner={corner("context")} maximized={maximized} />
                <SenderPanel
                  selected={selected}
                  account={selectedAccount}
                  tab={selected ? tabName(tabOf(selected)) : ""}
                  senderMail={senderMail}
                  recentMail={recentMail}
                  onJump={jumpTo}
                />
              </aside>
            )}
          </main>
        )}
        </div>
        {contextMenu && menuMail && (
          <ContextMenu
            x={contextMenu.x}
            y={contextMenu.y}
            anchor={contextMenu.anchor}
            onClose={closeContextMenu}
            actions={mailMenuActions(menuMail)}
          />
        )}
        {labelMail && (
          <MailPrivacy mail={labelMail}>
          <Dialog title="Gmail labels" onClose={() => { if (!busy) setLabelMail(null); }}>
            <LabelForm
              key={labelMail.id}
              mail={labelMail}
              labels={customLabels(labelMail, state.labels)}
              busy={busy || syncing}
              onSave={(labelIds) => void perform(async () => {
                await api(`/messages/${encodeURIComponent(labelMail.id)}`, "PATCH", { action: "labels", labelIds });
                setLabelMail(null);
              }, "Labels updated.")}
            />
          </Dialog>
          </MailPrivacy>
        )}
        {connecting === "choose" ? (
          <Dialog title="Connect account" onClose={() => setConnecting(null)}>
            <p className="dialog-copy">Choose your email provider.</p>
            <ProviderChoices googleConfigured={state.googleConfigured} busy={busy} onChoose={setConnecting} />
          </Dialog>
        ) : connecting && (
          <ConnectDialog
            key={connecting}
            provider={connecting}
            busy={busy}
            onClose={() => setConnecting(null)}
            onBack={() => setConnecting("choose")}
            onConnect={async (input) => {
              await api("/accounts/imap", "POST", input);
              setConnecting(null);
              setNotice("Account connected. Syncing your inbox.");
              try {
                await reload();
                void sync();
              } catch {
                setNotice("Account connected, but refreshing the inbox failed. Reload the app to sync.");
              }
            }}
          />
        )}
        {draftPicker && (
          <DraftPicker accounts={state.accounts} api={api}
            onClose={() => setDraftPicker(false)}
            onOpen={(draft) => { setDraftPicker(false); openCompose(draft); }} />
        )}
        {compose && (
          <ComposePanel
            draft={compose}
            setDraft={editCompose}
            accounts={state.accounts}
            preferences={state.composePreferences ?? emptyComposePreferences}
            busy={busy || updateInstalling}
            progress={draftProgress}
            onClose={closeCompose}
            onDiscard={() => runDraftAction(async (session) => {
              await session.discard();
              draftSession.current = null;
              setCompose(null);
            })}
            onRetry={() => runDraftAction(async (session) => {
              await session.retry();
              setCompose(session.content);
            })}
            onSaveCopy={() => {
              if (busy || sendLock.current || draftLock.current || updateInstalling) return;
              if (confirm("Save a separate copy? The previous saved draft may still exist. Neither copy will be sent automatically.")) openCompose(draftSession.current?.content || compose);
            }}
            onSend={() => {
              const session = draftSession.current;
              if (!session || busy || draftLock.current || sendLock.current || updateInstalling) return;
              const snapshot = session.content;
              sendTask.current = sendCompose(snapshot, {
                lock: sendLock,
                prepare: async () => {
                  session.change({ ...snapshot, sendUnknown: true });
                  setCompose(session.content);
                  await session.flush();
                },
                send: (draft) => {
                  pushToast("Sending email…", "info");
                  return api("/send", "POST", draft);
                },
                complete: () => session.discard(),
                reload,
                setDraft: (value) => {
                  if (value === null) draftSession.current = null;
                  setCompose(value);
                },
                setBusy,
                setNotice: (text) => pushToast(text, text === "Email sent." ? "success" : "error"),
                confirm: (message) => confirm(message),
              });
            }}
          />
        )}
        {teach && (
          <TeachDialog
            mail={teach}
            busy={busy}
            onClose={() => setTeach(null)}
            onSave={(input) =>
              void perform(async () => {
                await api("/rules", "POST", input);
                setTeach(null);
              }, "Sorting updated for this sender.")
            }
          />
        )}
        {snooze && (
          <SnoozeDialog
            mail={snooze}
            busy={busy}
            onClose={() => setSnooze(null)}
            onSnooze={(until) => void perform(async () => {
              await api(`/messages/${encodeURIComponent(snooze.id)}`, "PATCH", { action: "snooze", until });
              setSnooze(null);
              if (selectedId === snooze.id) setOpened(false);
            }, `Email snoozed until ${new Date(until).toLocaleString()}.`)}
            onUnsnooze={() => void perform(async () => {
              await api(`/messages/${encodeURIComponent(snooze.id)}`, "PATCH", { action: "unsnooze" });
              setSnooze(null);
            }, "Email returned to inbox.")}
          />
        )}
        {trashMail && (
          <TrashDialog
            busy={busy}
            syncing={syncing}
            onConfirm={() => void moveToTrash(trashMail)}
            onClose={() => { if (!trashLock.current) setTrashMail(null); }}
          />
        )}
        {shortcuts && <ShortcutsDialog shortcut={shortcut} onClose={() => setShortcuts(false)} />}
      </div>
    </PrivacyContext.Provider>
  );
}