AkurAI Build
Menu

Public repository / 10d19fe0941c186ec244d1f0e2af6b19200dcb0c

AkurAI-Mail / src/routes/webmail/+page.svelte

Latest change 10d19fe0941c186ec244d1f0e2af6b19200dcb0c - OLI-248: pure webmail logic as plain ES modules with JSDoc types; inline icon module by Ólafur Búi Ólafsson

<script lang="ts">
  import { scheduledSendView, type ScheduledSendView } from "$lib/webmail/session";
  import {
    Archive,
    ArrowLeft,
    Clock3,
    FilePen,
    FileText,
    Inbox,
    Keyboard,
    Mail,
    Paperclip,
    PenLine,
    RefreshCw,
    Search,
    Send,
    Settings2,
    ShieldAlert,
    SlidersHorizontal,
    Star,
    Trash2,
    UserPlus,
    X,
    ChevronDown
  } from "lucide-svelte";
  import { loginUrl } from "$lib/api";
  import MessageList from "$lib/webmail/MessageList.svelte";
  import MessageReader from "$lib/webmail/MessageReader.svelte";
  import ComposeDialog from "$lib/webmail/ComposeDialog.svelte";
  import SettingsDrawer from "$lib/webmail/SettingsDrawer.svelte";
  import SyncBanner from "$lib/webmail/SyncBanner.svelte";
  import RichTextEditor from "$lib/webmail/RichTextEditor.svelte";
  import RecipientCombobox from "$lib/webmail/RecipientCombobox.svelte";
  import { createSyncState } from "$lib/webmail/sync.svelte";
  import { SYNC_ERROR_MESSAGES, classifySyncError } from "$lib/webmail/sync";
  import {
    DRAFT_RECOVERY_KEY,
    UNDO_SEND_SECONDS,
    emptyCompose,
    forwardBody,
    hasDraftContent,
    parseSendAt,
    replyBody,
    secondsUntil,
    stashDraft,
    takeDraft,
    type ComposeState
  } from "$lib/webmail/compose";
  import { createShortcuts, moveSelection, type ShortcutAction } from "../../framework/shortcuts.js";
  import { RECENT_RECIPIENTS_KEY, extractEmail, mergeRecent, recipientSuggestions } from "$lib/webmail/recipients";
  import { FILTER_CHIPS, filterParams, isPlainView, mailboxCacheKey } from "$lib/webmail/filters";

  let { data } = $props();
  let status = $derived(data.status);
  let admin = $derived(data.admin);
  let webmailState: any = $state(null);
  const current = $derived(data.sessionMailbox);
  let folder = $state("Inbox");
  let query = $state("");
  let selected: any = $state(null);
  let notice = $state("");
  let pendingSend: any = $state(null);
  let undoSecondsLeft = $state(0);
  let undoTimer: ReturnType<typeof setInterval> | undefined;
  let busy = $state(false);
  let composeOpen = $state(false);
  let settingsOpen = $state(false);
  let deletePending = $state(false);
  let showCcBcc = $state(false);
  let compose: ComposeState = $state(emptyCompose());
  /** Bumped whenever compose content is replaced, so the editor reloads it. */
  let composeVersion = $state(0);
  let contact = $state({ name: "", email: "" });
  let signature = $state("");
  let importText = $state("");
  let page = $state(0);
  let pageCursors = $state<string[]>([""]);
  let vacation = $state({ enabled: false, subject: "Out of office", body: "" });
  let rule = $state({ field: "from", contains: "", action: "Archive", enabled: true });
  let ruleFieldMenuOpen = $state(false);
  let signals: any = $state({});
  let messageSignals: any = $state({});
  let signalsBusy = $state(false);
  let shortcutsOpen = $state(false);
  let scheduledSends: ScheduledSendView[] = $state([]);
  let sendLaterAt = $state("");
  let activeChips = $state(new Set<string>());
  let smartMailbox = $state("");
  const pageSize = 40;

  const smartMailboxes = [
    { id: "unread", label: "Unread", icon: Mail, category: "mailboxes" },
    { id: "flagged", label: "Flagged", icon: Star, category: "mailboxes" },
    { id: "attachment", label: "Attachments", icon: Paperclip, category: "mailboxes" },
    { id: "today", label: "Today", icon: Inbox, category: "mailboxes" },
    { id: "vip", label: "VIP", icon: ShieldAlert, category: "priority" },
    { id: "priority", label: "Needs attention", icon: ShieldAlert, category: "priority" }
  ];

  const sync = createSyncState();
  let ruleActionMenuOpen = $state(false);

  const folderList = $derived(webmailState?.folders ?? []);
  const messages = $derived(webmailState?.messages ?? []);
  const totalMessages = $derived(webmailState?.total ?? messages.length);
  const nextCursor = $derived(webmailState?.nextCursor ?? "");
  const contacts = $derived(webmailState?.contacts ?? []);

  let recentRecipients: string[] = $state(typeof localStorage !== "undefined" ? JSON.parse(localStorage.getItem(RECENT_RECIPIENTS_KEY) ?? "[]") : []);

  function rememberRecipients(used: string) {
    recentRecipients = mergeRecent(recentRecipients, used);
    if (typeof localStorage !== "undefined") localStorage.setItem(RECENT_RECIPIENTS_KEY, JSON.stringify(recentRecipients));
  }

  const suggestions = $derived(recipientSuggestions(contacts, recentRecipients));
  const signatures = $derived(webmailState?.signatures ?? {});
  const rules = $derived(webmailState?.rules ?? []);
  const preferences = $derived(webmailState?.preferences ?? {});
  const activeSignature = $derived(signatures[current] ?? "");
  const pageCount = $derived(Math.max(1, Math.ceil(totalMessages / pageSize)));
  const selectedDate = $derived(selected?.date ? new Date(selected.date) : null);
  const unreadCount = $derived(folderList.reduce((total: number, item: any) => total + (item.unread ?? 0), 0));
  const vipSenders: string[] = $derived(webmailState?.vipSenders ?? []);

  function isVipMessage(message: any) {
    return message.isVip ?? vipSenders.includes(extractEmail(message.from || ""));
  }

  function toggleChip(id: string) {
    const next = new Set(activeChips);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    activeChips = next;
    loadMailbox(folder);
  }

  function clearChips() {
    activeChips = new Set();
    loadMailbox(folder);
  }

  function chooseSmartMailbox(id: string) {
    smartMailbox = smartMailbox === id ? "" : id;
    loadMailbox(folder);
  }

  async function toggleVip(from: string | undefined) {
    if (!from) return;
    const email = extractEmail(from);
    if (!email) return;
    await post({ action: "config", kind: "vip", value: { email, remove: vipSenders.includes(email) } }, true);
  }

  const ruleFields = ["from", "to", "subject", "preview"];
  const ruleActions = ["Archive", "Spam", "Trash"];

  $effect(() => {
    if (!webmailState) webmailState = data.webmail;
  });

  let scheduledLoaded = false;
  $effect(() => {
    if (webmailState && !scheduledLoaded) {
      scheduledLoaded = true;
      loadScheduledSends();
    }
  });

  let initialSyncReported = false;
  $effect(() => {
    if (webmailState && !initialSyncReported) {
      initialSyncReported = true;
      sync.reportSuccess();
    }
  });

  // Restore a draft stashed before an expired session sent the user to sign in.
  $effect(() => {
    const draft = takeDraft(sessionStorage, DRAFT_RECOVERY_KEY);
    if (draft) {
      compose = draft;
      composeOpen = true;
    }
  });

  $effect(() => {
    document.documentElement.dataset.theme = preferences.theme ?? "light";
  });

  const shortcuts = createShortcuts({
    window,
    enabled: true,
    hasSelection: () => !!selected,
    persist: (enabled: boolean) => post({ action: "config", kind: "preferences", value: { ...preferences, shortcuts: enabled } }, false).then(() => loadMailbox(folder)),
    onAction: runShortcut,
  });

  $effect(() => {
    shortcuts.enabled(preferences.shortcuts !== false);
  });

  $effect(() => shortcuts.destroy);

  function runShortcut(action: ShortcutAction, event: KeyboardEvent) {
    const messageAction = (name: string) => post({ action: "message", id: selected.id, messageAction: name });
    const handlers: Record<ShortcutAction, () => void> = {
      closeTyping: () => {
        if (composeOpen) composeOpen = false;
        (event.target as HTMLElement | null)?.blur?.();
      },
      help: () => (shortcutsOpen = true),
      close: () => {
        if (shortcutsOpen) shortcutsOpen = false;
        else if (composeOpen) composeOpen = false;
        else if (settingsOpen) settingsOpen = false;
        else if (deletePending) deletePending = false;
        else if (selected) selected = null;
      },
      compose: () => (composeOpen = true),
      search: () => (document.querySelector("[data-search]") as HTMLInputElement | null)?.focus(),
      next: () => selectRelative("next"),
      prev: () => selectRelative("prev"),
      delete: () => (deletePending = true),
      reply: () => reply(false),
      replyAll: () => reply(true),
      forward: () => forward(),
      archive: () => messageAction("archive"),
      star: () => messageAction(selected.flagged ? "unstar" : "star"),
      unread: () => messageAction("unread"),
      spam: () => messageAction("spam"),
    };
    handlers[action]();
  }

  function selectRelative(direction: "next" | "prev") {
    const index = selected ? messages.findIndex((message: any) => message.id === selected.id) : -1;
    const next = moveSelection(index, messages.length, direction);
    if (next >= 0) readMessage(messages[next].id);
  }

  function shortDate(value: string) {
    const date = new Date(value);
    if (Number.isNaN(date.valueOf())) return value || "";
    const today = new Date();
    if (date.toDateString() === today.toDateString()) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
    return date.toLocaleDateString([], { month: "short", day: "numeric" });
  }

  function senderLabel(message: any) {
    return message.from || message.to || "(unknown)";
  }

  function folderIcon(name: string) {
    if (name === "Inbox") return Inbox;
    if (name === "Spam") return ShieldAlert;
    if (name === "Trash") return Trash2;
    if (name === "Sent") return Send;
    if (name === "Drafts") return FilePen;
    if (name === "Archive") return Archive;
    return FileText;
  }

  async function loadMailbox(nextFolder = folder, cursor = "", resetPage = true) {
    busy = true;
    sync.status = "syncing";
    let response: Response | undefined;
    try {
      const params = new URLSearchParams({ folder: nextFolder, query, limit: String(pageSize), ...filterParams(smartMailbox, activeChips) });
      if (cursor) params.set("cursor", cursor);
      response = await fetch(`/api/webmail?${params}`);
      const result = await response.json();
      if (!response.ok || result.ok === false) throw new Error(result.error || "Could not load this folder.");
      webmailState = result;
      folder = nextFolder;
      selected = null;
      if (resetPage) {
        page = 0;
        pageCursors = [""];
      }
      if (isPlainView(query, cursor, smartMailbox, activeChips) && typeof localStorage !== "undefined") {
        localStorage.setItem(mailboxCacheKey(current, nextFolder), JSON.stringify(result));
      }
      sync.reportSuccess();
      loadSignals(nextFolder);
    } catch (error) {
      const cached = typeof localStorage !== "undefined" ? localStorage.getItem(mailboxCacheKey(current, nextFolder)) : null;
      if (cached) {
        webmailState = JSON.parse(cached);
        folder = nextFolder;
        notice = "Offline — showing cached mailbox.";
      }
      sync.reportError(classifySyncError(error, response), () => loadMailbox(nextFolder, cursor, resetPage));
    } finally {
      busy = false;
    }
  }

  /** Keeps any open draft across the IdP round trip, then signs in again. */
  function reauthenticate() {
    if (composeOpen && hasDraftContent(compose)) stashDraft(sessionStorage, DRAFT_RECOVERY_KEY, compose);
    window.location.href = loginUrl(window.location.pathname);
  }

  async function loadSignals(forFolder = folder) {
    signalsBusy = true;
    try {
      const params = new URLSearchParams({ folder: forFolder });
      const response = await fetch(`/api/webmail?${params}&action=signals`);
      if (response.ok) {
        const result = await response.json();
        if (result.ok) {
          signals = result.signals ?? {};
          messageSignals = result.messageSignals ?? {};
        }
      }
    } catch {
      // Signals are non-critical; silently skip
    } finally {
      signalsBusy = false;
    }
  }

  async function correctSignal(convId: string, signalType: string, action: string, value?: any) {
    try {
      const payload = JSON.stringify({ conversationId: convId, signalType, action, value });
      const params = new URLSearchParams({ folder, action: "signal-correct", payload });
      const response = await fetch(`/api/webmail?${params}`);
      const result = await response.json();
      if (result.ok) await loadSignals(folder);
    } catch {
      notice = "Could not save correction.";
    }
  }

  function signalForMessage(msgId: string) {
    const convId = messageSignals[msgId];
    if (!convId) return null;
    return signals[convId] ?? null;
  }

  async function threadAction(thread: { messages: { id: string }[] }, messageAction: string) {
    busy = true;
    try {
      for (const message of thread.messages) {
        await fetch("/api/webmail", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ action: "message", id: message.id, messageAction })
        });
      }
      await loadMailbox(folder, "", false);
    } catch {
      notice = "Could not apply thread action.";
    } finally {
      busy = false;
    }
  }

  const signalForSelected = $derived(selected ? signalForMessage(selected.id) : null);

  async function nextPage() {
    if (!nextCursor || busy) return;
    pageCursors[page + 1] = nextCursor;
    page += 1;
    await loadMailbox(folder, nextCursor, false);
  }

  async function previousPage() {
    if (page === 0 || busy) return;
    page -= 1;
    await loadMailbox(folder, pageCursors[page], false);
  }

  async function readMessage(id: string) {
    busy = true;
    try {
      const response = await fetch("/api/webmail", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ action: "read", id })
      });
      const result = await response.json();
      if (!response.ok || !result.ok) throw new Error(result.error || "Could not open this message.");
      selected = result.message;
    } catch (error) {
      notice = error instanceof Error ? error.message : "Could not open this message.";
    } finally {
      busy = false;
    }
  }

  async function post(body: Record<string, unknown>, refresh = true) {
    busy = true;
    notice = "";
    try {
      const response = await fetch("/api/webmail", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(body)
      });
      const result = await response.json();
      notice = result.ok ? "Saved" : result.error || "Action failed";
      if (result.ok && refresh) await loadMailbox(folder);
      return result;
    } catch {
      notice = "Connection error. Please try again.";
      return { ok: false };
    } finally {
      busy = false;
    }
  }

  function resetCompose() {
    compose = emptyCompose();
    showCcBcc = false;
    composeVersion += 1;
  }

  /** Sends with an undo window: the server holds the message for UNDO_SEND_SECONDS. */
  async function send() {
    const result = await post({ action: "send", message: { ...compose, from: current, signature: activeSignature, undoDelaySecs: UNDO_SEND_SECONDS } }, false);
    if (result.ok) {
      rememberRecipients([compose.to, compose.cc, compose.bcc].filter(Boolean).join(", "));
      composeOpen = false;
      resetCompose();
      startUndoCountdown(result.pendingSend);
    }
  }

  function startUndoCountdown(pending: any) {
    clearInterval(undoTimer);
    pendingSend = pending;
    undoSecondsLeft = secondsUntil(pending.send_at);
    undoTimer = setInterval(() => {
      undoSecondsLeft = secondsUntil(pending.send_at);
      if (undoSecondsLeft <= 0) {
        clearInterval(undoTimer);
        pendingSend = null;
        loadMailbox(folder);
      }
    }, 250);
  }

  async function undoSend() {
    if (!pendingSend) return;
    clearInterval(undoTimer);
    const result = await post({ action: "cancel-send", id: pendingSend.id }, false);
    pendingSend = null;
    notice = result.ok ? "Send undone" : result.error || "Could not undo — window already closed";
  }

  async function sendLater() {
    const parsed = parseSendAt(sendLaterAt);
    if (parsed.error !== undefined) {
      notice = parsed.error;
      return;
    }
    const result = await post({ action: "schedule-send", sendAt: parsed.sendAt, message: { ...compose, from: current, signature: activeSignature } }, false);
    if (result.ok) {
      composeOpen = false;
      resetCompose();
      sendLaterAt = "";
      notice = "Message scheduled";
      await loadScheduledSends();
    }
  }

  async function loadScheduledSends() {
    const result = await post({ action: "scheduled-sends" }, false);
    if (result.ok) scheduledSends = (result.scheduledSends ?? []).map(scheduledSendView);
  }

  async function cancelScheduledSend(scheduledId: string) {
    const result = await post({ action: "cancel-scheduled-send", scheduledId }, false);
    if (result.ok) await loadScheduledSends();
  }

  async function saveDraft() {
    const result = await post({ action: "draft", message: { ...compose, from: current } });
    if (result.ok) notice = "Draft saved";
  }

  async function downloadAttachment(file: any) {
    const result = await post({ action: "attachment", id: selected.id, index: file.index }, false);
    if (!result.ok) return;
    const link = document.createElement("a");
    link.href = `data:${result.contentType};base64,${result.data}`;
    link.download = result.filename;
    link.click();
  }

  function formatAddress(entry: any) {
    if (!entry?.address) return "";
    const name = String(entry.name ?? "").replaceAll('"', '\\"');
    return name ? `"${name}" <${entry.address}>` : entry.address;
  }

  function reply(all = false) {
    if (!selected) return;
    const primaryEntry = selected.replyToAddresses?.[0] ?? selected.fromAddresses?.[0] ?? { address: selected.from };
    const primary = formatAddress(primaryEntry) || selected.from;
    const excluded = new Set([current.toLowerCase(), String(primaryEntry.address ?? "").toLowerCase()]);
    const cc = all
      ? [...(selected.toAddresses ?? []), ...(selected.ccAddresses ?? [])]
          .filter((entry: any) => {
            const address = String(entry.address ?? "").toLowerCase();
            if (!address || excluded.has(address)) return false;
            excluded.add(address);
            return true;
          })
          .map(formatAddress)
          .join(", ")
      : "";
    compose = {
      to: primary,
      cc,
      bcc: "",
      subject: selected.subject.startsWith("Re:") ? selected.subject : `Re: ${selected.subject}`,
      body: replyBody(selected),
      attachments: []
    };
    showCcBcc = all;
    composeOpen = true;
    composeVersion += 1;
  }

  function forward() {
    if (!selected) return;
    compose = {
      to: "",
      cc: "",
      bcc: "",
      subject: selected.subject.startsWith("Fwd:") ? selected.subject : `Fwd: ${selected.subject}`,
      body: forwardBody(selected),
      attachments: []
    };
    showCcBcc = false;
    composeOpen = true;
    composeVersion += 1;
  }

  async function attach(event: Event) {
    const input = event.currentTarget as HTMLInputElement;
    const files = Array.from(input.files ?? []).slice(0, 5);
    compose.attachments = await Promise.all(files.map((file) => new Promise<ComposeState["attachments"][number]>((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, data: String(reader.result).split(",", 2)[1] ?? "" });
      reader.onerror = reject;
      reader.readAsDataURL(file);
    })));
  }

  const defaultVacation = () => ({ enabled: false, subject: "Out of office", body: "" });

  function hydrateSettings() {
    signature = activeSignature;
    vacation = { ...defaultVacation(), ...(webmailState?.vacation ?? {}) };
    loadScheduledSends();
  }

  async function saveSettings(kind: "signature" | "vacation", value: Record<string, unknown>) {
    const result = await post({ action: "config", kind, value }, false);
    if (!result.ok) return;
    await loadMailbox(folder);
    hydrateSettings();
  }

  function chooseRuleField(field: string) {
    rule.field = field;
    ruleFieldMenuOpen = false;
  }

  function chooseRuleAction(action: string) {
    rule.action = action;
    ruleActionMenuOpen = false;
  }

  function setTheme(theme: string) {
    post({ action: "config", kind: "preferences", value: { ...preferences, theme } }, false).then(() => loadMailbox(folder));
  }

  function confirmDelete() {
    if (!selected) return;
    deletePending = false;
    post({ action: "message", id: selected.id, messageAction: "delete" });
  }

  function scheduledLabel(item: ScheduledSendView) {
    return `${item.to} · ${new Date(item.sendAt * 1000).toLocaleString()}`;
  }
