AkurAI Build
Menu

Public repository / 66e3647948f2e277afdd6ecd16939de8b6ad525f

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

Latest change 66e3647948f2e277afdd6ecd16939de8b6ad525f - Initial commit: AkurAI Mail UI (SvelteKit+Bun) by Ólafur Búi Ólafsson

<script lang="ts">
  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 compose: any = $state({ to: "", cc: "", bcc: "", subject: "", body: "", attachments: [] });
  let contact = $state({ name: "", email: "" });
  let signature = $state("");
  let importText = $state("");
  let page = $state(0);
  let vacation = $state({ enabled: false, subject: "Out of office", body: "" });
  let rule = $state({ field: "from", contains: "", action: "Archive", enabled: true });
  const pageSize = 40;

  const folderList = $derived(webmailState?.folders ?? []);
  const messages = $derived(webmailState?.messages ?? []);
  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 visibleMessages = $derived(messages.slice(page * pageSize, (page + 1) * pageSize));
  const pageCount = $derived(Math.max(1, Math.ceil(messages.length / pageSize)));
  const selectedDate = $derived(selected?.date ? new Date(selected.date) : null);

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

  $effect(() => {
    document.documentElement.dataset.theme = preferences.theme ?? "light";
    const onKey = (event: KeyboardEvent) => {
      const tag = (event.target as HTMLElement)?.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") 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") post({ action: "message", id: selected.id, messageAction: "delete" });
    };
    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)";
  }

  async function loadMailbox(nextFolder = folder) {
    busy = true;
    const params = new URLSearchParams({ email: current, folder: nextFolder, query });
    const response = await fetch(`/webmail/api?${params}`);
    webmailState = await response.json();
    folder = nextFolder;
    selected = null;
    page = 0;
    busy = false;
  }

  async function readMessage(id: string) {
    busy = true;
    const response = await fetch("/webmail/api", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ action: "read", email: current, id })
    });
    const result = await response.json();
    if (result.ok) selected = result.message;
    busy = false;
  }

  async function post(body: Record<string, unknown>, refresh = true) {
    busy = true;
    notice = "";
    const response = await fetch("/webmail/api", {
      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 (refresh) await loadMailbox(folder);
    busy = false;
    return result;
  }

  async function send() {
    const result = await post({ action: "send", message: { ...compose, from: current, signature: activeSignature } });
    if (result.ok) {
      composeOpen = false;
      compose = { to: "", cc: "", bcc: "", subject: "", body: "", attachments: [] };
      notice = "Message sent";
    }
  }

  async function saveDraft() {
    await post({ action: "draft", message: { ...compose, from: current } });
    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 reply(all = false) {
    if (!selected) return;
    compose = {
      to: selected.from,
      cc: all ? selected.to : "",
      bcc: "",
      subject: selected.subject.startsWith("Re:") ? selected.subject : `Re: ${selected.subject}`,
      body: `\n\nOn ${selected.date}, ${selected.from} wrote:\n${selected.bodyText ?? ""}`,
      attachments: []
    };
    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: []
    };
    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);
    })));
  }
