AkurAI Build
Menu

Public repository / 76f58dc21068ab7c01c75e65fe305d1b913124fb

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

Latest change aa05f301181c865428b6faf9c750f0631324c73a - feat: compute reply-needed, follow-up and priority signals for conversations (AKURAI-MAIL-7) by Ólafur Búi Ólafsson

<script lang="ts">
  import {
    Archive,
    ArrowLeft,
    AtSign,
    ChevronDown,
    ChevronLeft,
    ChevronRight,
    CircleHelp,
    Download,
    FileText,
    Forward,
    Inbox,
    Mail,
    Menu,
    MoreHorizontal,
    Paperclip,
    PenLine,
    RefreshCw,
    Reply,
    ReplyAll,
    Search,
    Send,
    Settings2,
    ShieldAlert,
    SlidersHorizontal,
    Sparkles,
    Star,
    Trash2,
    UserPlus,
    X
  } from "lucide-svelte";
  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 SignalBadge from "$lib/webmail/SignalBadge.svelte";

  let { data } = $props();
  let status = $derived(data.status);
  let admin = $derived(data.admin);
  let webmailState: any = $state(null);
  let current = $state("");
  let folder = $state("Inbox");
  let query = $state("");
  let selected: any = $state(null);
  let notice = $state("");
  let busy = $state(false);
  let composeOpen = $state(false);
  let settingsOpen = $state(false);
  let mailboxMenuOpen = $state(false);
  let deletePending = $state(false);
  let showCcBcc = $state(false);
  const emptyCompose = () => ({ to: "", cc: "", bcc: "", subject: "", body: "", attachments: [] });
  let compose: any = $state(emptyCompose());
  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);
  const pageSize = 40;

  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 ?? []);
  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 ruleFields = ["from", "to", "subject", "preview"];
  const ruleActions = ["Archive", "Spam", "Trash"];

  $effect(() => {
    if (!webmailState) webmailState = data.webmail;
    if (!current) current = data.webmail.mailbox || data.status.users?.[0]?.email || "";
  });

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

  $effect(() => {
    const onKey = (event: KeyboardEvent) => {
      const tag = (event.target as HTMLElement)?.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (event.target as HTMLElement)?.isContentEditable) return;
      if (event.key === "c") composeOpen = true;
      if (event.key === "/") {
        event.preventDefault();
        (document.querySelector("[data-search]") as HTMLInputElement | null)?.focus();
      }
      if (!selected) return;
      if (event.key === "r") reply(false);
      if (event.key === "f") forward();
      if (event.key === "Delete") deletePending = true;
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });

  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) {
    return name === "Inbox" ? Inbox : name === "Spam" ? ShieldAlert : name === "Trash" ? Trash2 : FileText;
  }

  async function loadMailbox(nextFolder = folder, cursor = "", resetPage = true) {
    busy = true;
    try {
      const params = new URLSearchParams({ email: current, folder: nextFolder, query, limit: String(pageSize) });
      if (cursor) params.set("cursor", cursor);
      const 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 = [""];
      }
      loadSignals(nextFolder);
    } catch (error) {
      notice = error instanceof Error ? error.message : "Could not load this folder.";
    } finally {
      busy = false;
    }
  }

  async function loadSignals(forFolder = folder) {
    signalsBusy = true;
    try {
      const params = new URLSearchParams({ email: current, 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({ email: current, 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;
  }

  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", email: current, 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({ email: current, ...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;
  }

  async function send() {
    const result = await post({ action: "send", message: { ...compose, from: current, signature: activeSignature } });
    if (result.ok) {
      composeOpen = false;
      resetCompose();
      notice = "Message sent";
    }
  }

  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();
  }

  async function exportSettings() {
    const result = await post({ action: "export" }, false);
    if (result.ok) importText = JSON.stringify(result.webmail, null, 2);
  }

  async function importSettings() {
    try {
      await post({ action: "import", value: JSON.parse(importText) }, false);
      await loadMailbox(folder);
    } catch {
      notice = "Import JSON is invalid";
    }
  }

  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: `\n\nOn ${selected.date}, ${selected.from} wrote:\n${selected.bodyText ?? ""}`,
      attachments: []
    };
    showCcBcc = all;
    composeOpen = true;
  }

  function forward() {
    if (!selected) return;
    compose = {
      to: "",
      cc: "",
      bcc: "",
      subject: selected.subject.startsWith("Fwd:") ? selected.subject : `Fwd: ${selected.subject}`,
      body: `\n\nForwarded message:\nFrom: ${selected.from}\nDate: ${selected.date}\nSubject: ${selected.subject}\n\n${selected.bodyText ?? ""}`,
      attachments: []
    };
    showCcBcc = false;
    composeOpen = true;
  }

  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((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 ?? {}) };
  }

  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 chooseMailbox(email: string) {
    settingsOpen = false;
    current = email;
    mailboxMenuOpen = false;
    loadMailbox("Inbox");
  }

  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: { theme } }, false).then(() => loadMailbox(folder));
  }

  function confirmDelete() {
    if (!selected) return;
    deletePending = false;
    post({ action: "message", id: selected.id, messageAction: "delete" });
  }
