Menu
Public repository / 7524d175c8a07405c12b592eeb04803cad52cafa
AkurAI-Mail / src/routes/webmail/+page.svelte
Latest change 1c2217cda20adcbcf6a4d0b6e8aa22d39f062555 - AKURAI-MAIL-12 remove unused webmail icons by Ólafur Búi Ólafsson
<script lang="ts">
import {
ArrowLeft,
ChevronDown,
FileText,
Inbox,
Mail,
Paperclip,
PenLine,
RefreshCw,
Search,
Send,
Settings2,
ShieldAlert,
SlidersHorizontal,
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>