Menu
Public repository / 10d19fe0941c186ec244d1f0e2af6b19200dcb0c
AkurAI-Mail / src/routes/webmail/+page.svelte
Latest change 10d19fe0941c186ec244d1f0e2af6b19200dcb0c - OLI-248: pure webmail logic as plain ES modules with JSDoc types; inline icon module by Ólafur Búi Ólafsson
<script lang="ts">
import { scheduledSendView, type ScheduledSendView } from "$lib/webmail/session";
import {
Archive,
ArrowLeft,
Clock3,
FilePen,
FileText,
Inbox,
Keyboard,
Mail,
Paperclip,
PenLine,
RefreshCw,
Search,
Send,
Settings2,
ShieldAlert,
SlidersHorizontal,
Star,
Trash2,
UserPlus,
X,
ChevronDown
} from "lucide-svelte";
import { loginUrl } from "$lib/api";
import MessageList from "$lib/webmail/MessageList.svelte";
import MessageReader from "$lib/webmail/MessageReader.svelte";
import ComposeDialog from "$lib/webmail/ComposeDialog.svelte";
import SettingsDrawer from "$lib/webmail/SettingsDrawer.svelte";
import SyncBanner from "$lib/webmail/SyncBanner.svelte";
import RichTextEditor from "$lib/webmail/RichTextEditor.svelte";
import RecipientCombobox from "$lib/webmail/RecipientCombobox.svelte";
import { createSyncState } from "$lib/webmail/sync.svelte";
import { SYNC_ERROR_MESSAGES, classifySyncError } from "$lib/webmail/sync";
import {
DRAFT_RECOVERY_KEY,
UNDO_SEND_SECONDS,
emptyCompose,
forwardBody,
hasDraftContent,
parseSendAt,
replyBody,
secondsUntil,
stashDraft,
takeDraft,
type ComposeState
} from "$lib/webmail/compose";
import { createShortcuts, moveSelection, type ShortcutAction } from "../../framework/shortcuts.js";
import { RECENT_RECIPIENTS_KEY, extractEmail, mergeRecent, recipientSuggestions } from "$lib/webmail/recipients";
import { FILTER_CHIPS, filterParams, isPlainView, mailboxCacheKey } from "$lib/webmail/filters";
let { data } = $props();
let status = $derived(data.status);
let admin = $derived(data.admin);
let webmailState: any = $state(null);
const current = $derived(data.sessionMailbox);
let folder = $state("Inbox");
let query = $state("");
let selected: any = $state(null);
let notice = $state("");
let pendingSend: any = $state(null);
let undoSecondsLeft = $state(0);
let undoTimer: ReturnType<typeof setInterval> | undefined;
let busy = $state(false);
let composeOpen = $state(false);
let settingsOpen = $state(false);
let deletePending = $state(false);
let showCcBcc = $state(false);
let compose: ComposeState = $state(emptyCompose());
/** Bumped whenever compose content is replaced, so the editor reloads it. */
let composeVersion = $state(0);
let contact = $state({ name: "", email: "" });
let signature = $state("");
let importText = $state("");
let page = $state(0);
let pageCursors = $state<string[]>([""]);
let vacation = $state({ enabled: false, subject: "Out of office", body: "" });
let rule = $state({ field: "from", contains: "", action: "Archive", enabled: true });
let ruleFieldMenuOpen = $state(false);
let signals: any = $state({});
let messageSignals: any = $state({});
let signalsBusy = $state(false);
let shortcutsOpen = $state(false);
let scheduledSends: ScheduledSendView[] = $state([]);
let sendLaterAt = $state("");
let activeChips = $state(new Set<string>());
let smartMailbox = $state("");
const pageSize = 40;
const smartMailboxes = [
{ id: "unread", label: "Unread", icon: Mail, category: "mailboxes" },
{ id: "flagged", label: "Flagged", icon: Star, category: "mailboxes" },
{ id: "attachment", label: "Attachments", icon: Paperclip, category: "mailboxes" },
{ id: "today", label: "Today", icon: Inbox, category: "mailboxes" },
{ id: "vip", label: "VIP", icon: ShieldAlert, category: "priority" },
{ id: "priority", label: "Needs attention", icon: ShieldAlert, category: "priority" }
];
const sync = createSyncState();
let ruleActionMenuOpen = $state(false);
const folderList = $derived(webmailState?.folders ?? []);
const messages = $derived(webmailState?.messages ?? []);
const totalMessages = $derived(webmailState?.total ?? messages.length);
const nextCursor = $derived(webmailState?.nextCursor ?? "");
const contacts = $derived(webmailState?.contacts ?? []);
let recentRecipients: string[] = $state(typeof localStorage !== "undefined" ? JSON.parse(localStorage.getItem(RECENT_RECIPIENTS_KEY) ?? "[]") : []);
function rememberRecipients(used: string) {
recentRecipients = mergeRecent(recentRecipients, used);
if (typeof localStorage !== "undefined") localStorage.setItem(RECENT_RECIPIENTS_KEY, JSON.stringify(recentRecipients));
}
const suggestions = $derived(recipientSuggestions(contacts, recentRecipients));
const signatures = $derived(webmailState?.signatures ?? {});
const rules = $derived(webmailState?.rules ?? []);
const preferences = $derived(webmailState?.preferences ?? {});
const activeSignature = $derived(signatures[current] ?? "");
const pageCount = $derived(Math.max(1, Math.ceil(totalMessages / pageSize)));
const selectedDate = $derived(selected?.date ? new Date(selected.date) : null);
const unreadCount = $derived(folderList.reduce((total: number, item: any) => total + (item.unread ?? 0), 0));
const vipSenders: string[] = $derived(webmailState?.vipSenders ?? []);
function isVipMessage(message: any) {
return message.isVip ?? vipSenders.includes(extractEmail(message.from || ""));
}
function toggleChip(id: string) {
const next = new Set(activeChips);
if (next.has(id)) next.delete(id);
else next.add(id);
activeChips = next;
loadMailbox(folder);
}
function clearChips() {
activeChips = new Set();
loadMailbox(folder);
}
function chooseSmartMailbox(id: string) {
smartMailbox = smartMailbox === id ? "" : id;
loadMailbox(folder);
}
async function toggleVip(from: string | undefined) {
if (!from) return;
const email = extractEmail(from);
if (!email) return;
await post({ action: "config", kind: "vip", value: { email, remove: vipSenders.includes(email) } }, true);
}
const ruleFields = ["from", "to", "subject", "preview"];
const ruleActions = ["Archive", "Spam", "Trash"];
$effect(() => {
if (!webmailState) webmailState = data.webmail;
});
let scheduledLoaded = false;
$effect(() => {
if (webmailState && !scheduledLoaded) {
scheduledLoaded = true;
loadScheduledSends();
}
});
let initialSyncReported = false;
$effect(() => {
if (webmailState && !initialSyncReported) {
initialSyncReported = true;
sync.reportSuccess();
}
});
// Restore a draft stashed before an expired session sent the user to sign in.
$effect(() => {
const draft = takeDraft(sessionStorage, DRAFT_RECOVERY_KEY);
if (draft) {
compose = draft;
composeOpen = true;
}
});
$effect(() => {
document.documentElement.dataset.theme = preferences.theme ?? "light";
});
const shortcuts = createShortcuts({
window,
enabled: true,
hasSelection: () => !!selected,
persist: (enabled: boolean) => post({ action: "config", kind: "preferences", value: { ...preferences, shortcuts: enabled } }, false).then(() => loadMailbox(folder)),
onAction: runShortcut,
});
$effect(() => {
shortcuts.enabled(preferences.shortcuts !== false);
});
$effect(() => shortcuts.destroy);
function runShortcut(action: ShortcutAction, event: KeyboardEvent) {
const messageAction = (name: string) => post({ action: "message", id: selected.id, messageAction: name });
const handlers: Record<ShortcutAction, () => void> = {
closeTyping: () => {
if (composeOpen) composeOpen = false;
(event.target as HTMLElement | null)?.blur?.();
},
help: () => (shortcutsOpen = true),
close: () => {
if (shortcutsOpen) shortcutsOpen = false;
else if (composeOpen) composeOpen = false;
else if (settingsOpen) settingsOpen = false;
else if (deletePending) deletePending = false;
else if (selected) selected = null;
},
compose: () => (composeOpen = true),
search: () => (document.querySelector("[data-search]") as HTMLInputElement | null)?.focus(),
next: () => selectRelative("next"),
prev: () => selectRelative("prev"),
delete: () => (deletePending = true),
reply: () => reply(false),
replyAll: () => reply(true),
forward: () => forward(),
archive: () => messageAction("archive"),
star: () => messageAction(selected.flagged ? "unstar" : "star"),
unread: () => messageAction("unread"),
spam: () => messageAction("spam"),
};
handlers[action]();
}
function selectRelative(direction: "next" | "prev") {
const index = selected ? messages.findIndex((message: any) => message.id === selected.id) : -1;
const next = moveSelection(index, messages.length, direction);
if (next >= 0) readMessage(messages[next].id);
}
function shortDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value || "";
const today = new Date();
if (date.toDateString() === today.toDateString()) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function senderLabel(message: any) {
return message.from || message.to || "(unknown)";
}
function folderIcon(name: string) {
if (name === "Inbox") return Inbox;
if (name === "Spam") return ShieldAlert;
if (name === "Trash") return Trash2;
if (name === "Sent") return Send;
if (name === "Drafts") return FilePen;
if (name === "Archive") return Archive;
return FileText;
}
async function loadMailbox(nextFolder = folder, cursor = "", resetPage = true) {
busy = true;
sync.status = "syncing";
let response: Response | undefined;
try {
const params = new URLSearchParams({ folder: nextFolder, query, limit: String(pageSize), ...filterParams(smartMailbox, activeChips) });
if (cursor) params.set("cursor", cursor);
response = await fetch(`/api/webmail?${params}`);
const result = await response.json();
if (!response.ok || result.ok === false) throw new Error(result.error || "Could not load this folder.");
webmailState = result;
folder = nextFolder;
selected = null;
if (resetPage) {
page = 0;
pageCursors = [""];
}
if (isPlainView(query, cursor, smartMailbox, activeChips) && typeof localStorage !== "undefined") {
localStorage.setItem(mailboxCacheKey(current, nextFolder), JSON.stringify(result));
}
sync.reportSuccess();
loadSignals(nextFolder);
} catch (error) {
const cached = typeof localStorage !== "undefined" ? localStorage.getItem(mailboxCacheKey(current, nextFolder)) : null;
if (cached) {
webmailState = JSON.parse(cached);
folder = nextFolder;
notice = "Offline — showing cached mailbox.";
}
sync.reportError(classifySyncError(error, response), () => loadMailbox(nextFolder, cursor, resetPage));
} finally {
busy = false;
}
}
/** Keeps any open draft across the IdP round trip, then signs in again. */
function reauthenticate() {
if (composeOpen && hasDraftContent(compose)) stashDraft(sessionStorage, DRAFT_RECOVERY_KEY, compose);
window.location.href = loginUrl(window.location.pathname);
}
async function loadSignals(forFolder = folder) {
signalsBusy = true;
try {
const params = new URLSearchParams({ folder: forFolder });
const response = await fetch(`/api/webmail?${params}&action=signals`);
if (response.ok) {
const result = await response.json();
if (result.ok) {
signals = result.signals ?? {};
messageSignals = result.messageSignals ?? {};
}
}
} catch {
// Signals are non-critical; silently skip
} finally {
signalsBusy = false;
}
}
async function correctSignal(convId: string, signalType: string, action: string, value?: any) {
try {
const payload = JSON.stringify({ conversationId: convId, signalType, action, value });
const params = new URLSearchParams({ folder, action: "signal-correct", payload });
const response = await fetch(`/api/webmail?${params}`);
const result = await response.json();
if (result.ok) await loadSignals(folder);
} catch {
notice = "Could not save correction.";
}
}
function signalForMessage(msgId: string) {
const convId = messageSignals[msgId];
if (!convId) return null;
return signals[convId] ?? null;
}
async function threadAction(thread: { messages: { id: string }[] }, messageAction: string) {
busy = true;
try {
for (const message of thread.messages) {
await fetch("/api/webmail", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "message", id: message.id, messageAction })
});
}
await loadMailbox(folder, "", false);
} catch {
notice = "Could not apply thread action.";
} finally {
busy = false;
}
}
const signalForSelected = $derived(selected ? signalForMessage(selected.id) : null);
async function nextPage() {
if (!nextCursor || busy) return;
pageCursors[page + 1] = nextCursor;
page += 1;
await loadMailbox(folder, nextCursor, false);
}
async function previousPage() {
if (page === 0 || busy) return;
page -= 1;
await loadMailbox(folder, pageCursors[page], false);
}
async function readMessage(id: string) {
busy = true;
try {
const response = await fetch("/api/webmail", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "read", id })
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || "Could not open this message.");
selected = result.message;
} catch (error) {
notice = error instanceof Error ? error.message : "Could not open this message.";
} finally {
busy = false;
}
}
async function post(body: Record<string, unknown>, refresh = true) {
busy = true;
notice = "";
try {
const response = await fetch("/api/webmail", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
notice = result.ok ? "Saved" : result.error || "Action failed";
if (result.ok && refresh) await loadMailbox(folder);
return result;
} catch {
notice = "Connection error. Please try again.";
return { ok: false };
} finally {
busy = false;
}
}
function resetCompose() {
compose = emptyCompose();
showCcBcc = false;
composeVersion += 1;
}
/** Sends with an undo window: the server holds the message for UNDO_SEND_SECONDS. */
async function send() {
const result = await post({ action: "send", message: { ...compose, from: current, signature: activeSignature, undoDelaySecs: UNDO_SEND_SECONDS } }, false);
if (result.ok) {
rememberRecipients([compose.to, compose.cc, compose.bcc].filter(Boolean).join(", "));
composeOpen = false;
resetCompose();
startUndoCountdown(result.pendingSend);
}
}
function startUndoCountdown(pending: any) {
clearInterval(undoTimer);
pendingSend = pending;
undoSecondsLeft = secondsUntil(pending.send_at);
undoTimer = setInterval(() => {
undoSecondsLeft = secondsUntil(pending.send_at);
if (undoSecondsLeft <= 0) {
clearInterval(undoTimer);
pendingSend = null;
loadMailbox(folder);
}
}, 250);
}
async function undoSend() {
if (!pendingSend) return;
clearInterval(undoTimer);
const result = await post({ action: "cancel-send", id: pendingSend.id }, false);
pendingSend = null;
notice = result.ok ? "Send undone" : result.error || "Could not undo — window already closed";
}
async function sendLater() {
const parsed = parseSendAt(sendLaterAt);
if (parsed.error !== undefined) {
notice = parsed.error;
return;
}
const result = await post({ action: "schedule-send", sendAt: parsed.sendAt, message: { ...compose, from: current, signature: activeSignature } }, false);
if (result.ok) {
composeOpen = false;
resetCompose();
sendLaterAt = "";
notice = "Message scheduled";
await loadScheduledSends();
}
}
async function loadScheduledSends() {
const result = await post({ action: "scheduled-sends" }, false);
if (result.ok) scheduledSends = (result.scheduledSends ?? []).map(scheduledSendView);
}
async function cancelScheduledSend(scheduledId: string) {
const result = await post({ action: "cancel-scheduled-send", scheduledId }, false);
if (result.ok) await loadScheduledSends();
}
async function saveDraft() {
const result = await post({ action: "draft", message: { ...compose, from: current } });
if (result.ok) notice = "Draft saved";
}
async function downloadAttachment(file: any) {
const result = await post({ action: "attachment", id: selected.id, index: file.index }, false);
if (!result.ok) return;
const link = document.createElement("a");
link.href = `data:${result.contentType};base64,${result.data}`;
link.download = result.filename;
link.click();
}
function formatAddress(entry: any) {
if (!entry?.address) return "";
const name = String(entry.name ?? "").replaceAll('"', '\\"');
return name ? `"${name}" <${entry.address}>` : entry.address;
}
function reply(all = false) {
if (!selected) return;
const primaryEntry = selected.replyToAddresses?.[0] ?? selected.fromAddresses?.[0] ?? { address: selected.from };
const primary = formatAddress(primaryEntry) || selected.from;
const excluded = new Set([current.toLowerCase(), String(primaryEntry.address ?? "").toLowerCase()]);
const cc = all
? [...(selected.toAddresses ?? []), ...(selected.ccAddresses ?? [])]
.filter((entry: any) => {
const address = String(entry.address ?? "").toLowerCase();
if (!address || excluded.has(address)) return false;
excluded.add(address);
return true;
})
.map(formatAddress)
.join(", ")
: "";
compose = {
to: primary,
cc,
bcc: "",
subject: selected.subject.startsWith("Re:") ? selected.subject : `Re: ${selected.subject}`,
body: replyBody(selected),
attachments: []
};
showCcBcc = all;
composeOpen = true;
composeVersion += 1;
}
function forward() {
if (!selected) return;
compose = {
to: "",
cc: "",
bcc: "",
subject: selected.subject.startsWith("Fwd:") ? selected.subject : `Fwd: ${selected.subject}`,
body: forwardBody(selected),
attachments: []
};
showCcBcc = false;
composeOpen = true;
composeVersion += 1;
}
async function attach(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const files = Array.from(input.files ?? []).slice(0, 5);
compose.attachments = await Promise.all(files.map((file) => new Promise<ComposeState["attachments"][number]>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, data: String(reader.result).split(",", 2)[1] ?? "" });
reader.onerror = reject;
reader.readAsDataURL(file);
})));
}
const defaultVacation = () => ({ enabled: false, subject: "Out of office", body: "" });
function hydrateSettings() {
signature = activeSignature;
vacation = { ...defaultVacation(), ...(webmailState?.vacation ?? {}) };
loadScheduledSends();
}
async function saveSettings(kind: "signature" | "vacation", value: Record<string, unknown>) {
const result = await post({ action: "config", kind, value }, false);
if (!result.ok) return;
await loadMailbox(folder);
hydrateSettings();
}
function chooseRuleField(field: string) {
rule.field = field;
ruleFieldMenuOpen = false;
}
function chooseRuleAction(action: string) {
rule.action = action;
ruleActionMenuOpen = false;
}
function setTheme(theme: string) {
post({ action: "config", kind: "preferences", value: { ...preferences, theme } }, false).then(() => loadMailbox(folder));
}
function confirmDelete() {
if (!selected) return;
deletePending = false;
post({ action: "message", id: selected.id, messageAction: "delete" });
}
function scheduledLabel(item: ScheduledSendView) {
return `${item.to} · ${new Date(item.sendAt * 1000).toLocaleString()}`;
}
</script>
<svelte:head><title>Webmail · AkurAI Mail</title></svelte:head>
<main class="webmail-shell">
<aside class="platform-rail">
<a class="brand" href="/" aria-label="AkurAI Mail home">
<span class="mark">A</span>
<span><strong>AkurAI Mail</strong><small>{status.hostname}</small></span>
</a>
<nav aria-label="Primary navigation">
{#if admin}<a href="/"><SlidersHorizontal size={17} strokeWidth={1.8} /> <span>Admin</span></a>{/if}
<a class="active" href="/webmail"><Mail size={17} strokeWidth={1.8} /> <span>Webmail</span></a>
</nav>
<div class="rail-footer">
<span class="secure-state"><ShieldAlert size={15} strokeWidth={1.8} /> Private mailbox</span>
<a class="sign-out" href="/auth/logout"><ArrowLeft size={17} strokeWidth={1.8} /> Sign out</a>
</div>
</aside>
<section class="mail-app" aria-busy={busy}>
<SyncBanner {sync} onRetry={() => sync.retryNow(() => loadMailbox(folder))} onReauth={reauthenticate} />
<header class="mail-topbar">
<div class="identity">
<span class="eyebrow">Mailbox</span>
<strong>{current || "AkurAI Webmail"}</strong>
</div>
<label class="search-control">
<Search size={18} strokeWidth={1.8} aria-hidden="true" />
<span class="sr">Search mail</span>
<input data-search bind:value={query} onkeydown={(event) => { if (event.key === "Enter") loadMailbox(folder); }} placeholder="Search this mailbox" />
{#if query}<button class="icon-button clear-search" aria-label="Clear search" onclick={() => { query = ""; loadMailbox(folder); }}><X size={15} /></button>{/if}
</label>
<div class="top-actions">
<button class="icon-button" aria-label="Refresh mailbox" title="Refresh mailbox" onclick={() => loadMailbox(folder)} disabled={busy}><RefreshCw size={18} class={busy ? "spin" : ""} /></button>
<button class="icon-button" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => (shortcutsOpen = true)}><Keyboard size={18} /></button>
<button class="icon-button settings-button" aria-label="Open settings" title="Settings" onclick={() => { settingsOpen = true; hydrateSettings(); }}><Settings2 size={18} /></button>
<button class="primary-action" onclick={() => composeOpen = true}><PenLine size={17} strokeWidth={2} /> <span>Compose</span></button>
</div>
</header>
<div class:reading={selected} class="mail-canvas">
<MessageList
{folderList}
{folder}
{unreadCount}
{folderIcon}
onFolder={(name: string) => { smartMailbox = ""; loadMailbox(name); }}
{messages}
{totalMessages}
{query}
{senderLabel}
{shortDate}
{selected}
onRead={readMessage}
{page}
{pageCount}
{nextCursor}
{busy}
onPrevious={previousPage}
onNext={nextPage}
{signalForMessage}
{smartMailboxes}
{smartMailbox}
onSmartMailbox={chooseSmartMailbox}
chipDefs={FILTER_CHIPS}
{activeChips}
onToggleChip={toggleChip}
onClearChips={clearChips}
{isVipMessage}
onThreadAction={threadAction}
/>
<MessageReader
{selected}
{selectedDate}
{current}
{reply}
{forward}
{post}
onBack={() => selected = null}
{downloadAttachment}
onDelete={() => deletePending = true}
convSignal={signalForSelected}
onCorrectSignal={correctSignal}
onDismissSignal={correctSignal}
isVip={selected ? vipSenders.includes(extractEmail(selected.from || "")) : false}
onToggleVip={() => toggleVip(selected?.from)}
{folderList}
isFirstRun={folder === "Inbox" && totalMessages === 0 && !query}
isNoResults={totalMessages === 0 && !!query}
isEmptyMailbox={totalMessages === 0 && !query && folder !== "Inbox"}
/>
</div>
</section>
{#if pendingSend}
<div class="toast undo-send-toast" role="status" aria-live="polite"><span>Message sending in {undoSecondsLeft}s</span> <button class="subtle-button" onclick={undoSend}>Undo</button></div>
{/if}
{#if notice}
<div class="toast" role="status" aria-live="polite"><span>{notice}</span><button aria-label="Dismiss notification" onclick={() => notice = ""}><X size={16} /></button></div>
{/if}
<ComposeDialog open={composeOpen}>
<div class="compose-title"><span><PenLine size={18} /> New message</span><button class="icon-button inverse" aria-label="Close compose window" onclick={() => composeOpen = false}><X size={18} /></button></div>
<div class="recipient-row"><span>To</span><RecipientCombobox bind:value={compose.to} {suggestions} label="To" placeholder="name@example.com" /></div>
{#if showCcBcc}
<div class="recipient-row"><span>Cc</span><RecipientCombobox bind:value={compose.cc} {suggestions} label="Cc" /></div>
<div class="recipient-row"><span>Bcc</span><RecipientCombobox bind:value={compose.bcc} {suggestions} label="Bcc" /></div>
{/if}
<div class="compose-subject"><input bind:value={compose.subject} placeholder="Subject" aria-label="Subject" /><button class="subtle-button" onclick={() => showCcBcc = !showCcBcc}>{showCcBcc ? "Hide Cc/Bcc" : "Cc/Bcc"}</button></div>
<div class="body-field"><RichTextEditor bind:html={compose.body} bind:text={compose.bodyText} placeholder="Write your message" resetKey={composeVersion} /></div>
{#if activeSignature}<p class="signature-note">Your saved signature will be appended.</p>{/if}
{#if compose.attachments?.length}<p class="attachment-note"><Paperclip size={15} /> {compose.attachments.length} attachment{compose.attachments.length === 1 ? "" : "s"} ready</p>{/if}
<div class="compose-actions">
<div><button class="primary-action" onclick={send} disabled={busy}><Send size={17} /> Send</button><button class="icon-button" aria-label="Attach files" title="Attach files" onclick={() => document.getElementById("attachment-input")?.click()}><Paperclip size={18} /></button><input id="attachment-input" class="file-input" type="file" multiple onchange={attach} /></div>
<div class="schedule-row"><input type="datetime-local" bind:value={sendLaterAt} aria-label="Send later at" /><button class="subtle-button" onclick={sendLater} disabled={busy}><Clock3 size={15} /> Send later</button></div>
<div><button class="subtle-button" onclick={saveDraft} disabled={busy}>Save draft</button><button class="icon-button" aria-label="Discard draft" title="Discard draft" onclick={() => { composeOpen = false; resetCompose(); }}><Trash2 size={18} /></button></div>
</div>
</ComposeDialog>
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false}>
<div class="settings-head"><span><Settings2 size={19} /> Settings</span><button class="icon-button" aria-label="Close settings" onclick={() => settingsOpen = false}><X size={18} /></button></div>
<section>
<h2>Scheduled sends</h2>
<p class="setting-copy">Messages queued to send later. Cancel any time before it fires.</p>
<div class="settings-list">
{#each scheduledSends.filter((item) => item.status === "pending") as item (item.id)}
<p><b>{item.subject || "(no subject)"}</b> <code>{scheduledLabel(item)}</code> <button class="icon-button" aria-label="Cancel scheduled send" title="Cancel" onclick={() => cancelScheduledSend(item.id)}><Trash2 size={16} /></button></p>
{:else}<p class="muted">No scheduled sends pending.</p>{/each}
</div>
</section>
<section>
<h2>Account</h2>
<p class="setting-copy">Signed in as <b>{current}</b>.</p>
{#if sync.errorKind}
<div class="account-status" class:warn={sync.errorKind === "auth-expired"}>
<p>{SYNC_ERROR_MESSAGES[sync.errorKind]}</p>
{#if sync.errorKind === "auth-expired"}
<button class="secondary-action" onclick={reauthenticate}>Sign in again</button>
{:else}
<button class="secondary-action" onclick={() => sync.retryNow(() => loadMailbox(folder))}>Retry sync</button>
{/if}
</div>
{:else}
<p class="account-ok">Connected. Last synced {sync.lastSyncAt ? new Date(sync.lastSyncAt).toLocaleTimeString() : "—"}.</p>
{/if}
</section>
<section>
<h2>Appearance</h2>
<p class="setting-copy">Choose the contrast that is most comfortable for you.</p>
<div class="segmented" aria-label="Theme preference"><button class:active={(preferences.theme ?? "light") === "light"} aria-pressed={(preferences.theme ?? "light") === "light"} onclick={() => setTheme("light")}>Light</button><button class:active={(preferences.theme ?? "light") === "dark"} aria-pressed={(preferences.theme ?? "light") === "dark"} onclick={() => setTheme("dark")}>Dark</button></div>
<label class="setting-copy"><input type="checkbox" checked={preferences.shortcuts !== false} onchange={(event) => shortcuts.setEnabled(event.currentTarget.checked)} /> Keyboard shortcuts</label>
</section>
<section>
<h2>Contacts</h2>
<div class="stack-fields"><input bind:value={contact.name} placeholder="Name" /><input bind:value={contact.email} placeholder="email@example.com" /><button class="secondary-action" onclick={() => post({ action: "config", kind: "contact", value: contact }, false).then(() => loadMailbox(folder))}><UserPlus size={16} /> Add contact</button></div>
<div class="settings-list">{#each contacts as item}<p><b>{item.name || item.email}</b><code>{item.email}</code></p>{:else}<p class="muted">No contacts saved.</p>{/each}</div>
</section>
<section>
<h2>Signature</h2>
<textarea bind:value={signature} placeholder="Signature for this mailbox"></textarea>
<button class="secondary-action" onclick={() => saveSettings("signature", { signature })}>Save signature</button>
</section>
<section>
<h2>Vacation reply</h2>
<label class="check"><input type="checkbox" bind:checked={vacation.enabled} /> <span>Send automatic replies</span></label>
<input bind:value={vacation.subject} placeholder="Subject" />
<textarea bind:value={vacation.body} placeholder="Reply body"></textarea>
<button class="secondary-action" onclick={() => saveSettings("vacation", vacation)}>Save vacation reply</button>
</section>
<section>
<h2>Inbox rules</h2>
<div class="rule-builder">
<div class="menu-wrap"><button class="field-button" aria-haspopup="menu" aria-expanded={ruleFieldMenuOpen} onclick={() => ruleFieldMenuOpen = !ruleFieldMenuOpen}>{rule.field}<ChevronDown size={15} /></button>{#if ruleFieldMenuOpen}<div class="popup-menu" role="menu">{#each ruleFields as field}<button role="menuitem" onclick={() => chooseRuleField(field)}>{field}</button>{/each}</div>{/if}</div>
<input bind:value={rule.contains} placeholder="contains" />
<div class="menu-wrap"><button class="field-button" aria-haspopup="menu" aria-expanded={ruleActionMenuOpen} onclick={() => ruleActionMenuOpen = !ruleActionMenuOpen}>{rule.action}<ChevronDown size={15} /></button>{#if ruleActionMenuOpen}<div class="popup-menu" role="menu">{#each ruleActions as action}<button role="menuitem" onclick={() => chooseRuleAction(action)}>{action}</button>{/each}</div>{/if}</div>
</div>
<button class="secondary-action" onclick={() => post({ action: "config", kind: "rule", value: rule }, false).then(() => loadMailbox(folder))}>Add rule</button>
<button class="subtle-button" onclick={() => post({ action: "apply-rules", folder })}>Apply rules now</button>
<div class="settings-list">{#each rules as item}<p><b>{item.field}</b> contains <code>{item.contains}</code><span>{item.action}</span></p>{:else}<p class="muted">No rules configured.</p>{/each}</div>
</section>
<section>
<h2>Import or export</h2>
<textarea bind:value={importText} placeholder="Settings JSON"></textarea>
</section>
</SettingsDrawer>
{#if deletePending}
<div class="overlay confirm-overlay">
<div class="confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
<div class="danger-icon"><Trash2 size={20} /></div>
<h2 id="delete-title">Delete this message?</h2>
<p>It will move to Trash. You can restore it there if needed.</p>
<div><button class="subtle-button" onclick={() => deletePending = false}>Keep message</button><button class="danger-action" onclick={confirmDelete}>Move to Trash</button></div>
</div>
</div>
{/if}
{#if shortcutsOpen}
<div class="overlay shortcuts-overlay" role="presentation" onclick={() => (shortcutsOpen = false)}>
<div class="shortcuts-modal" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" tabindex="-1" onclick={(event) => event.stopPropagation()} onkeydown={() => {}}>
<div class="shortcuts-head"><span id="shortcuts-title"><Keyboard size={18} /> Keyboard shortcuts</span><button class="icon-button" aria-label="Close shortcuts" onclick={() => (shortcutsOpen = false)}><X size={18} /></button></div>
<dl class="shortcuts-list">
<div><dt><kbd>c</kbd></dt><dd>Compose new message</dd></div>
<div><dt><kbd>r</kbd></dt><dd>Reply</dd></div>
<div><dt><kbd>R</kbd></dt><dd>Reply all</dd></div>
<div><dt><kbd>f</kbd></dt><dd>Forward</dd></div>
<div><dt><kbd>e</kbd></dt><dd>Archive</dd></div>
<div><dt><kbd>Delete</kbd> / <kbd>#</kbd></dt><dd>Delete</dd></div>
<div><dt><kbd>Enter</kbd></dt><dd>Open message</dd></div>
<div><dt><kbd>s</kbd></dt><dd>Flag / unflag</dd></div>
<div><dt><kbd>u</kbd></dt><dd>Mark as unread</dd></div>
<div><dt><kbd>!</kbd></dt><dd>Mark as spam</dd></div>
<div><dt><kbd>j</kbd> / <kbd>↓</kbd></dt><dd>Next message</dd></div>
<div><dt><kbd>k</kbd> / <kbd>↑</kbd></dt><dd>Previous message</dd></div>
<div><dt><kbd>/</kbd></dt><dd>Focus search</dd></div>
<div><dt><kbd>Esc</kbd></dt><dd>Close dialog / deselect message</dd></div>
<div><dt><kbd>?</kbd></dt><dd>Show this reference</dd></div>
</dl>
<p class="shortcuts-note">Shortcuts are disabled while typing in the composer or search field.</p>
</div>
</div>
{/if}
</main>
<style>
:global(*) {
box-sizing: border-box;
}
:global(html),
:global(body) {
min-height: 100%;
margin: 0;
}
:global(body) {
background: var(--app-bg);
color: var(--ink);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
:global(html) {
--app-bg: #edf2ef;
--surface: #fff;
--surface-soft: #f6f9f7;
--surface-selected: #e5f2ec;
--ink: #17231f;
--muted: #60716b;
--line: #d7e2dd;
--line-soft: #e9efec;
--accent: #126b5a;
--accent-hover: #0e594b;
--accent-soft: #dcefe7;
--rail: #10211d;
--rail-hover: #1d3831;
--rail-text: #e6f0ec;
--danger: #a33b2e;
--focus: #9ad5c5;
}
:global(html[data-theme="dark"]),
:global(html[data-theme="akurai"]) {
--app-bg: #0d1512;
--surface: #14221e;
--surface-soft: #10201b;
--surface-selected: #203a32;
--ink: #e7f1ec;
--muted: #a1b3ac;
--line: #2a4038;
--line-soft: #1d3029;
--accent: #79c4ab;
--accent-hover: #a4dcc9;
--accent-soft: #244f42;
--rail: #091512;
--rail-hover: #173028;
--rail-text: #e6f0ec;
--danger: #ef9e93;
--focus: #a4dcc9;
}
:global(.spin) {
animation: 0.8s linear infinite spin;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
:global(.vip-icon) {
color: #d6a62c;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.52;
}
button:focus-visible,
input:focus,
textarea:focus {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
input,
textarea {
border: 1px solid var(--line);
background: var(--surface);
width: 100%;
min-width: 0;
color: var(--ink);
border-radius: 6px;
padding: 10px 12px;
}
input::placeholder,
textarea::placeholder {
color: var(--muted);
opacity: 0.82;
}
textarea {
resize: vertical;
}
.sr {
clip: rect(0, 0, 0, 0);
white-space: nowrap;
width: 1px;
height: 1px;
position: absolute;
overflow: hidden;
}
.webmail-shell {
grid-template-columns: 232px minmax(0, 1fr);
min-height: 100dvh;
display: grid;
}
.platform-rail {
background: var(--rail);
color: var(--rail-text);
flex-direction: column;
gap: 22px;
min-width: 0;
padding: 20px 16px;
display: flex;
}
.brand {
color: inherit;
align-items: center;
gap: 11px;
min-width: 0;
padding: 4px;
text-decoration: none;
display: flex;
}
.mark {
color: #112019;
background: #c7ef61;
border-radius: 6px;
flex: none;
place-items: center;
block-size: 38px;
inline-size: 38px;
font-weight: 900;
display: grid;
}
.brand strong,
.brand small {
text-overflow: ellipsis;
white-space: nowrap;
display: block;
overflow: hidden;
}
.brand strong {
letter-spacing: -0.01em;
font-size: 14px;
}
.brand small {
color: #99ada5;
margin-top: 2px;
font-size: 11px;
}
nav {
gap: 4px;
display: grid;
}
nav a,
.sign-out {
color: #cfddd7;
border-radius: 6px;
align-items: center;
gap: 10px;
min-block-size: 42px;
padding: 0 11px;
font-size: 14px;
text-decoration: none;
display: flex;
}
nav a:hover,
nav a.active,
.sign-out:hover {
background: var(--rail-hover);
color: #fff;
}
.rail-footer {
gap: 14px;
margin-top: auto;
display: grid;
}
.secure-state {
color: #9bb1a8;
align-items: center;
gap: 8px;
padding: 0 10px;
font-size: 12px;
display: flex;
}
.mail-app {
grid-template-rows: auto 76px minmax(0, 1fr);
min-width: 0;
min-height: 100dvh;
display: grid;
}
.mail-topbar {
z-index: 20;
background: var(--surface);
border-bottom: 1px solid var(--line);
grid-template-columns: minmax(170px, 245px) minmax(260px, 1fr) auto;
align-items: center;
gap: 16px;
padding: 12px 18px;
display: grid;
position: relative;
}
.identity {
gap: 2px;
min-width: 0;
display: grid;
}
.eyebrow {
color: var(--muted);
letter-spacing: 0.04em;
font-size: 11px;
font-weight: 750;
}
.identity strong {
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
overflow: hidden;
}
.search-control {
background: var(--surface-soft);
border: 1px solid var(--line);
min-width: 0;
min-height: 42px;
color: var(--muted);
border-radius: 6px;
align-items: center;
gap: 9px;
padding: 0 12px;
display: flex;
}
.search-control input {
background: 0 0;
border: 0;
border-radius: 0;
outline: 0;
padding: 0;
}
.clear-search {
margin-left: auto;
}
.top-actions {
align-items: center;
gap: 8px;
min-width: 0;
display: flex;
}
.menu-wrap {
position: relative;
}
.field-button {
border: 1px solid var(--line);
background: var(--surface);
min-height: 42px;
color: var(--ink);
text-align: left;
border-radius: 6px;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 0 11px;
display: flex;
}
.popup-menu {
z-index: 70;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
gap: 2px;
min-width: 100%;
max-width: min(360px, 100vw - 32px);
padding: 6px;
display: grid;
position: absolute;
top: calc(100% + 7px);
left: 0;
box-shadow: 0 18px 48px #0c1f182e;
}
.popup-menu button {
color: var(--ink);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
background: 0 0;
border: 0;
border-radius: 6px;
padding: 9px 10px;
overflow: hidden;
}
.popup-menu button:hover {
background: var(--surface-selected);
color: var(--accent);
}
.icon-button {
border: 1px solid var(--line);
background: var(--surface);
block-size: 40px;
inline-size: 40px;
color: var(--ink);
border-radius: 6px;
flex: none;
place-items: center;
display: inline-grid;
}
.icon-button:hover {
background: var(--surface-selected);
color: var(--accent);
}
.primary-action,
.secondary-action,
.danger-action {
border: 0;
border-radius: 6px;
justify-content: center;
align-items: center;
gap: 8px;
min-block-size: 42px;
padding: 0 14px;
font-weight: 750;
display: inline-flex;
}
.primary-action {
background: var(--accent);
color: #fff;
}
:global(html[data-theme="dark"]) .primary-action,
:global(html[data-theme="akurai"]) .primary-action {
color: #082018;
}
.primary-action:hover {
background: var(--accent-hover);
}
.secondary-action {
background: var(--accent-soft);
color: var(--accent);
}
.secondary-action:hover,
.subtle-button:hover {
background: var(--surface-selected);
}
.danger-action {
background: var(--danger);
color: #fff;
}
.subtle-button {
min-height: 38px;
color: var(--muted);
background: 0 0;
border: 0;
border-radius: 6px;
padding: 0 10px;
font-weight: 700;
}
.mail-canvas {
background: var(--surface);
grid-template-columns: 205px minmax(310px, 400px) minmax(0, 1fr);
min-height: 0;
display: grid;
overflow: hidden;
}
kbd {
border: 1px solid var(--line);
font: inherit;
border-bottom-width: 2px;
border-radius: 4px;
padding: 0 3px;
}
dl {
gap: 5px;
margin: 0;
display: grid;
}
dl div {
grid-template-columns: 48px minmax(0, 1fr);
gap: 10px;
display: grid;
}
dt {
color: var(--muted);
font-size: 12px;
}
dd {
overflow-wrap: anywhere;
margin: 0;
font-size: 13px;
}
.toast {
z-index: 100;
border: 1px solid var(--line);
background: var(--surface);
max-width: min(400px, 100vw - 40px);
color: var(--ink);
border-radius: 6px;
align-items: center;
gap: 12px;
padding: 10px 10px 10px 14px;
display: flex;
position: fixed;
bottom: 20px;
right: 20px;
box-shadow: 0 16px 45px #0c1f1833;
}
.toast span {
flex: 1;
}
.toast button {
color: var(--muted);
background: 0 0;
border: 0;
}
.overlay {
z-index: 80;
background: #07130e6b;
place-items: end center;
padding: 18px;
display: grid;
position: fixed;
inset: 0;
}
.compose-title {
background: var(--rail);
min-height: 53px;
color: var(--rail-text);
justify-content: space-between;
align-items: center;
padding: 0 12px 0 16px;
display: flex;
}
.compose-title span {
align-items: center;
gap: 8px;
font-weight: 800;
display: inline-flex;
}
.inverse {
color: #fff;
background: 0 0;
border-color: #ffffff2e;
}
.inverse:hover {
color: #fff;
background: #ffffff1a;
}
.recipient-row,
.compose-subject {
border-bottom: 1px solid var(--line-soft);
grid-template-columns: 52px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 7px 14px;
display: grid;
}
.recipient-row > span {
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.compose-subject input {
border: 0;
border-radius: 0;
padding: 6px 0;
}
.compose-subject {
grid-template-columns: minmax(0, 1fr) auto;
}
.body-field {
min-height: 230px;
padding: 14px;
display: grid;
}
.signature-note,
.attachment-note {
color: var(--muted);
align-items: center;
gap: 6px;
margin: 0;
padding: 0 14px 8px;
font-size: 12px;
display: flex;
}
.compose-actions {
border-top: 1px solid var(--line);
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px 12px;
display: flex;
}
.compose-actions > div {
align-items: center;
gap: 7px;
display: flex;
}
.file-input {
display: none;
}
.schedule-row {
align-items: center;
gap: 7px;
display: flex;
}
.schedule-row
input[type="datetime-local"] {
border-radius: 6px;
padding: 5px 8px;
font-size: 12px;
}
.settings-list button.icon-button {
justify-self: end;
}
.settings-head {
z-index: 2;
border-bottom: 1px solid var(--line);
background: var(--surface);
justify-content: space-between;
align-items: center;
min-height: 64px;
padding: 0 16px;
display: flex;
position: sticky;
top: 0;
}
.settings-head span {
align-items: center;
gap: 8px;
font-weight: 850;
display: inline-flex;
}
.setting-copy,
.muted {
color: var(--muted);
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.account-status {
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
color: var(--danger);
border-radius: 6px;
gap: 8px;
padding: 10px;
display: grid;
}
.account-status.warn {
background: color-mix(in srgb, #d6a62c 14%, var(--surface));
color: #7a5a00;
}
.account-status p {
margin: 0;
font-size: 13px;
}
.account-ok {
color: var(--muted);
margin: 0;
font-size: 12px;
}
.segmented {
background: var(--surface-soft);
border-radius: 6px;
padding: 3px;
display: flex;
}
.segmented button {
min-height: 34px;
color: var(--muted);
background: 0 0;
border: 0;
border-radius: 6px;
flex: 1;
font-weight: 750;
}
.segmented button.active {
background: var(--surface);
color: var(--accent);
box-shadow: 0 1px 3px #091b141f;
}
.stack-fields {
gap: 8px;
display: grid;
}
.settings-list {
gap: 6px;
display: grid;
}
.settings-list p {
color: var(--muted);
gap: 3px;
margin: 0;
font-size: 12px;
display: grid;
}
.settings-list b {
color: var(--ink);
font-size: 13px;
}
.settings-list code {
text-overflow: ellipsis;
white-space: nowrap;
background: var(--surface-soft);
width: fit-content;
max-width: 100%;
color: var(--muted);
border-radius: 5px;
padding: 2px 5px;
overflow: hidden;
}
.settings-list span {
color: var(--accent);
font-weight: 700;
}
.check {
color: var(--ink);
align-items: center;
gap: 8px;
font-size: 13px;
display: flex;
}
.check input {
block-size: 18px;
inline-size: 18px;
}
.rule-builder {
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr) minmax(0, 0.8fr);
gap: 7px;
display: grid;
}
.rule-builder .field-button {
width: 100%;
min-height: 40px;
}
.confirm-overlay {
z-index: 110;
place-items: center;
}
.confirm-modal {
border: 1px solid var(--line);
background: var(--surface);
width: min(390px, 100%);
color: var(--ink);
text-align: center;
border-radius: 6px;
justify-items: center;
gap: 10px;
padding: 26px;
display: grid;
box-shadow: 0 30px 80px #04100b59;
}
.danger-icon {
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
block-size: 42px;
inline-size: 42px;
color: var(--danger);
border-radius: 6px;
place-items: center;
display: grid;
}
.confirm-modal h2,
.confirm-modal p {
margin: 0;
}
.confirm-modal h2 {
font-size: 18px;
}
.confirm-modal p {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.confirm-modal > div:last-child {
gap: 8px;
margin-top: 7px;
display: flex;
}
.shortcuts-overlay {
place-items: center;
}
.shortcuts-modal {
border: 1px solid var(--line);
background: var(--surface);
width: min(440px, 100%);
max-height: min(600px, 100dvh - 36px);
color: var(--ink);
border-radius: 6px;
gap: 4px;
padding: 0 0 16px;
display: grid;
overflow: auto;
box-shadow: 0 30px 80px #04100b59;
}
.shortcuts-head {
z-index: 1;
border-bottom: 1px solid var(--line);
background: var(--surface);
justify-content: space-between;
align-items: center;
min-height: 56px;
padding: 0 16px;
display: flex;
position: sticky;
top: 0;
}
.shortcuts-head span {
align-items: center;
gap: 8px;
font-weight: 800;
display: inline-flex;
}
.shortcuts-list {
gap: 2px;
margin: 8px 16px 0;
display: grid;
}
.shortcuts-list div {
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 6px 0;
display: grid;
}
.shortcuts-list dt {
gap: 4px;
display: flex;
}
.shortcuts-list dd {
color: var(--muted);
margin: 0;
font-size: 13px;
}
.shortcuts-note {
color: var(--muted);
margin: 12px 16px 0;
font-size: 12px;
}
@media (width<=1180px) {
.webmail-shell {
grid-template-columns: 200px minmax(0, 1fr);
}
.platform-rail {
padding-inline: 13px;
}
.mail-canvas {
grid-template-columns: 180px minmax(290px, 360px) minmax(0, 1fr);
}
}
@media (width<=900px) {
.webmail-shell {
grid-template-columns: minmax(0, 1fr);
}
.platform-rail {
z-index: 60;
flex-direction: row;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 9px 13px;
position: sticky;
top: 0;
}
.brand {
padding: 0;
}
.brand small {
display: none;
}
.platform-rail nav {
margin-left: auto;
display: flex;
}
.platform-rail
nav
a {
min-height: 37px;
padding: 0 9px;
}
.rail-footer {
margin: 0;
display: block;
}
.secure-state {
display: none;
}
.sign-out {
min-height: 37px;
padding: 0 9px;
}
.mail-app {
grid-template-rows: auto auto minmax(0, 1fr);
min-height: calc(100dvh - 58px);
}
.mail-topbar {
grid-template-rows: auto auto;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 10px 13px;
}
.search-control {
grid-area: 2/1 / auto/-1;
}
.identity {
grid-column: 1;
}
.top-actions {
grid-column: 2;
}
.mail-canvas {
grid-template-columns: 160px minmax(280px, 1fr);
}
.mail-canvas.reading {
grid-template-columns: minmax(0, 1fr);
}
}
@media (width<=640px) {
.platform-rail {
gap: 8px;
}
.brand span:last-child {
display: none;
}
.platform-rail
nav
a
span,
.sign-out {
font-size: 0;
}
.platform-rail
nav
a,
.sign-out {
justify-content: center;
inline-size: 38px;
padding: 0;
}
.mail-topbar {
min-height: 112px;
}
.identity strong {
max-width: 54vw;
}
.top-actions .settings-button {
display: none;
}
.mail-canvas {
grid-template-columns: minmax(0, 1fr);
}
.overlay {
padding: 0;
}
.rule-builder {
grid-template-columns: 1fr;
}
.compose-actions {
align-items: stretch;
}
.compose-actions > div {
justify-content: space-between;
}
.toast {
max-width: calc(100vw - 24px);
bottom: 12px;
right: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
</style>