</script>

<svelte:head><title>Webmail · AkurAI Mail</title></svelte:head>

<main class="webmail-shell">
  <aside class="platform-rail">
    <a class="brand" href="/" aria-label="AkurAI Mail home">
      <span class="mark">A</span>
      <span><strong>AkurAI Mail</strong><small>{status.hostname}</small></span>
    </a>

    <nav aria-label="Primary navigation">
      {#if admin}<a href="/"><SlidersHorizontal size={17} strokeWidth={1.8} /> <span>Admin</span></a>{/if}
      <a class="active" href="/webmail"><Mail size={17} strokeWidth={1.8} /> <span>Webmail</span></a>
    </nav>

    <div class="rail-footer">
      <span class="secure-state"><ShieldAlert size={15} strokeWidth={1.8} /> Private mailbox</span>
      <a class="sign-out" href="/auth/logout"><ArrowLeft size={17} strokeWidth={1.8} /> Sign out</a>
    </div>
  </aside>

  <section class="mail-app" aria-busy={busy}>
    <SyncBanner {sync} onRetry={() => sync.retryNow(() => loadMailbox(folder))} onReauth={reauthenticate} />
    <header class="mail-topbar">
      <div class="identity">
        <span class="eyebrow">Mailbox</span>
        <strong>{current || "AkurAI Webmail"}</strong>
      </div>

      <label class="search-control">
        <Search size={18} strokeWidth={1.8} aria-hidden="true" />
        <span class="sr">Search mail</span>
        <input data-search bind:value={query} onkeydown={(event) => { if (event.key === "Enter") loadMailbox(folder); }} placeholder="Search this mailbox" />
        {#if query}<button class="icon-button clear-search" aria-label="Clear search" onclick={() => { query = ""; loadMailbox(folder); }}><X size={15} /></button>{/if}
      </label>

      <div class="top-actions">
        <button class="icon-button" aria-label="Refresh mailbox" title="Refresh mailbox" onclick={() => loadMailbox(folder)} disabled={busy}><RefreshCw size={18} class={busy ? "spin" : ""} /></button>
        <button class="icon-button" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => (shortcutsOpen = true)}><Keyboard size={18} /></button>
        <button class="icon-button settings-button" aria-label="Open settings" title="Settings" onclick={() => { settingsOpen = true; hydrateSettings(); }}><Settings2 size={18} /></button>
        <button class="primary-action" onclick={() => composeOpen = true}><PenLine size={17} strokeWidth={2} /> <span>Compose</span></button>
      </div>
    </header>

    <div class:reading={selected} class="mail-canvas">
      <MessageList
        {folderList}
        {folder}
        {unreadCount}
        {folderIcon}
        onFolder={(name: string) => { smartMailbox = ""; loadMailbox(name); }}
        {messages}
        {totalMessages}
        {query}
        {senderLabel}
        {shortDate}
        {selected}
        onRead={readMessage}
        {page}
        {pageCount}
        {nextCursor}
        {busy}
        onPrevious={previousPage}
        onNext={nextPage}
        {signalForMessage}
        {smartMailboxes}
        {smartMailbox}
        onSmartMailbox={chooseSmartMailbox}
        chipDefs={FILTER_CHIPS}
        {activeChips}
        onToggleChip={toggleChip}
        onClearChips={clearChips}
        {isVipMessage}
        onThreadAction={threadAction}
      />
      <MessageReader
        {selected}
        {selectedDate}
        {current}
        {reply}
        {forward}
        {post}
        onBack={() => selected = null}
        {downloadAttachment}
        onDelete={() => deletePending = true}
        convSignal={signalForSelected}
        onCorrectSignal={correctSignal}
        onDismissSignal={correctSignal}
        isVip={selected ? vipSenders.includes(extractEmail(selected.from || "")) : false}
        onToggleVip={() => toggleVip(selected?.from)}
        {folderList}
        isFirstRun={folder === "Inbox" && totalMessages === 0 && !query}
        isNoResults={totalMessages === 0 && !!query}
        isEmptyMailbox={totalMessages === 0 && !query && folder !== "Inbox"}
      />
    </div>
  </section>

  {#if pendingSend}
    <div class="toast undo-send-toast" role="status" aria-live="polite"><span>Message sending in {undoSecondsLeft}s</span> <button class="subtle-button" onclick={undoSend}>Undo</button></div>
  {/if}
  {#if notice}
    <div class="toast" role="status" aria-live="polite"><span>{notice}</span><button aria-label="Dismiss notification" onclick={() => notice = ""}><X size={16} /></button></div>
  {/if}
  <ComposeDialog open={composeOpen}>
    <div class="compose-title"><span><PenLine size={18} /> New message</span><button class="icon-button inverse" aria-label="Close compose window" onclick={() => composeOpen = false}><X size={18} /></button></div>
    <div class="recipient-row"><span>To</span><RecipientCombobox bind:value={compose.to} {suggestions} label="To" placeholder="name@example.com" /></div>
    {#if showCcBcc}
      <div class="recipient-row"><span>Cc</span><RecipientCombobox bind:value={compose.cc} {suggestions} label="Cc" /></div>
      <div class="recipient-row"><span>Bcc</span><RecipientCombobox bind:value={compose.bcc} {suggestions} label="Bcc" /></div>
    {/if}
    <div class="compose-subject"><input bind:value={compose.subject} placeholder="Subject" aria-label="Subject" /><button class="subtle-button" onclick={() => showCcBcc = !showCcBcc}>{showCcBcc ? "Hide Cc/Bcc" : "Cc/Bcc"}</button></div>
    <div class="body-field"><RichTextEditor bind:html={compose.body} bind:text={compose.bodyText} placeholder="Write your message" resetKey={composeVersion} /></div>
    {#if activeSignature}<p class="signature-note">Your saved signature will be appended.</p>{/if}
    {#if compose.attachments?.length}<p class="attachment-note"><Paperclip size={15} /> {compose.attachments.length} attachment{compose.attachments.length === 1 ? "" : "s"} ready</p>{/if}
    <div class="compose-actions">
      <div><button class="primary-action" onclick={send} disabled={busy}><Send size={17} /> Send</button><button class="icon-button" aria-label="Attach files" title="Attach files" onclick={() => document.getElementById("attachment-input")?.click()}><Paperclip size={18} /></button><input id="attachment-input" class="file-input" type="file" multiple onchange={attach} /></div>
      <div class="schedule-row"><input type="datetime-local" bind:value={sendLaterAt} aria-label="Send later at" /><button class="subtle-button" onclick={sendLater} disabled={busy}><Clock3 size={15} /> Send later</button></div>
      <div><button class="subtle-button" onclick={saveDraft} disabled={busy}>Save draft</button><button class="icon-button" aria-label="Discard draft" title="Discard draft" onclick={() => { composeOpen = false; resetCompose(); }}><Trash2 size={18} /></button></div>
    </div>
  </ComposeDialog>
  <SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false}>
    <div class="settings-head"><span><Settings2 size={19} /> Settings</span><button class="icon-button" aria-label="Close settings" onclick={() => settingsOpen = false}><X size={18} /></button></div>
      <section>
        <h2>Scheduled sends</h2>
        <p class="setting-copy">Messages queued to send later. Cancel any time before it fires.</p>
        <div class="settings-list">
          {#each scheduledSends.filter((item) => item.status === "pending") as item (item.id)}
            <p><b>{item.subject || "(no subject)"}</b> <code>{scheduledLabel(item)}</code> <button class="icon-button" aria-label="Cancel scheduled send" title="Cancel" onclick={() => cancelScheduledSend(item.id)}><Trash2 size={16} /></button></p>
          {:else}<p class="muted">No scheduled sends pending.</p>{/each}
        </div>
      </section>
      <section>
        <h2>Account</h2>
        <p class="setting-copy">Signed in as <b>{current}</b>.</p>
        {#if sync.errorKind}
          <div class="account-status" class:warn={sync.errorKind === "auth-expired"}>
            <p>{SYNC_ERROR_MESSAGES[sync.errorKind]}</p>
            {#if sync.errorKind === "auth-expired"}
              <button class="secondary-action" onclick={reauthenticate}>Sign in again</button>
            {:else}
              <button class="secondary-action" onclick={() => sync.retryNow(() => loadMailbox(folder))}>Retry sync</button>
            {/if}
          </div>
        {:else}
          <p class="account-ok">Connected. Last synced {sync.lastSyncAt ? new Date(sync.lastSyncAt).toLocaleTimeString() : "—"}.</p>
        {/if}
      </section>
      <section>
        <h2>Appearance</h2>
        <p class="setting-copy">Choose the contrast that is most comfortable for you.</p>
        <div class="segmented" aria-label="Theme preference"><button class:active={(preferences.theme ?? "light") === "light"} aria-pressed={(preferences.theme ?? "light") === "light"} onclick={() => setTheme("light")}>Light</button><button class:active={(preferences.theme ?? "light") === "dark"} aria-pressed={(preferences.theme ?? "light") === "dark"} onclick={() => setTheme("dark")}>Dark</button></div>
      <label class="setting-copy"><input type="checkbox" checked={preferences.shortcuts !== false} onchange={(event) => shortcuts.setEnabled(event.currentTarget.checked)} /> Keyboard shortcuts</label>
      </section>
      <section>
        <h2>Contacts</h2>
        <div class="stack-fields"><input bind:value={contact.name} placeholder="Name" /><input bind:value={contact.email} placeholder="email@example.com" /><button class="secondary-action" onclick={() => post({ action: "config", kind: "contact", value: contact }, false).then(() => loadMailbox(folder))}><UserPlus size={16} /> Add contact</button></div>
        <div class="settings-list">{#each contacts as item}<p><b>{item.name || item.email}</b><code>{item.email}</code></p>{:else}<p class="muted">No contacts saved.</p>{/each}</div>
      </section>
      <section>
        <h2>Signature</h2>
        <textarea bind:value={signature} placeholder="Signature for this mailbox"></textarea>
        <button class="secondary-action" onclick={() => saveSettings("signature", { signature })}>Save signature</button>
      </section>
      <section>
        <h2>Vacation reply</h2>
        <label class="check"><input type="checkbox" bind:checked={vacation.enabled} /> <span>Send automatic replies</span></label>
        <input bind:value={vacation.subject} placeholder="Subject" />
        <textarea bind:value={vacation.body} placeholder="Reply body"></textarea>
        <button class="secondary-action" onclick={() => saveSettings("vacation", vacation)}>Save vacation reply</button>
      </section>
      <section>
        <h2>Inbox rules</h2>
        <div class="rule-builder">
          <div class="menu-wrap"><button class="field-button" aria-haspopup="menu" aria-expanded={ruleFieldMenuOpen} onclick={() => ruleFieldMenuOpen = !ruleFieldMenuOpen}>{rule.field}<ChevronDown size={15} /></button>{#if ruleFieldMenuOpen}<div class="popup-menu" role="menu">{#each ruleFields as field}<button role="menuitem" onclick={() => chooseRuleField(field)}>{field}</button>{/each}</div>{/if}</div>
          <input bind:value={rule.contains} placeholder="contains" />
          <div class="menu-wrap"><button class="field-button" aria-haspopup="menu" aria-expanded={ruleActionMenuOpen} onclick={() => ruleActionMenuOpen = !ruleActionMenuOpen}>{rule.action}<ChevronDown size={15} /></button>{#if ruleActionMenuOpen}<div class="popup-menu" role="menu">{#each ruleActions as action}<button role="menuitem" onclick={() => chooseRuleAction(action)}>{action}</button>{/each}</div>{/if}</div>
        </div>
        <button class="secondary-action" onclick={() => post({ action: "config", kind: "rule", value: rule }, false).then(() => loadMailbox(folder))}>Add rule</button>
        <button class="subtle-button" onclick={() => post({ action: "apply-rules", folder })}>Apply rules now</button>
        <div class="settings-list">{#each rules as item}<p><b>{item.field}</b> contains <code>{item.contains}</code><span>{item.action}</span></p>{:else}<p class="muted">No rules configured.</p>{/each}</div>
      </section>
      <section>
        <h2>Import or export</h2>
        <textarea bind:value={importText} placeholder="Settings JSON"></textarea>
      </section>
  </SettingsDrawer>

  {#if deletePending}
    <div class="overlay confirm-overlay">
      <div class="confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
        <div class="danger-icon"><Trash2 size={20} /></div>
        <h2 id="delete-title">Delete this message?</h2>
        <p>It will move to Trash. You can restore it there if needed.</p>
        <div><button class="subtle-button" onclick={() => deletePending = false}>Keep message</button><button class="danger-action" onclick={confirmDelete}>Move to Trash</button></div>
      </div>
    </div>
  {/if}

  {#if shortcutsOpen}
    <div class="overlay shortcuts-overlay" role="presentation" onclick={() => (shortcutsOpen = false)}>
      <div class="shortcuts-modal" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" tabindex="-1" onclick={(event) => event.stopPropagation()} onkeydown={() => {}}>
        <div class="shortcuts-head"><span id="shortcuts-title"><Keyboard size={18} /> Keyboard shortcuts</span><button class="icon-button" aria-label="Close shortcuts" onclick={() => (shortcutsOpen = false)}><X size={18} /></button></div>
        <dl class="shortcuts-list">
          <div><dt><kbd>c</kbd></dt><dd>Compose new message</dd></div>
          <div><dt><kbd>r</kbd></dt><dd>Reply</dd></div>
          <div><dt><kbd>R</kbd></dt><dd>Reply all</dd></div>
          <div><dt><kbd>f</kbd></dt><dd>Forward</dd></div>
          <div><dt><kbd>e</kbd></dt><dd>Archive</dd></div>
          <div><dt><kbd>Delete</kbd> / <kbd>#</kbd></dt><dd>Delete</dd></div>
          <div><dt><kbd>Enter</kbd></dt><dd>Open message</dd></div>
          <div><dt><kbd>s</kbd></dt><dd>Flag / unflag</dd></div>
          <div><dt><kbd>u</kbd></dt><dd>Mark as unread</dd></div>
          <div><dt><kbd>!</kbd></dt><dd>Mark as spam</dd></div>
          <div><dt><kbd>j</kbd> / <kbd>&darr;</kbd></dt><dd>Next message</dd></div>
          <div><dt><kbd>k</kbd> / <kbd>&uarr;</kbd></dt><dd>Previous message</dd></div>
          <div><dt><kbd>/</kbd></dt><dd>Focus search</dd></div>
          <div><dt><kbd>Esc</kbd></dt><dd>Close dialog / deselect message</dd></div>
          <div><dt><kbd>?</kbd></dt><dd>Show this reference</dd></div>
        </dl>
        <p class="shortcuts-note">Shortcuts are disabled while typing in the composer or search field.</p>
      </div>
    </div>
  {/if}
</main>

<style>
  :global(*) {
    box-sizing: border-box;
  }
  :global(html),
  :global(body) {
    min-height: 100%;
    margin: 0;
  }
  :global(body) {
    background: var(--app-bg);
    color: var(--ink);
    font-family:
    ui-sans-serif,
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    sans-serif;
  }
  :global(html) {
    --app-bg: #edf2ef;
    --surface: #fff;
    --surface-soft: #f6f9f7;
    --surface-selected: #e5f2ec;
    --ink: #17231f;
    --muted: #60716b;
    --line: #d7e2dd;
    --line-soft: #e9efec;
    --accent: #126b5a;
    --accent-hover: #0e594b;
    --accent-soft: #dcefe7;
    --rail: #10211d;
    --rail-hover: #1d3831;
    --rail-text: #e6f0ec;
    --danger: #a33b2e;
    --focus: #9ad5c5;
  }
  :global(html[data-theme="dark"]),
  :global(html[data-theme="akurai"]) {
    --app-bg: #0d1512;
    --surface: #14221e;
    --surface-soft: #10201b;
    --surface-selected: #203a32;
    --ink: #e7f1ec;
    --muted: #a1b3ac;
    --line: #2a4038;
    --line-soft: #1d3029;
    --accent: #79c4ab;
    --accent-hover: #a4dcc9;
    --accent-soft: #244f42;
    --rail: #091512;
    --rail-hover: #173028;
    --rail-text: #e6f0ec;
    --danger: #ef9e93;
    --focus: #a4dcc9;
  }
  :global(.spin) {
    animation: 0.8s linear infinite spin;
  }
  @keyframes spin {
    to {
      transform: rotate(360deg);
    }
  }
  :global(.vip-icon) {
    color: #d6a62c;
  }
  button,
  input,
  textarea {
    font: inherit;
  }
  button {
    cursor: pointer;
  }
  button:disabled {
    cursor: not-allowed;
    opacity: 0.52;
  }
  button:focus-visible,
  input:focus,
  textarea:focus {
    outline: 3px solid var(--focus);
    outline-offset: 2px;
  }
  input,
  textarea {
    border: 1px solid var(--line);
    background: var(--surface);
    width: 100%;
    min-width: 0;
    color: var(--ink);
    border-radius: 6px;
    padding: 10px 12px;
  }
  input::placeholder,
  textarea::placeholder {
    color: var(--muted);
    opacity: 0.82;
  }
  textarea {
    resize: vertical;
  }
  .sr {
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    width: 1px;
    height: 1px;
    position: absolute;
    overflow: hidden;
  }
  .webmail-shell {
    grid-template-columns: 232px minmax(0, 1fr);
    min-height: 100dvh;
    display: grid;
  }
  .platform-rail {
    background: var(--rail);
    color: var(--rail-text);
    flex-direction: column;
    gap: 22px;
    min-width: 0;
    padding: 20px 16px;
    display: flex;
  }
  .brand {
    color: inherit;
    align-items: center;
    gap: 11px;
    min-width: 0;
    padding: 4px;
    text-decoration: none;
    display: flex;
  }
  .mark {
    color: #112019;
    background: #c7ef61;
    border-radius: 6px;
    flex: none;
    place-items: center;
    block-size: 38px;
    inline-size: 38px;
    font-weight: 900;
    display: grid;
  }
  .brand strong,
  .brand small {
    text-overflow: ellipsis;
    white-space: nowrap;
    display: block;
    overflow: hidden;
  }
  .brand strong {
    letter-spacing: -0.01em;
    font-size: 14px;
  }
  .brand small {
    color: #99ada5;
    margin-top: 2px;
    font-size: 11px;
  }
  nav {
    gap: 4px;
    display: grid;
  }
  nav a,
  .sign-out {
    color: #cfddd7;
    border-radius: 6px;
    align-items: center;
    gap: 10px;
    min-block-size: 42px;
    padding: 0 11px;
    font-size: 14px;
    text-decoration: none;
    display: flex;
  }
  nav a:hover,
  nav a.active,
  .sign-out:hover {
    background: var(--rail-hover);
    color: #fff;
  }
  .rail-footer {
    gap: 14px;
    margin-top: auto;
    display: grid;
  }
  .secure-state {
    color: #9bb1a8;
    align-items: center;
    gap: 8px;
    padding: 0 10px;
    font-size: 12px;
    display: flex;
  }
  .mail-app {
    grid-template-rows: auto 76px minmax(0, 1fr);
    min-width: 0;
    min-height: 100dvh;
    display: grid;
  }
  .mail-topbar {
    z-index: 20;
    background: var(--surface);
    border-bottom: 1px solid var(--line);
    grid-template-columns: minmax(170px, 245px) minmax(260px, 1fr) auto;
    align-items: center;
    gap: 16px;
    padding: 12px 18px;
    display: grid;
    position: relative;
  }
  .identity {
    gap: 2px;
    min-width: 0;
    display: grid;
  }
  .eyebrow {
    color: var(--muted);
    letter-spacing: 0.04em;
    font-size: 11px;
    font-weight: 750;
  }
  .identity strong {
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 15px;
    overflow: hidden;
  }
  .search-control {
    background: var(--surface-soft);
    border: 1px solid var(--line);
    min-width: 0;
    min-height: 42px;
    color: var(--muted);
    border-radius: 6px;
    align-items: center;
    gap: 9px;
    padding: 0 12px;
    display: flex;
  }
  .search-control input {
    background: 0 0;
    border: 0;
    border-radius: 0;
    outline: 0;
    padding: 0;
  }
  .clear-search {
    margin-left: auto;
  }
  .top-actions {
    align-items: center;
    gap: 8px;
    min-width: 0;
    display: flex;
  }
  .menu-wrap {
    position: relative;
  }
  .field-button {
    border: 1px solid var(--line);
    background: var(--surface);
    min-height: 42px;
    color: var(--ink);
    text-align: left;
    border-radius: 6px;
    justify-content: space-between;
    align-items: center;
    gap: 8px;
    padding: 0 11px;
    display: flex;
  }
  .popup-menu {
    z-index: 70;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: 6px;
    gap: 2px;
    min-width: 100%;
    max-width: min(360px, 100vw - 32px);
    padding: 6px;
    display: grid;
    position: absolute;
    top: calc(100% + 7px);
    left: 0;
    box-shadow: 0 18px 48px #0c1f182e;
  }
  .popup-menu button {
    color: var(--ink);
    text-align: left;
    text-overflow: ellipsis;
    white-space: nowrap;
    background: 0 0;
    border: 0;
    border-radius: 6px;
    padding: 9px 10px;
    overflow: hidden;
  }
  .popup-menu button:hover {
    background: var(--surface-selected);
    color: var(--accent);
  }
  .icon-button {
    border: 1px solid var(--line);
    background: var(--surface);
    block-size: 40px;
    inline-size: 40px;
    color: var(--ink);
    border-radius: 6px;
    flex: none;
    place-items: center;
    display: inline-grid;
  }
  .icon-button:hover {
    background: var(--surface-selected);
    color: var(--accent);
  }
  .primary-action,
  .secondary-action,
  .danger-action {
    border: 0;
    border-radius: 6px;
    justify-content: center;
    align-items: center;
    gap: 8px;
    min-block-size: 42px;
    padding: 0 14px;
    font-weight: 750;
    display: inline-flex;
  }
  .primary-action {
    background: var(--accent);
    color: #fff;
  }
  :global(html[data-theme="dark"]) .primary-action,
  :global(html[data-theme="akurai"]) .primary-action {
    color: #082018;
  }
  .primary-action:hover {
    background: var(--accent-hover);
  }
  .secondary-action {
    background: var(--accent-soft);
    color: var(--accent);
  }
  .secondary-action:hover,
  .subtle-button:hover {
    background: var(--surface-selected);
  }
  .danger-action {
    background: var(--danger);
    color: #fff;
  }
  .subtle-button {
    min-height: 38px;
    color: var(--muted);
    background: 0 0;
    border: 0;
    border-radius: 6px;
    padding: 0 10px;
    font-weight: 700;
  }
  .mail-canvas {
    background: var(--surface);
    grid-template-columns: 205px minmax(310px, 400px) minmax(0, 1fr);
    min-height: 0;
    display: grid;
    overflow: hidden;
  }
  kbd {
    border: 1px solid var(--line);
    font: inherit;
    border-bottom-width: 2px;
    border-radius: 4px;
    padding: 0 3px;
  }
  dl {
    gap: 5px;
    margin: 0;
    display: grid;
  }
  dl div {
    grid-template-columns: 48px minmax(0, 1fr);
    gap: 10px;
    display: grid;
  }
  dt {
    color: var(--muted);
    font-size: 12px;
  }
  dd {
    overflow-wrap: anywhere;
    margin: 0;
    font-size: 13px;
  }
  .toast {
    z-index: 100;
    border: 1px solid var(--line);
    background: var(--surface);
    max-width: min(400px, 100vw - 40px);
    color: var(--ink);
    border-radius: 6px;
    align-items: center;
    gap: 12px;
    padding: 10px 10px 10px 14px;
    display: flex;
    position: fixed;
    bottom: 20px;
    right: 20px;
    box-shadow: 0 16px 45px #0c1f1833;
  }
  .toast span {
    flex: 1;
  }
  .toast button {
    color: var(--muted);
    background: 0 0;
    border: 0;
  }
  .overlay {
    z-index: 80;
    background: #07130e6b;
    place-items: end center;
    padding: 18px;
    display: grid;
    position: fixed;
    inset: 0;
  }
  .compose-title {
    background: var(--rail);
    min-height: 53px;
    color: var(--rail-text);
    justify-content: space-between;
    align-items: center;
    padding: 0 12px 0 16px;
    display: flex;
  }
  .compose-title span {
    align-items: center;
    gap: 8px;
    font-weight: 800;
    display: inline-flex;
  }
  .inverse {
    color: #fff;
    background: 0 0;
    border-color: #ffffff2e;
  }
  .inverse:hover {
    color: #fff;
    background: #ffffff1a;
  }
  .recipient-row,
  .compose-subject {
    border-bottom: 1px solid var(--line-soft);
    grid-template-columns: 52px minmax(0, 1fr);
    align-items: center;
    gap: 8px;
    padding: 7px 14px;
    display: grid;
  }
  .recipient-row > span {
    color: var(--muted);
    font-size: 12px;
    font-weight: 750;
  }
  .compose-subject input {
    border: 0;
    border-radius: 0;
    padding: 6px 0;
  }
  .compose-subject {
    grid-template-columns: minmax(0, 1fr) auto;
  }
  .body-field {
    min-height: 230px;
    padding: 14px;
    display: grid;
  }
  .signature-note,
  .attachment-note {
    color: var(--muted);
    align-items: center;
    gap: 6px;
    margin: 0;
    padding: 0 14px 8px;
    font-size: 12px;
    display: flex;
  }
  .compose-actions {
    border-top: 1px solid var(--line);
    justify-content: space-between;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    display: flex;
  }
  .compose-actions > div {
    align-items: center;
    gap: 7px;
    display: flex;
  }
  .file-input {
    display: none;
  }
  .schedule-row {
    align-items: center;
    gap: 7px;
    display: flex;
  }
  .schedule-row
    input[type="datetime-local"] {
    border-radius: 6px;
    padding: 5px 8px;
    font-size: 12px;
  }
  .settings-list button.icon-button {
    justify-self: end;
  }
  .settings-head {
    z-index: 2;
    border-bottom: 1px solid var(--line);
    background: var(--surface);
    justify-content: space-between;
    align-items: center;
    min-height: 64px;
    padding: 0 16px;
    display: flex;
    position: sticky;
    top: 0;
  }
  .settings-head span {
    align-items: center;
    gap: 8px;
    font-weight: 850;
    display: inline-flex;
  }
  .setting-copy,
  .muted {
    color: var(--muted);
    margin: 0;
    font-size: 13px;
    line-height: 1.45;
  }
  .account-status {
    background: color-mix(in srgb, var(--danger) 8%, var(--surface));
    color: var(--danger);
    border-radius: 6px;
    gap: 8px;
    padding: 10px;
    display: grid;
  }
  .account-status.warn {
    background: color-mix(in srgb, #d6a62c 14%, var(--surface));
    color: #7a5a00;
  }
  .account-status p {
    margin: 0;
    font-size: 13px;
  }
  .account-ok {
    color: var(--muted);
    margin: 0;
    font-size: 12px;
  }
  .segmented {
    background: var(--surface-soft);
    border-radius: 6px;
    padding: 3px;
    display: flex;
  }
  .segmented button {
    min-height: 34px;
    color: var(--muted);
    background: 0 0;
    border: 0;
    border-radius: 6px;
    flex: 1;
    font-weight: 750;
  }
  .segmented button.active {
    background: var(--surface);
    color: var(--accent);
    box-shadow: 0 1px 3px #091b141f;
  }
  .stack-fields {
    gap: 8px;
    display: grid;
  }
  .settings-list {
    gap: 6px;
    display: grid;
  }
  .settings-list p {
    color: var(--muted);
    gap: 3px;
    margin: 0;
    font-size: 12px;
    display: grid;
  }
  .settings-list b {
    color: var(--ink);
    font-size: 13px;
  }
  .settings-list code {
    text-overflow: ellipsis;
    white-space: nowrap;
    background: var(--surface-soft);
    width: fit-content;
    max-width: 100%;
    color: var(--muted);
    border-radius: 5px;
    padding: 2px 5px;
    overflow: hidden;
  }
  .settings-list span {
    color: var(--accent);
    font-weight: 700;
  }
  .check {
    color: var(--ink);
    align-items: center;
    gap: 8px;
    font-size: 13px;
    display: flex;
  }
  .check input {
    block-size: 18px;
    inline-size: 18px;
  }
  .rule-builder {
    grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr) minmax(0, 0.8fr);
    gap: 7px;
    display: grid;
  }
  .rule-builder .field-button {
    width: 100%;
    min-height: 40px;
  }
  .confirm-overlay {
    z-index: 110;
    place-items: center;
  }
  .confirm-modal {
    border: 1px solid var(--line);
    background: var(--surface);
    width: min(390px, 100%);
    color: var(--ink);
    text-align: center;
    border-radius: 6px;
    justify-items: center;
    gap: 10px;
    padding: 26px;
    display: grid;
    box-shadow: 0 30px 80px #04100b59;
  }
  .danger-icon {
    background: color-mix(in srgb, var(--danger) 12%, var(--surface));
    block-size: 42px;
    inline-size: 42px;
    color: var(--danger);
    border-radius: 6px;
    place-items: center;
    display: grid;
  }
  .confirm-modal h2,
  .confirm-modal p {
    margin: 0;
  }
  .confirm-modal h2 {
    font-size: 18px;
  }
  .confirm-modal p {
    color: var(--muted);
    font-size: 13px;
    line-height: 1.5;
  }
  .confirm-modal > div:last-child {
    gap: 8px;
    margin-top: 7px;
    display: flex;
  }
  .shortcuts-overlay {
    place-items: center;
  }
  .shortcuts-modal {
    border: 1px solid var(--line);
    background: var(--surface);
    width: min(440px, 100%);
    max-height: min(600px, 100dvh - 36px);
    color: var(--ink);
    border-radius: 6px;
    gap: 4px;
    padding: 0 0 16px;
    display: grid;
    overflow: auto;
    box-shadow: 0 30px 80px #04100b59;
  }
  .shortcuts-head {
    z-index: 1;
    border-bottom: 1px solid var(--line);
    background: var(--surface);
    justify-content: space-between;
    align-items: center;
    min-height: 56px;
    padding: 0 16px;
    display: flex;
    position: sticky;
    top: 0;
  }
  .shortcuts-head span {
    align-items: center;
    gap: 8px;
    font-weight: 800;
    display: inline-flex;
  }
  .shortcuts-list {
    gap: 2px;
    margin: 8px 16px 0;
    display: grid;
  }
  .shortcuts-list div {
    grid-template-columns: 96px minmax(0, 1fr);
    align-items: center;
    gap: 10px;
    padding: 6px 0;
    display: grid;
  }
  .shortcuts-list dt {
    gap: 4px;
    display: flex;
  }
  .shortcuts-list dd {
    color: var(--muted);
    margin: 0;
    font-size: 13px;
  }
  .shortcuts-note {
    color: var(--muted);
    margin: 12px 16px 0;
    font-size: 12px;
  }
  @media (width<=1180px) {
    .webmail-shell {
      grid-template-columns: 200px minmax(0, 1fr);
    }
    .platform-rail {
      padding-inline: 13px;
    }
    .mail-canvas {
      grid-template-columns: 180px minmax(290px, 360px) minmax(0, 1fr);
    }
  }
  @media (width<=900px) {
    .webmail-shell {
      grid-template-columns: minmax(0, 1fr);
    }
    .platform-rail {
      z-index: 60;
      flex-direction: row;
      align-items: center;
      gap: 12px;
      min-height: 58px;
      padding: 9px 13px;
      position: sticky;
      top: 0;
    }
    .brand {
      padding: 0;
    }
    .brand small {
      display: none;
    }
    .platform-rail nav {
      margin-left: auto;
      display: flex;
    }
    .platform-rail
      nav
      a {
      min-height: 37px;
      padding: 0 9px;
    }
    .rail-footer {
      margin: 0;
      display: block;
    }
    .secure-state {
      display: none;
    }
    .sign-out {
      min-height: 37px;
      padding: 0 9px;
    }
    .mail-app {
      grid-template-rows: auto auto minmax(0, 1fr);
      min-height: calc(100dvh - 58px);
    }
    .mail-topbar {
      grid-template-rows: auto auto;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 10px;
      padding: 10px 13px;
    }
    .search-control {
      grid-area: 2/1 / auto/-1;
    }
    .identity {
      grid-column: 1;
    }
    .top-actions {
      grid-column: 2;
    }
    .mail-canvas {
      grid-template-columns: 160px minmax(280px, 1fr);
    }
    .mail-canvas.reading {
      grid-template-columns: minmax(0, 1fr);
    }
  }
  @media (width<=640px) {
    .platform-rail {
      gap: 8px;
    }
    .brand span:last-child {
      display: none;
    }
    .platform-rail
      nav
      a
      span,
    .sign-out {
      font-size: 0;
    }
    .platform-rail
      nav
      a,
    .sign-out {
      justify-content: center;
      inline-size: 38px;
      padding: 0;
    }
    .mail-topbar {
      min-height: 112px;
    }
    .identity strong {
      max-width: 54vw;
    }
    .top-actions .settings-button {
      display: none;
    }
    .mail-canvas {
      grid-template-columns: minmax(0, 1fr);
    }
    .overlay {
      padding: 0;
    }
    .rule-builder {
      grid-template-columns: 1fr;
    }
    .compose-actions {
      align-items: stretch;
    }
    .compose-actions > div {
      justify-content: space-between;
    }
    .toast {
      max-width: calc(100vw - 24px);
      bottom: 12px;
      right: 12px;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
      scroll-behavior: auto !important;
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
    }
  }
</style>