</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 admin home">
      <span class="mark">A</span>
      <span><strong>AkurAI Mail</strong><small>{status.hostname}</small></span>
    </a>
    <nav aria-label="Primary">
      {#if admin}<a href="/">Admin</a>{/if}
      <a class="active" href="/webmail">Webmail</a>
      <a href="/webmail/logout">Sign out</a>
    </nav>
  </aside>

  <section class="mail-app">
    <header class="mail-topbar">
      <div class="mail-title">
        <strong>AkurAI Webmail</strong>
        <span>{current}</span>
      </div>
      <label class="search">
        <span class="sr">Search mail</span>
        <input data-search bind:value={query} onkeydown={(event) => { if (event.key === "Enter") loadMailbox(folder); }} placeholder="Search mail" />
      </label>
      <div class="top-actions">
        <select bind:value={current} onchange={() => loadMailbox("Inbox")} aria-label="Mailbox">
          {#each status.users as user}<option value={user.email}>{user.email}</option>{/each}
        </select>
        <button class="primary" onclick={() => composeOpen = true}>Compose</button>
        <button class="quiet" onclick={() => settingsOpen = !settingsOpen} aria-expanded={settingsOpen}>Settings</button>
      </div>
    </header>

    {#if notice}<p class="toast" aria-live="polite">{notice}</p>{/if}

    <div class:reading={selected} class="mail-canvas">
      <aside class="folder-column" aria-label="Mail folders">
        <button class="compose-wide" onclick={() => composeOpen = true}>Compose</button>
        <div class="folder-group">
          {#each folderList as item}
            <button class:active={item.name === folder} onclick={() => loadMailbox(item.name)}>
              <span>{item.name}</span>
              <em>{item.unread || item.total}</em>
            </button>
          {/each}
        </div>
      </aside>

      <section class="message-column" aria-label="Message list">
        <div class="column-bar">
          <div>
            <strong>{folder}</strong>
            <span>{messages.length} message{messages.length === 1 ? "" : "s"}</span>
          </div>
          <button class="quiet compact" onclick={() => loadMailbox(folder)} disabled={busy}>Refresh</button>
        </div>
        <div class="message-list">
          {#each visibleMessages as message}
            <button class:unread={message.unread} class:selected={selected?.id === message.id} onclick={() => readMessage(message.id)}>
              <span class="row-top">
                <b>{senderLabel(message)}</b>
                <time>{shortDate(message.date)}</time>
              </span>
              <span class="subject-line">
                {#if message.flagged}<i>Starred</i>{/if}
                <strong>{message.subject}</strong>
              </span>
              <span class="preview">{message.preview}</span>
              {#if message.attachments?.length || message.spamScore}
                <span class="message-tags">
                  {#if message.attachments?.length}<em>{message.attachments.length} attachment{message.attachments.length === 1 ? "" : "s"}</em>{/if}
                  {#if message.spamScore}<em>Spam score {message.spamScore}</em>{/if}
                </span>
              {/if}
            </button>
          {:else}
            <div class="empty-state"><strong>No messages</strong><p>{query ? "No mail matches this search." : "This folder is empty."}</p></div>
          {/each}
        </div>
        <div class="pager">
          <button class="quiet" disabled={page === 0} onclick={() => page = Math.max(0, page - 1)}>Previous</button>
          <span>{page + 1} / {pageCount}</span>
          <button class="quiet" disabled={page + 1 >= pageCount} onclick={() => page = Math.min(pageCount - 1, page + 1)}>Next</button>
        </div>
      </section>

      <section class="reader-column" aria-label="Message reader">
        {#if selected}
          <div class="reader-toolbar">
            <button class="quiet mobile-back" onclick={() => selected = null}>Back</button>
            <button class="quiet" onclick={() => reply(false)}>Reply</button>
            <button class="quiet" onclick={() => reply(true)}>Reply all</button>
            <button class="quiet" onclick={forward}>Forward</button>
            <button class="quiet" onclick={() => post({ action: "message", id: selected.id, messageAction: selected.flagged ? "unstar" : "star" })}>{selected.flagged ? "Unstar" : "Star"}</button>
            <button class="quiet" onclick={() => post({ action: "message", id: selected.id, messageAction: "archive" })}>Archive</button>
            <button class="quiet" onclick={() => post({ action: "message", id: selected.id, messageAction: "spam" })}>Spam</button>
            <button class="danger" onclick={() => post({ action: "message", id: selected.id, messageAction: "delete" })}>Delete</button>
          </div>
          <article class="reader-card">
            <header class="reader-head">
              <div>
                <h1>{selected.subject}</h1>
                <p>{selectedDate ? selectedDate.toLocaleString() : selected.date}</p>
              </div>
              <dl>
                <div><dt>From</dt><dd>{selected.from || "(unknown)"}</dd></div>
                <div><dt>To</dt><dd>{selected.to || current}</dd></div>
              </dl>
            </header>
            {#if selected.spamReasons?.length}
              <div class="warning">
                <strong>Spam signals</strong>
                <span>{selected.spamReasons.join(", ")}</span>
              </div>
            {/if}
            <div class="message-body">{@html selected.bodyHtml}</div>
            {#if selected.attachments?.length}
              <div class="attachments">
                <strong>Attachments</strong>
                {#each selected.attachments as file}<button class="quiet" onclick={() => downloadAttachment(file)}>{file.filename} ({Math.ceil(file.size / 1024)} KB)</button>{/each}
              </div>
            {/if}
          </article>
        {:else}
          <div class="reader-empty">
            <strong>Select a message</strong>
            <p>Mail opens here on desktop and in a focused detail view on smaller screens.</p>
          </div>
        {/if}
      </section>
    </div>
  </section>

  {#if composeOpen}
    <section class="compose-window" aria-label="Compose message">
      <div class="compose-title">
        <strong>New message</strong>
        <button class="ghost" onclick={() => composeOpen = false}>Close</button>
      </div>
      <label><span>To</span><input bind:value={compose.to} autocomplete="email" /></label>
      <label><span>Cc</span><input bind:value={compose.cc} autocomplete="email" /></label>
      <label><span>Bcc</span><input bind:value={compose.bcc} autocomplete="email" /></label>
      <label><span>Subject</span><input bind:value={compose.subject} /></label>
      <label class="body-field"><span>Message</span><textarea bind:value={compose.body}></textarea></label>
      {#if activeSignature}<small>Signature will be appended for {current}.</small>{/if}
      <label class="file-field"><span>Attachments</span><input type="file" multiple onchange={attach} /></label>
      {#if compose.attachments?.length}<small>{compose.attachments.length} attachment(s) ready</small>{/if}
      <div class="compose-actions">
        <button class="primary" onclick={send} disabled={busy}>Send</button>
        <button class="quiet" onclick={saveDraft} disabled={busy}>Save draft</button>
      </div>
    </section>
  {/if}

  {#if settingsOpen}
    <aside class="settings-drawer" aria-label="Webmail settings">
      <div class="settings-head">
        <strong>Settings</strong>
        <button class="ghost" onclick={() => settingsOpen = false}>Close</button>
      </div>
      <section>
        <h2>Contacts</h2>
        <div class="inline-fields"><input bind:value={contact.name} placeholder="Name" /><input bind:value={contact.email} placeholder="email@example.com" /><button class="primary" onclick={() => post({ action: "config", kind: "contact", value: contact }, false).then(() => loadMailbox(folder))}>Add</button></div>
        {#each contacts as item}<p>{item.name || item.email} <code>{item.email}</code></p>{:else}<p class="muted">No contacts saved.</p>{/each}
      </section>
      <section>
        <h2>Signature</h2>
        <textarea bind:value={signature} placeholder="Signature for selected mailbox"></textarea>
        <button class="primary" onclick={() => post({ action: "config", kind: "signature", value: { email: current, signature } }, false).then(() => loadMailbox(folder))}>Save signature</button>
      </section>
      <section>
        <h2>Vacation reply</h2>
        <label class="check"><input type="checkbox" bind:checked={vacation.enabled} /> Enabled</label>
        <input bind:value={vacation.subject} placeholder="Subject" />
        <textarea bind:value={vacation.body} placeholder="Reply body"></textarea>
        <button class="primary" onclick={() => post({ action: "config", kind: "vacation", value: vacation }, false).then(() => loadMailbox(folder))}>Save vacation reply</button>
      </section>
      <section>
        <h2>Preferences</h2>
        <select value={preferences.theme ?? "light"} onchange={(event) => post({ action: "config", kind: "preferences", value: { theme: (event.currentTarget as HTMLSelectElement).value } }, false).then(() => loadMailbox(folder))}>
          <option value="light">Light mode</option>
          <option value="dark">Dark mode</option>
        </select>
      </section>
      <section>
        <h2>Rules</h2>
        <div class="inline-fields"><select bind:value={rule.field}><option>from</option><option>to</option><option>subject</option><option>preview</option></select><input bind:value={rule.contains} placeholder="contains" /><select bind:value={rule.action}><option>Archive</option><option>Spam</option><option>Trash</option></select><button class="primary" onclick={() => post({ action: "config", kind: "rule", value: rule }, false).then(() => loadMailbox(folder))}>Add rule</button></div>
        <button class="quiet" onclick={() => post({ action: "apply-rules", folder })}>Apply rules now</button>
        {#each rules as item}<p>{item.field} contains <code>{item.contains}</code> -> {item.action}</p>{:else}<p class="muted">No rules configured.</p>{/each}
      </section>
      <section>
        <h2>Import / export</h2>
        <textarea bind:value={importText} placeholder="Settings JSON"></textarea>
        <div class="compose-actions"><button class="quiet" onclick={exportSettings}>Export</button><button class="primary" onclick={importSettings}>Import</button></div>
      </section>
    </aside>
  {/if}
</main>

<style>
  :global(*){box-sizing:border-box}
  :global(html),:global(body){margin:0;overflow:hidden}
  :global(body){font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#eef2f0;color:#151d1b}
  :global(html[data-theme="dark"] body){background:#101715;color:#eaf3ef}
  button,input,select,textarea{font:inherit}
  button{cursor:pointer}
  button:disabled{cursor:not-allowed;opacity:.55}
  input,select,textarea{border:1px solid #cbd8d4;border-radius:6px;background:#fff;color:#151d1b;min-width:0;padding:10px}
  input:focus,select:focus,textarea:focus,button:focus-visible{outline:3px solid #b7e7da;outline-offset:1px}
  textarea{resize:vertical}
  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
  .webmail-shell{height:100dvh;display:grid;grid-template-columns:248px minmax(0,1fr);overflow:hidden}
  .platform-rail{background:#0d1b18;color:#edf7f3;padding:20px;display:flex;flex-direction:column;gap:20px;min-width:0}
  .brand{display:flex;gap:12px;align-items:center;color:inherit;text-decoration:none;min-width:0}
  .brand span:last-child{min-width:0}.brand strong,.brand small{display:block;overflow-wrap:anywhere}.brand small{margin-top:2px;color:#96aaa3;font-size:12px}
  .mark{display:grid;place-items:center;flex:0 0 auto;width:40px;height:40px;border-radius:8px;background:#c8f05a;color:#0d1b18;font-weight:900}
  nav{display:grid;gap:4px}nav a{color:#dce9e4;text-decoration:none;border-radius:6px;padding:10px 11px;min-height:44px;display:flex;align-items:center}
  nav a:hover,nav a.active{background:#1c332e}
  .mail-app{height:100dvh;display:grid;grid-template-rows:64px minmax(0,1fr);min-width:0}
  .mail-topbar{background:#fff;border-bottom:1px solid #dce4e1;display:grid;grid-template-columns:minmax(190px,260px) minmax(220px,1fr) auto;gap:14px;align-items:center;padding:10px 16px;min-width:0}
  .mail-title{display:grid;gap:2px;min-width:0}.mail-title strong{font-size:15px}.mail-title span{font-size:12px;color:#61716c;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .search input{width:100%;height:42px;border-radius:999px;background:#f4f8f6;padding-inline:16px}
  .top-actions{display:flex;gap:8px;align-items:center;min-width:0}.top-actions select{max-width:260px;height:42px}
  .primary,.quiet,.danger,.ghost{min-height:42px;border:0;border-radius:6px;padding:9px 13px;font-weight:800;transition:background .16s ease,color .16s ease,border-color .16s ease}
  .primary{background:#126b5a;color:#fff}.primary:hover{background:#0e594b}
  .quiet,.ghost{background:#edf4f1;color:#17352f}.quiet:hover,.ghost:hover{background:#dbe9e4}
  .danger{background:#9a3125;color:#fff}.danger:hover{background:#81271e}
  .compact{min-height:36px;padding:7px 10px}
  .toast{position:fixed;right:18px;top:78px;z-index:40;margin:0;background:#e9f6f2;border:1px solid #b7dccf;border-radius:8px;padding:10px 12px;color:#17352f;box-shadow:0 14px 30px rgba(13,27,24,.12)}
  .mail-canvas{min-height:0;display:grid;grid-template-columns:196px minmax(320px,420px) minmax(0,1fr);background:#fff;overflow:hidden}
  .folder-column{border-right:1px solid #dce4e1;background:#f7faf8;padding:12px;display:grid;align-content:start;gap:12px;min-width:0}
  .compose-wide{width:100%;border-radius:999px;background:#126b5a;color:#fff;box-shadow:0 8px 18px rgba(18,107,90,.18)}
  .folder-group{display:grid;gap:2px}.folder-group button{border:0;background:transparent;color:#31413c;border-radius:999px;min-height:40px;padding:8px 11px;display:flex;align-items:center;justify-content:space-between;text-align:left}
  .folder-group button:hover,.folder-group button.active{background:#e3f2ed;color:#0e594b}.folder-group em{font-style:normal;color:#61716c;font-size:12px}
  .message-column{border-right:1px solid #dce4e1;display:grid;grid-template-rows:auto minmax(0,1fr) auto;min-width:0;background:#fff}
  .column-bar{min-height:58px;border-bottom:1px solid #dce4e1;padding:9px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px}
  .column-bar div{display:grid;gap:2px}.column-bar strong{font-size:16px}.column-bar span,.pager span{font-size:12px;color:#61716c}
  .message-list{overflow:auto;min-height:0}.message-list button{width:100%;min-height:104px;border:0;border-bottom:1px solid #edf1ef;border-radius:0;background:#fff;color:#151d1b;text-align:left;padding:12px 14px;display:grid;gap:5px}
  .message-list button:hover,.message-list button.selected{background:#f2f8f5}.message-list button.unread b,.message-list button.unread .subject-line strong{font-weight:900}
  .row-top,.subject-line,.message-tags{display:flex;gap:8px;align-items:center;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 time,.preview{color:#61716c;font-size:12px}.subject-line i,.message-tags em{font-style:normal;font-size:11px;border-radius:999px;background:#e9f6f2;color:#126b5a;padding:2px 7px;flex:0 0 auto}.message-tags em{background:#eef4f1;color:#33413d}
  .pager{border-top:1px solid #dce4e1;padding:8px;display:flex;align-items:center;justify-content:space-between;gap:8px}
  .reader-column{background:#fbfdfc;display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}
  .reader-toolbar{min-height:56px;border-bottom:1px solid #dce4e1;padding:8px 12px;display:flex;gap:8px;align-items:center;overflow-x:auto}
  .mobile-back{display:none}
  .reader-card{min-height:0;overflow:auto;background:#fff}.reader-head{padding:24px 28px 16px;border-bottom:1px solid #edf1ef;display:grid;gap:16px}
  .reader-head h1{margin:0;font-size:24px;line-height:1.2;overflow-wrap:anywhere}.reader-head p{margin:6px 0 0;color:#61716c}
  dl{margin:0;display:grid;gap:6px}dl div{display:grid;grid-template-columns:48px minmax(0,1fr);gap:12px}dt{color:#61716c;font-size:12px}dd{margin:0;overflow-wrap:anywhere}
  .warning{margin:14px 28px 0;border:1px solid #e7c2a7;background:#fff7ed;border-radius:8px;padding:10px 12px;display:grid;gap:4px;color:#6d3517}.warning span{overflow-wrap:anywhere}
  .message-body{padding:22px 28px 32px;line-height:1.58;max-width:86ch}.message-body :global(img){max-width:100%;height:auto}.message-body :global(a){color:#126b5a}
  .attachments{border-top:1px solid #edf1ef;padding:14px 28px;display:flex;gap:10px;flex-wrap:wrap}.attachments strong{flex-basis:100%}
  .empty-state,.reader-empty{display:grid;place-content:center;text-align:center;padding:28px;color:#61716c;min-height:260px}.reader-empty{min-height:100%}.empty-state strong,.reader-empty strong{color:#151d1b;font-size:18px}.empty-state p,.reader-empty p{max-width:42ch;line-height:1.55}
  .compose-window{position:fixed;right:24px;bottom:24px;z-index:60;width:min(620px,calc(100vw - 48px));max-height:min(760px,calc(100dvh - 48px));background:#fff;border:1px solid #cbd8d4;border-radius:8px;box-shadow:0 24px 70px rgba(13,27,24,.28);display:grid;grid-template-rows:auto repeat(4,auto) minmax(180px,1fr) auto auto auto;overflow:auto}
  .compose-title{min-height:48px;background:#0d1b18;color:#edf7f3;padding:8px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px}.compose-window label,.file-field{display:grid;gap:5px;padding:8px 12px;border-bottom:1px solid #edf1ef}.compose-window label span{font-size:12px;font-weight:800;color:#61716c}.compose-window input,.compose-window textarea{border:0;border-radius:0;padding:4px 0}.body-field textarea{min-height:220px}.compose-window small{padding:0 12px;color:#61716c}.compose-actions{display:flex;gap:8px;padding:10px 12px;align-items:center}
  .settings-drawer{position:fixed;right:0;top:0;bottom:0;z-index:55;width:min(440px,100vw);background:#fff;border-left:1px solid #cbd8d4;box-shadow:-18px 0 48px rgba(13,27,24,.18);overflow:auto;display:grid;align-content:start}
  .settings-head{position:sticky;top:0;background:#fff;border-bottom:1px solid #dce4e1;min-height:58px;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;z-index:1}.settings-drawer section{padding:14px;border-bottom:1px solid #edf1ef;display:grid;gap:9px}.settings-drawer h2{font-size:16px;margin:0}.settings-drawer textarea{min-height:110px}.inline-fields{display:grid;gap:8px}.check{display:flex;gap:8px;align-items:center}.check input{width:auto}.muted{color:#61716c}.settings-drawer code{background:#eef4f1;border-radius:5px;padding:3px 6px;overflow-wrap:anywhere}
  :global(html[data-theme="dark"]) .mail-topbar,:global(html[data-theme="dark"]) .message-column,:global(html[data-theme="dark"]) .reader-card,:global(html[data-theme="dark"]) .compose-window,:global(html[data-theme="dark"]) .settings-drawer,:global(html[data-theme="dark"]) .settings-head{background:#131f1c;color:#eaf3ef;border-color:#2d403a}
  :global(html[data-theme="dark"]) .mail-canvas,:global(html[data-theme="dark"]) .reader-column{background:#101715}
  :global(html[data-theme="dark"]) .folder-column{background:#10201d;border-color:#2d403a}
  :global(html[data-theme="dark"]) input,:global(html[data-theme="dark"]) select,:global(html[data-theme="dark"]) textarea,:global(html[data-theme="dark"]) .message-list button{background:#101715;color:#eaf3ef;border-color:#2d403a}
  :global(html[data-theme="dark"]) .message-list button:hover,:global(html[data-theme="dark"]) .message-list button.selected,:global(html[data-theme="dark"]) .quiet,:global(html[data-theme="dark"]) .ghost{background:#20342f;color:#eaf3ef}
  :global(html[data-theme="dark"]) .reader-head,:global(html[data-theme="dark"]) .reader-toolbar,:global(html[data-theme="dark"]) .column-bar,:global(html[data-theme="dark"]) .pager,:global(html[data-theme="dark"]) .attachments{border-color:#2d403a}
  @media(max-width:1180px){.webmail-shell{grid-template-columns:200px minmax(0,1fr)}.platform-rail{padding:16px 14px}.mail-canvas{grid-template-columns:168px minmax(300px,380px) minmax(0,1fr)}}
  @media(max-width:920px){:global(html),:global(body){overflow:auto}.webmail-shell{height:auto;min-height:100dvh;grid-template-columns:minmax(0,1fr)}.platform-rail{position:sticky;top:0;z-index:50;height:auto;display:flex;flex-direction:row;align-items:center;padding:10px 14px}.brand span:last-child{display:block;font-size:initial}nav{display:flex;margin-left:auto;overflow-x:auto}nav a{font-size:14px;width:auto;padding:9px 11px;background:rgba(255,255,255,.06);white-space:nowrap}.mail-app{height:calc(100dvh - 64px)}.mail-topbar{grid-template-columns:minmax(0,1fr) auto;grid-template-rows:auto auto}.mail-title{grid-column:1}.search{grid-column:1 / -1;grid-row:2}.top-actions{grid-column:2;grid-row:1}.top-actions select{display:none}.compose-wide{display:none}.mail-canvas{grid-template-columns:132px minmax(0,1fr)}.reader-column{grid-column:1 / -1;border-top:1px solid #dce4e1;min-height:420px}.message-body{max-width:none}}
  @media(max-width:640px){.platform-rail{gap:10px}.brand small{display:none}.mail-app{height:calc(100dvh - 58px)}.mail-topbar{min-height:118px;padding:10px;gap:8px}.mail-title span{max-width:58vw}.top-actions{gap:6px}.top-actions .quiet{display:none}.mail-canvas{grid-template-columns:minmax(0,1fr);position:relative}.folder-column{border-right:0;border-bottom:1px solid #dce4e1;padding:8px;display:block;overflow-x:auto;white-space:nowrap}.folder-group{display:flex;gap:6px}.folder-group button{min-width:108px}.message-column{border-right:0;min-height:0}.reader-column{display:none;grid-column:auto;border-top:0}.mail-canvas.reading .folder-column,.mail-canvas.reading .message-column{display:none}.mail-canvas.reading .reader-column{display:grid;min-height:calc(100dvh - 176px)}.mobile-back{display:inline-flex}.reader-toolbar{position:sticky;top:0;background:#fff;z-index:2}.reader-head{padding:18px 16px 12px}.reader-head h1{font-size:20px}.message-body{padding:18px 16px 26px}.warning{margin:12px 16px 0}.attachments{padding:12px 16px}.compose-window{inset:0;width:auto;max-height:none;border-radius:0;border:0}.settings-drawer{width:100vw}.toast{left:12px;right:12px;top:auto;bottom:12px}}
  @media(max-width:430px){.top-actions .primary{padding-inline:10px}.mail-title strong{font-size:14px}.search input{height:40px}.reader-toolbar{gap:6px}.reader-toolbar .quiet,.reader-toolbar .danger{min-height:38px;padding:7px 9px}.message-list button{min-height:112px}.compose-actions{display:grid}.compose-actions button{width:100%}}
</style>