</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="/webmail/logout"><ArrowLeft size={17} strokeWidth={1.8} /> Sign out</a>
    </div>
  </aside>

  <section class="mail-app" aria-busy={busy}>
    <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">
        <div class="menu-wrap">
          <button class="mailbox-switch" aria-haspopup="menu" aria-expanded={mailboxMenuOpen} onclick={() => mailboxMenuOpen = !mailboxMenuOpen}>
            <span>{current || "Choose mailbox"}</span><ChevronDown size={16} strokeWidth={1.8} />
          </button>
          {#if mailboxMenuOpen}
            <div class="popup-menu mailbox-menu" role="menu">
              {#each status.users as user}
                <button class:current={user.email === current} role="menuitem" onclick={() => chooseMailbox(user.email)}>{user.email}</button>
              {/each}
            </div>
          {/if}
        </div>
        <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 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) => loadMailbox(name)}
        {messages}
        {totalMessages}
        {query}
        {senderLabel}
        {shortDate}
        {selected}
        onRead={readMessage}
        {page}
        {pageCount}
        {nextCursor}
        {busy}
        onPrevious={previousPage}
        onNext={nextPage}
        {signalForMessage}
      />
      <MessageReader
        {selected}
        {selectedDate}
        {current}
        {reply}
        {forward}
        {post}
        onBack={() => selected = null}
        {downloadAttachment}
        onDelete={() => deletePending = true}
        convSignal={signalForSelected}
        onCorrectSignal={correctSignal}
        onDismissSignal={correctSignal}
      />
    </div>
  </section>

  {#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><input bind:value={compose.to} autocomplete="email" placeholder="name@example.com" /></div>
        {#if showCcBcc}
          <div class="recipient-row"><span>Cc</span><input bind:value={compose.cc} autocomplete="email" /></div>
          <div class="recipient-row"><span>Bcc</span><input bind:value={compose.bcc} autocomplete="email" /></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>
        <label class="body-field"><span class="sr">Message</span><textarea bind:value={compose.body} placeholder="Write your message"></textarea></label>
        {#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><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>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>
      </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", { email: current, 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}
</main>

<style>
  :global(*) { box-sizing: border-box; }
  :global(html), :global(body) { margin: 0; min-height: 100%; }
  :global(body) { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--app-bg); color: var(--ink); }
  :global(html) { --app-bg: #edf2ef; --surface: #ffffff; --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; }
  button, input, textarea { font: inherit; }
  button { cursor: pointer; }
  button:disabled { cursor: not-allowed; opacity: .52; }
  button:focus-visible, input:focus, textarea:focus { outline: 3px solid var(--focus); outline-offset: 2px; }
  input, textarea { width: 100%; min-width: 0; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 10px 12px; }
  input::placeholder, textarea::placeholder { color: var(--muted); opacity: .82; }
  textarea { resize: vertical; }
  .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
  .webmail-shell { min-height: 100dvh; display: grid; grid-template-columns: 232px minmax(0, 1fr); }
  .platform-rail { background: var(--rail); color: var(--rail-text); padding: 20px 16px; display: flex; flex-direction: column; gap: 22px; min-width: 0; }
  .brand { display: flex; align-items: center; gap: 11px; color: inherit; text-decoration: none; min-width: 0; padding: 4px; }
  .mark { display: grid; place-items: center; flex: 0 0 auto; inline-size: 38px; block-size: 38px; border-radius: 11px; background: #c7ef61; color: #112019; font-weight: 900; }
  .brand strong, .brand small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .brand strong { font-size: 14px; letter-spacing: -.01em; }
  .brand small { color: #99ada5; font-size: 11px; margin-top: 2px; }
  nav { display: grid; gap: 4px; }
  nav a, .sign-out { display: flex; align-items: center; gap: 10px; min-block-size: 42px; padding: 0 11px; border-radius: 9px; color: #cfddd7; text-decoration: none; font-size: 14px; }
  nav a:hover, nav a.active, .sign-out:hover { background: var(--rail-hover); color: #fff; }
  .rail-footer { display: grid; gap: 14px; margin-top: auto; }
  .secure-state { display: flex; gap: 8px; align-items: center; color: #9bb1a8; font-size: 12px; padding: 0 10px; }
  .mail-app { min-width: 0; min-height: 100dvh; display: grid; grid-template-rows: 76px minmax(0, 1fr); }
  .mail-topbar { position: relative; z-index: 20; display: grid; grid-template-columns: minmax(170px, 245px) minmax(260px, 1fr) auto; align-items: center; gap: 16px; padding: 12px 18px; background: var(--surface); border-bottom: 1px solid var(--line); }
  .identity { display: grid; gap: 2px; min-width: 0; }
  .eyebrow { color: var(--muted); font-size: 11px; font-weight: 750; letter-spacing: .04em; }
  .identity strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
  .search-control { min-width: 0; display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 0 12px; background: var(--surface-soft); border: 1px solid var(--line); border-radius: 12px; color: var(--muted); }
  .search-control input { border: 0; border-radius: 0; background: transparent; padding: 0; outline: 0; }
  .clear-search { margin-left: auto; }
  .top-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
  .menu-wrap { position: relative; }
  .mailbox-switch, .field-button { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 42px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 11px; text-align: left; }
  .mailbox-switch { max-width: 245px; }
  .mailbox-switch span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .popup-menu { position: absolute; z-index: 70; top: calc(100% + 7px); left: 0; min-width: 100%; max-width: min(360px, calc(100vw - 32px)); padding: 6px; display: grid; gap: 2px; background: var(--surface); border: 1px solid var(--line); border-radius: 11px; box-shadow: 0 18px 48px rgb(12 31 24 / .18); }
  .mailbox-menu { right: 0; left: auto; min-width: 260px; }
  .popup-menu button { border: 0; border-radius: 7px; background: transparent; color: var(--ink); padding: 9px 10px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .popup-menu button:hover, .popup-menu button.current { background: var(--surface-selected); color: var(--accent); }
  .icon-button { inline-size: 40px; block-size: 40px; display: inline-grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--ink); }
  .icon-button:hover { background: var(--surface-selected); color: var(--accent); }
  .icon-button.destructive:hover { background: color-mix(in srgb, var(--danger) 12%, var(--surface)); color: var(--danger); }
  .primary-action, .secondary-action, .danger-action { min-block-size: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 9px; padding: 0 14px; font-weight: 750; }
  .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; border: 0; border-radius: 8px; padding: 0 10px; background: transparent; color: var(--muted); font-weight: 700; }
  :global(.spin) { animation: spin .8s linear infinite; }
  .mail-canvas { min-height: 0; display: grid; grid-template-columns: 205px minmax(310px, 400px) minmax(0, 1fr); overflow: hidden; background: var(--surface); }
  .folder-column { min-width: 0; padding: 14px 12px; display: grid; align-content: start; gap: 14px; background: var(--surface-soft); border-right: 1px solid var(--line); }
  .compose-wide { min-height: 44px; display: flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 11px; background: var(--accent); color: #fff; font-weight: 800; box-shadow: 0 9px 20px rgb(18 107 90 / .18); }
  :global(html[data-theme="dark"]) .compose-wide, :global(html[data-theme="akurai"]) .compose-wide { color: #082018; }
  .folder-summary { display: flex; justify-content: space-between; align-items: center; padding: 0 8px; color: var(--muted); font-size: 11px; }
  .folder-summary strong { color: var(--ink); font-size: 11px; }
  .folder-group { display: grid; gap: 3px; }
  .folder-group button { min-height: 39px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: 9px; padding: 0 9px; background: transparent; color: var(--ink); text-align: left; }
  .folder-group button:hover, .folder-group button.active { background: var(--surface-selected); color: var(--accent); }
  .folder-name { display: inline-flex; align-items: center; gap: 10px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
  .folder-group em, .folder-group small { min-width: 21px; padding: 2px 6px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-style: normal; text-align: center; }
  .folder-group small { padding: 0; background: transparent; color: var(--muted); }
  .folder-hint { display: flex; align-items: center; gap: 6px; margin-top: 4px; padding: 0 8px; color: var(--muted); font-size: 11px; }
  kbd { border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 3px; font: inherit; }
  .message-column { min-width: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border-right: 1px solid var(--line); }
  .column-bar { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
  .column-bar div { display: grid; gap: 2px; }
  .column-bar strong { font-size: 17px; letter-spacing: -.02em; }
  .message-count, .pager { color: var(--muted); font-size: 12px; }
  .message-list { min-height: 0; overflow: auto; }
  .message-list > button { position: relative; width: 100%; min-height: 106px; display: flex; gap: 10px; border: 0; border-bottom: 1px solid var(--line-soft); padding: 13px 14px 12px 11px; background: var(--surface); color: var(--ink); text-align: left; }
  .message-list > button:hover, .message-list > button.selected { background: var(--surface-soft); }
  .message-list > button.selected { box-shadow: inset 3px 0 0 var(--accent); }
  .message-dot { inline-size: 7px; block-size: 7px; flex: 0 0 auto; margin-top: 8px; border-radius: 50%; background: transparent; }
  .unread .message-dot { background: var(--accent); }
  .message-main { min-width: 0; flex: 1; display: grid; align-content: start; gap: 4px; }
  .row-top, .subject-line, .message-tags { display: flex; align-items: center; gap: 7px; min-width: 0; }
  .row-top { justify-content: space-between; }
  .row-top b, .subject-line strong, .preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .row-top b { font-size: 14px; font-weight: 650; }
  .unread .row-top b, .unread .subject-line strong { font-weight: 850; }
  .row-top time, .preview { color: var(--muted); font-size: 12px; }
  .subject-line strong { font-size: 13px; }
  .subject-line :global(svg) { flex: 0 0 auto; color: #d6a62c; }
  .message-tags { margin-top: 1px; }
  .message-tags em { display: inline-flex; align-items: center; gap: 3px; border-radius: 999px; padding: 2px 6px; background: var(--accent-soft); color: var(--accent); font-size: 10px; font-style: normal; }
  .message-tags .spam-tag { background: color-mix(in srgb, var(--danger) 13%, var(--surface)); color: var(--danger); }
  .pager { min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; border-top: 1px solid var(--line); }
  .reader-column { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); background: var(--surface-soft); }
  .reader-toolbar { min-height: 60px; display: flex; align-items: center; gap: 7px; overflow-x: auto; padding: 9px 14px; border-bottom: 1px solid var(--line); background: var(--surface); }
  .toolbar-button { min-height: 38px; display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; border: 0; border-radius: 8px; padding: 0 10px; background: transparent; color: var(--ink); font-size: 13px; font-weight: 700; }
  .toolbar-button:hover { background: var(--surface-selected); color: var(--accent); }
  .toolbar-divider { inline-size: 1px; align-self: stretch; margin: 2px 2px; background: var(--line); }
  .mobile-back { display: none; }
  .reader-card { min-height: 0; overflow: auto; background: var(--surface); }
  .reader-head { display: grid; gap: 20px; padding: 27px 32px 20px; border-bottom: 1px solid var(--line-soft); }
  .reader-head h1 { margin: 0; font-size: clamp(21px, 2.2vw, 29px); line-height: 1.15; letter-spacing: -.035em; overflow-wrap: anywhere; }
  .reader-head p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
  dl { display: grid; gap: 5px; margin: 0; }
  dl div { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 10px; }
  dt { color: var(--muted); font-size: 12px; } dd { margin: 0; overflow-wrap: anywhere; font-size: 13px; }
  .warning { display: flex; gap: 10px; margin: 16px 32px 0; padding: 12px; border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--danger) 8%, var(--surface)); color: var(--danger); }
  .warning span { display: grid; gap: 3px; color: var(--ink); font-size: 13px; }
  .warning strong { color: var(--danger); }
  .message-body { max-width: 86ch; padding: 26px 32px 40px; line-height: 1.65; overflow-wrap: anywhere; }
  .message-body :global(img) { max-width: 100%; height: auto; }
  .message-body :global(a) { color: var(--accent); }
  .attachments { display: grid; gap: 10px; padding: 18px 32px 28px; border-top: 1px solid var(--line-soft); }
  .attachments > strong { display: inline-flex; align-items: center; gap: 7px; }
  .attachments > div { display: flex; flex-wrap: wrap; gap: 8px; }
  .attachment { min-width: min(100%, 240px); display: flex; align-items: center; gap: 9px; border: 1px solid var(--line); border-radius: 9px; padding: 9px; background: var(--surface-soft); color: var(--ink); text-align: left; }
  .attachment:hover { border-color: var(--accent); color: var(--accent); }
  .attachment span { min-width: 0; flex: 1; display: grid; gap: 2px; }.attachment b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }.attachment small { color: var(--muted); }
  .empty-state, .reader-empty { min-height: 270px; display: grid; place-content: center; justify-items: center; gap: 8px; padding: 28px; color: var(--muted); text-align: center; }
  .reader-empty { min-height: 100%; }.empty-state strong, .reader-empty strong { color: var(--ink); font-size: 16px; }.empty-state p, .reader-empty p { max-width: 34ch; margin: 0; line-height: 1.5; font-size: 13px; }
  .empty-icon { inline-size: 48px; block-size: 48px; display: grid; place-items: center; border-radius: 14px; background: var(--accent-soft); color: var(--accent); }
  .toast { position: fixed; z-index: 100; right: 20px; bottom: 20px; max-width: min(400px, calc(100vw - 40px)); display: flex; align-items: center; gap: 12px; padding: 10px 10px 10px 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); color: var(--ink); box-shadow: 0 16px 45px rgb(12 31 24 / .2); }.toast span { flex: 1; }.toast button { border: 0; background: transparent; color: var(--muted); }
  .overlay, .drawer-overlay { position: fixed; inset: 0; z-index: 80; display: grid; place-items: end center; padding: 18px; background: rgb(7 19 14 / .42); }
  .drawer-overlay { z-index: 74; padding: 0; place-items: initial; }
  .compose-window { width: min(680px, 100%); max-height: min(780px, calc(100dvh - 36px)); display: grid; grid-template-rows: auto auto auto minmax(200px, 1fr) auto auto; overflow: auto; border: 1px solid var(--line); border-radius: 13px; background: var(--surface); box-shadow: 0 30px 80px rgb(4 16 11 / .35); }
  .compose-title { min-height: 53px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px 0 16px; background: var(--rail); color: var(--rail-text); }.compose-title span { display: inline-flex; align-items: center; gap: 8px; font-weight: 800; }.inverse { border-color: rgb(255 255 255 / .18); background: transparent; color: #fff; }.inverse:hover { background: rgb(255 255 255 / .1); color: #fff; }
  .recipient-row, .compose-subject { display: grid; grid-template-columns: 52px minmax(0, 1fr); align-items: center; gap: 8px; padding: 7px 14px; border-bottom: 1px solid var(--line-soft); }.recipient-row > span { color: var(--muted); font-size: 12px; font-weight: 750; }.recipient-row input, .compose-subject input { border: 0; border-radius: 0; padding: 6px 0; }.compose-subject { grid-template-columns: minmax(0, 1fr) auto; }.body-field { display: grid; min-height: 230px; padding: 14px; }.body-field textarea { min-height: 100%; border: 0; border-radius: 0; padding: 0; line-height: 1.55; }.signature-note, .attachment-note { display: flex; align-items: center; gap: 6px; margin: 0; padding: 0 14px 8px; color: var(--muted); font-size: 12px; }.compose-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--line); }.compose-actions > div { display: flex; gap: 7px; align-items: center; }.file-input { display: none; }
  .settings-drawer { position: fixed; z-index: 75; top: 0; right: 0; bottom: 0; width: min(450px, 100vw); overflow: auto; background: var(--surface); border-left: 1px solid var(--line); box-shadow: -18px 0 52px rgb(9 27 20 / .22); }.settings-head { position: sticky; top: 0; z-index: 2; min-height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--surface); }.settings-head span { display: inline-flex; gap: 8px; align-items: center; font-weight: 850; }.settings-drawer section { display: grid; gap: 10px; padding: 18px 16px; border-bottom: 1px solid var(--line-soft); }.settings-drawer h2 { margin: 0; font-size: 15px; }.setting-copy, .muted { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }.segmented { display: flex; padding: 3px; border-radius: 9px; background: var(--surface-soft); }.segmented button { flex: 1; min-height: 34px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-weight: 750; }.segmented button.active { background: var(--surface); color: var(--accent); box-shadow: 0 1px 3px rgb(9 27 20 / .12); }.stack-fields { display: grid; gap: 8px; }.settings-list { display: grid; gap: 6px; }.settings-list p { margin: 0; display: grid; gap: 3px; color: var(--muted); font-size: 12px; }.settings-list b { color: var(--ink); font-size: 13px; }.settings-list code { width: fit-content; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 5px; padding: 2px 5px; background: var(--surface-soft); color: var(--muted); }.settings-list span { color: var(--accent); font-weight: 700; }.check { display: flex; align-items: center; gap: 8px; color: var(--ink); font-size: 13px; }.check input { inline-size: 18px; block-size: 18px; }.rule-builder { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1fr) minmax(0, .8fr); gap: 7px; }.rule-builder .field-button { width: 100%; min-height: 40px; }.settings-actions { display: flex; gap: 8px; align-items: center; }
  .confirm-overlay { z-index: 110; place-items: center; }.confirm-modal { width: min(390px, 100%); display: grid; justify-items: center; gap: 10px; padding: 26px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface); color: var(--ink); text-align: center; box-shadow: 0 30px 80px rgb(4 16 11 / .35); }.danger-icon { inline-size: 42px; block-size: 42px; display: grid; place-items: center; border-radius: 50%; background: color-mix(in srgb, var(--danger) 12%, var(--surface)); color: var(--danger); }.confirm-modal h2, .confirm-modal p { margin: 0; }.confirm-modal h2 { font-size: 18px; }.confirm-modal p { color: var(--muted); line-height: 1.5; font-size: 13px; }.confirm-modal > div:last-child { display: flex; gap: 8px; margin-top: 7px; }
  @keyframes spin { to { transform: rotate(360deg); } }
  @media (max-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); }.mailbox-switch { max-width: 190px; }.toolbar-button span { display: none; } }
  @media (max-width: 900px) { .webmail-shell { grid-template-columns: minmax(0, 1fr); }.platform-rail { position: sticky; top: 0; z-index: 60; min-height: 58px; flex-direction: row; align-items: center; gap: 12px; padding: 9px 13px; }.brand { padding: 0; }.brand small { display: none; }.platform-rail nav { display: flex; margin-left: auto; }.platform-rail nav a { min-height: 37px; padding: 0 9px; }.rail-footer { display: block; margin: 0; }.secure-state { display: none; }.sign-out { min-height: 37px; padding: 0 9px; }.mail-app { min-height: calc(100dvh - 58px); grid-template-rows: auto minmax(0, 1fr); }.mail-topbar { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; gap: 10px; padding: 10px 13px; }.search-control { grid-column: 1 / -1; grid-row: 2; }.identity { grid-column: 1; }.top-actions { grid-column: 2; }.mailbox-switch { display: none; }.mail-canvas { grid-template-columns: 160px minmax(280px, 1fr); }.reader-column { display: none; }.mail-canvas.reading { grid-template-columns: minmax(0, 1fr); }.mail-canvas.reading .folder-column, .mail-canvas.reading .message-column { display: none; }.mail-canvas.reading .reader-column { display: grid; }.mobile-back { display: inline-grid; }.compose-window { margin-bottom: 0; } }
  @media (max-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 { inline-size: 38px; justify-content: center; 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); }.folder-column { display: block; overflow-x: auto; padding: 8px; border-right: 0; border-bottom: 1px solid var(--line); white-space: nowrap; }.compose-wide, .folder-summary, .folder-hint { display: none; }.folder-group { display: flex; gap: 5px; }.folder-group button { flex: 0 0 auto; min-width: max-content; min-height: 35px; }.message-column { min-height: 0; border-right: 0; }.column-bar { min-height: 64px; }.message-list > button { min-height: 105px; }.reader-head { padding: 20px 18px 16px; }.message-body { padding: 21px 18px 32px; }.warning { margin-inline: 18px; }.attachments { padding-inline: 18px; }.reader-toolbar { padding-inline: 9px; }.compose-window { width: 100%; max-height: 100dvh; border-radius: 13px 13px 0 0; }.overlay { padding: 0; }.drawer-overlay { display: none; }.settings-drawer { width: 100%; }.rule-builder { grid-template-columns: 1fr; }.compose-actions { align-items: stretch; }.compose-actions > div { justify-content: space-between; }.toast { right: 12px; bottom: 12px; max-width: calc(100vw - 24px); } }
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } }
</style>