Menu
Public repository / 649b86b3a6fb87df3c82b4df0a648ffe81b6a053
AkurAI-Mail / src/routes/+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 dns = $derived(data.dns);
let userEmail = $state("");
let alias = $state("");
let target = $state("");
let spamValue = $state("");
let spamField = $state("blocklist");
let spamMode = $state("add");
let spamMailbox = $state("");
let result = $state<any>(null);
let busy = $state(false);
const serviceLabels: Record<string, string> = {
postfix: "Postfix",
dovecot: "Dovecot",
opendkim: "OpenDKIM",
nginx: "Nginx",
"akurai-mail-ui": "Console",
fail2ban: "Fail2ban"
};
function pct(value: number | undefined) {
return `${Math.round(value ?? 0)}%`;
}
function points(key: "cpu" | "memory" | "disk") {
const rows = status.metricsHistory ?? [];
if (rows.length === 0) return "0,54 260,54";
return rows.map((row: any, index: number) => {
const x = rows.length === 1 ? 260 : (index / (rows.length - 1)) * 260;
const y = 58 - Math.max(0, Math.min(100, Number(row[key] ?? 0))) * 0.52;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
}
async function refresh() {
const response = await fetch(location.href, { headers: { accept: "text/html" } });
if (response.ok) location.reload();
}
async function action(payload: Record<string, any>) {
busy = true;
result = null;
try {
const response = await fetch("/api/actions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
result = await response.json();
if (result.ok) await refresh();
} finally {
busy = false;
}
}
function spamList(field: string) {
return status.antiSpam?.policy?.[field] ?? [];
}
</script>
<svelte:head><title>AkurAI Mail Admin</title></svelte:head>
<main class="shell">
<aside class="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">
<a class="active" href="/">Admin</a>
<a href="/webmail">Webmail</a>
<a href="#vm">VM status</a>
<a href="#anti-spam">Anti-spam</a>
<a href="#mailboxes">Mailboxes</a>
<a href="#dns">DNS</a>
</nav>
<a class="signout" href="/logout">Sign out</a>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="kicker">Operations console</p>
<h1>Mail platform control plane</h1>
<p>{status.publicIp} / {status.domain}</p>
</div>
<button class="primary" onclick={refresh} disabled={busy}>Refresh</button>
</header>
{#if result?.password}
<section class="notice success" aria-live="polite"><strong>Temporary password</strong><code>{result.password}</code></section>
{:else if result?.ok}
<section class="notice success" aria-live="polite"><strong>Action complete</strong><span>{result.moved ?? 0} message(s) quarantined</span></section>
{:else if result?.error}
<section class="notice error" role="alert"><strong>Action failed</strong><span>{result.error}</span></section>
{/if}
<section class="service-strip" aria-label="Service health">
{#each Object.entries(status.services) as [name, value]}
<article class:ok={value === "active"}>
<span>{serviceLabels[name] ?? name}</span>
<strong>{value}</strong>
</article>
{/each}
</section>
<section class="split">
<article class="panel">
<div class="panel-head">
<div>
<h2>Mail queue</h2>
<p>{status.queue?.summary ?? "Queue status unavailable"}</p>
</div>
<strong class="big">{status.queue?.count ?? 0}</strong>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div>
<h2>Delivery errors</h2>
<p>Recent Postfix warnings, rejects, and bounces</p>
</div>
</div>
<div class="log-list">
{#each status.deliveryErrors ?? [] as line}
<code>{line}</code>
{:else}
<p class="empty">No delivery errors in the recent journal sample.</p>
{/each}
</div>
</article>
</section>
<section id="anti-spam" class="panel">
<div class="panel-head">
<div>
<h2>Anti-spam</h2>
<p>{status.antiSpam?.totalSpam ?? 0} quarantined · {status.antiSpam?.policy?.stats?.moved ?? 0} moved by policy</p>
</div>
<button class="primary" onclick={() => action({ action: "anti-spam-scan", email: spamMailbox })} disabled={busy}>Scan</button>
</div>
<div class="spam-grid">
<article>
<span>Status</span>
<strong>{status.antiSpam?.policy?.enabled ? "Enabled" : "Disabled"}</strong>
<button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { enabled: !status.antiSpam?.policy?.enabled } })} disabled={busy}>
{status.antiSpam?.policy?.enabled ? "Disable" : "Enable"}
</button>
</article>
<article>
<span>Quarantine</span>
<strong>{status.antiSpam?.policy?.autoMove ? "Auto move" : "Score only"}</strong>
<button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { autoMove: !status.antiSpam?.policy?.autoMove } })} disabled={busy}>
{status.antiSpam?.policy?.autoMove ? "Score only" : "Auto move"}
</button>
</article>
<article>
<span>Threshold</span>
<strong>{status.antiSpam?.policy?.threshold ?? 5}</strong>
<div class="stepper">
<button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { threshold: Math.max(1, Number(status.antiSpam?.policy?.threshold ?? 5) - 1) } })} disabled={busy}>-</button>
<button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { threshold: Math.min(20, Number(status.antiSpam?.policy?.threshold ?? 5) + 1) } })} disabled={busy}>+</button>
</div>
</article>
<article>
<span>Last scan</span>
<strong>{status.antiSpam?.policy?.stats?.lastScan ? new Date(status.antiSpam.policy.stats.lastScan * 1000).toLocaleString() : "Never"}</strong>
<small>{status.antiSpam?.policy?.stats?.scanned ?? 0} scanned</small>
</article>
</div>
<form class="spam-form" onsubmit={(e) => { e.preventDefault(); action({ action: "anti-spam-config", policy: { field: spamField, mode: spamMode, value: spamValue } }); spamValue = ""; }}>
<label>
<span>List</span>
<select bind:value={spamField}>
<option value="blocklist">Blocked senders</option>
<option value="allowlist">Allowed senders/domains</option>
<option value="domainBlocklist">Blocked domains</option>
<option value="subjectKeywords">Spam keywords</option>
<option value="attachmentExtensions">Blocked extensions</option>
</select>
</label>
<label>
<span>Mode</span>
<select bind:value={spamMode}>
<option value="add">Add</option>
<option value="remove">Remove</option>
<option value="set">Replace</option>
</select>
</label>
<label>
<span>Value</span>
<input bind:value={spamValue} placeholder="sender@example.com, example.net, keyword" required />
</label>
<button class="primary" disabled={busy}>Apply</button>
</form>
<form class="scan-form" onsubmit={(e) => { e.preventDefault(); action({ action: "anti-spam-scan", email: spamMailbox }); }}>
<label>
<span>Scan mailbox</span>
<select bind:value={spamMailbox}>
<option value="">All mailboxes</option>
{#each status.users as user}<option value={user.email}>{user.email}</option>{/each}
</select>
</label>
<button class="primary" disabled={busy}>Run scan</button>
</form>
<div class="split compact">
<div class="pill-list">
<h3>Blocked senders</h3>
{#each spamList("blocklist") as item}<code>{item}</code>{:else}<p class="empty">No blocked senders.</p>{/each}
</div>
<div class="pill-list">
<h3>Blocked domains</h3>
{#each spamList("domainBlocklist") as item}<code>{item}</code>{:else}<p class="empty">No blocked domains.</p>{/each}
</div>
</div>
<div class="table spam-counts">
{#each status.antiSpam?.mailboxes ?? [] as item}
<div><span>{item.email}</span><em>{item.spam} spam</em></div>
{/each}
</div>
</section>
<section id="vm" class="panel">
<div class="panel-head">
<div>
<h2>VM status</h2>
<p>Last sample {new Date(status.generatedAt * 1000).toLocaleTimeString()}</p>
</div>
</div>
<div class="charts">
<article>
<div><span>CPU</span><strong>{pct(status.metrics?.cpu)}</strong></div>
<svg viewBox="0 0 260 64" role="img" aria-label="CPU usage trend">
<path d="M0 58H260" />
<polyline points={points("cpu")} />
</svg>
</article>
<article>
<div><span>Memory</span><strong>{pct(status.metrics?.memory?.percent)}</strong></div>
<svg viewBox="0 0 260 64" role="img" aria-label="Memory usage trend">
<path d="M0 58H260" />
<polyline points={points("memory")} />
</svg>
<p>{status.metrics?.memory?.usedMb} MB / {status.metrics?.memory?.totalMb} MB</p>
</article>
<article>
<div><span>Disk</span><strong>{pct(status.metrics?.disk?.percent)}</strong></div>
<svg viewBox="0 0 260 64" role="img" aria-label="Disk usage trend">
<path d="M0 58H260" />
<polyline points={points("disk")} />
</svg>
<p>{status.metrics?.disk?.usedGb} GB / {status.metrics?.disk?.totalGb} GB</p>
</article>
</div>
</section>
<section class="split">
<article id="mailboxes" class="panel">
<div class="panel-head">
<div>
<h2>Mailboxes</h2>
<p>{status.users.length} active</p>
</div>
</div>
<form onsubmit={(e) => { e.preventDefault(); action({ action: "add-user", email: userEmail }); }}>
<label>
<span>Email</span>
<input bind:value={userEmail} type="email" placeholder="name@olibuijr.com" required />
</label>
<button class="primary" disabled={busy}>Add</button>
</form>
<div class="table">
{#each status.users as user}
<div>
<span><strong>{user.email}</strong><small>{user.quotaUsedMb ?? 0} MB used · last delivery {user.lastDelivery ? new Date(user.lastDelivery * 1000).toLocaleString() : "none"}</small></span>
<a class="mini-link" href={`/webmail?mailbox=${encodeURIComponent(user.email)}`}>Open webmail</a>
</div>
{:else}
<p class="empty">No mailboxes are configured.</p>
{/each}
</div>
</article>
<article id="aliases" class="panel">
<div class="panel-head">
<div>
<h2>Aliases</h2>
<p>{status.aliases.length} active</p>
</div>
</div>
<form class="alias-form" onsubmit={(e) => { e.preventDefault(); action({ action: "add-alias", alias, target }); }}>
<label>
<span>Alias</span>
<input bind:value={alias} type="email" placeholder="alias@olibuijr.com" required />
</label>
<label>
<span>Target</span>
<input bind:value={target} type="email" placeholder="target@olibuijr.com" required />
</label>
<button class="primary" disabled={busy}>Add</button>
</form>
<div class="table">
{#each status.aliases as item}
<div>
<span>{item.alias}</span>
<em>{item.target}</em>
</div>
{:else}
<p class="empty">No aliases are configured.</p>
{/each}
</div>
</article>
</section>
<section id="dns" class="panel">
<div class="panel-head">
<div>
<h2>DNS records</h2>
<p>1984 Hosting values expected by this VM</p>
</div>
</div>
<div class="records">
{#each dns.records as record}
<div>
<b>{record.type}</b>
<span>{record.name}</span>
<code>{record.value}</code>
</div>
{/each}
</div>
<div class="health-list">
{#each status.dnsHealth ?? [] as check}
<div>
<b class:ok={check.ok}>{check.ok ? "OK" : "Check"}</b>
<span>{check.type} {check.name}</span>
<code>{(check.values ?? []).join(", ") || check.error || "no answer"}</code>
</div>
{/each}
</div>
</section>
</section>
</main>
<style>
:global(*){box-sizing:border-box}
:global(html),:global(body){margin:0;overflow-x:hidden}
:global(body){font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f6f5;color:#151d1b}
button,input,select{font:inherit}
.shell{min-height:100dvh;display:grid;grid-template-columns:248px minmax(0,1fr)}
.rail{position:sticky;top:0;height:100dvh;background:#0d1b18;color:#edf7f3;padding:20px;display:flex;flex-direction:column;gap:20px}
.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,.signout{color:#dce9e4;text-decoration:none;border-radius:6px;padding:10px 11px;min-height:44px;display:flex;align-items:center}
nav a:hover,.signout:hover,nav a.active{background:#1c332e}.signout{margin-top:auto;color:#b8c9c3}
.workspace{padding:26px;display:grid;gap:18px;min-width:0}
.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;min-width:0}
.kicker{margin:0 0 6px;color:#126b5a;font-size:13px;font-weight:800}.topbar h1{margin:0;font-size:30px;line-height:1.15;text-wrap:balance}.topbar p:last-child{margin:6px 0 0;color:#61716c;overflow-wrap:anywhere}
.primary{border:0;border-radius:6px;background:#126b5a;color:#fff;min-height:44px;padding:10px 15px;font-weight:800;cursor:pointer}.primary:hover{background:#0e594b}.primary:disabled{opacity:.55;cursor:not-allowed}
.secondary{border:1px solid #cbd8d4;border-radius:6px;background:#fff;color:#126b5a;min-height:38px;padding:8px 11px;font-weight:800;cursor:pointer}.secondary:hover{background:#eef7f4}.secondary:disabled{opacity:.55;cursor:not-allowed}
.service-strip{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px}
.service-strip article,.panel,.notice{background:#fff;border:1px solid #dce4e1;border-radius:8px;min-width:0}
.service-strip article{padding:12px}.service-strip span{display:block;color:#61716c;font-size:12px}.service-strip strong{display:block;margin-top:5px;color:#9a473a;font-size:15px}.service-strip article.ok strong{color:#126b5a}
.panel{padding:18px}.panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:15px}.panel h2{margin:0;font-size:18px}.panel p{margin:4px 0 0;color:#61716c}.big{font-size:32px;color:#126b5a}.log-list{display:grid;gap:8px}.log-list code{background:#f6f8f7;border-radius:5px;padding:7px;overflow-wrap:anywhere}
.charts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.charts article{border:1px solid #edf1ef;border-radius:8px;padding:14px;min-width:0}.charts div{display:flex;justify-content:space-between;gap:12px}.charts span{color:#61716c}.charts strong{font-size:24px}.charts svg{width:100%;height:76px;margin-top:8px}.charts path{stroke:#e1e8e5}.charts polyline{fill:none;stroke:#126b5a;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px}
form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;margin-bottom:14px}.alias-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto}label{display:grid;gap:6px;min-width:0}label span{font-size:13px;font-weight:800;color:#33413d}input,select{width:100%;min-width:0;border:1px solid #cbd8d4;border-radius:6px;padding:11px;background:#fff;color:#151d1b}input:focus,select:focus{outline:3px solid #b7e7da;border-color:#126b5a}
.spam-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}.spam-grid article{border:1px solid #edf1ef;border-radius:8px;padding:12px;display:grid;gap:8px;min-width:0}.spam-grid span,.pill-list h3{color:#61716c;font-size:12px;font-weight:800;margin:0}.spam-grid strong{font-size:18px;overflow-wrap:anywhere}.spam-grid small{color:#61716c}.stepper{display:grid;grid-template-columns:1fr 1fr;gap:8px}.spam-form{grid-template-columns:minmax(140px,200px) minmax(110px,150px) minmax(0,1fr) auto}.scan-form{grid-template-columns:minmax(0,300px) auto}.compact{margin-top:4px}.pill-list{border-top:1px solid #edf1ef;padding-top:12px;display:flex;flex-wrap:wrap;gap:8px;align-content:flex-start}.pill-list h3{flex-basis:100%}.pill-list code{background:#eef4f1;border-radius:999px;padding:5px 8px;overflow-wrap:anywhere}.spam-counts{margin-top:14px}
.table,.records{display:grid;gap:8px}.table div,.records div{display:grid;align-items:center;gap:10px;border-top:1px solid #edf1ef;padding-top:10px;min-width:0}.table div{grid-template-columns:minmax(0,1fr) auto}.records div{grid-template-columns:72px minmax(150px,260px) minmax(0,1fr)}
.table span,.table em,.records span,.records code,.health-list span,.health-list code{min-width:0;overflow-wrap:anywhere}.table span small{display:block;color:#61716c;margin-top:3px}.table em,.mini-link{font-style:normal;color:#126b5a;background:#e9f6f2;border-radius:999px;padding:4px 8px;font-size:12px;font-weight:800;text-decoration:none}.records code,.notice code,.health-list code{background:#eef4f1;border-radius:5px;padding:5px 7px;white-space:pre-wrap;word-break:break-word}.health-list{margin-top:14px;display:grid;gap:8px}.health-list div{display:grid;grid-template-columns:72px minmax(150px,260px) minmax(0,1fr);gap:10px;align-items:center}.health-list b{color:#9a473a}.health-list b.ok{color:#126b5a}
.notice{display:flex;align-items:center;gap:12px;padding:14px 16px}.notice.success{border-color:#a9d9c9}.notice.error{border-color:#d49a91}.empty{color:#61716c}
@media(max-width:1050px){.service-strip{grid-template-columns:repeat(3,minmax(0,1fr))}.charts,.split,.spam-grid{grid-template-columns:minmax(0,1fr)}}
@media(max-width:760px){.shell{grid-template-columns:minmax(0,1fr)}.rail{position:static;height:auto;padding:14px 16px;gap:12px}.brand small{font-size:12px}nav{display:flex;overflow-x:auto;gap:6px;scrollbar-width:none}nav::-webkit-scrollbar{display:none}nav a{white-space:nowrap;background:rgba(255,255,255,.06)}.signout{margin-top:0}.workspace{padding:16px}.topbar{display:grid}.topbar h1{font-size:25px}.primary{width:100%}.service-strip{grid-template-columns:repeat(2,minmax(0,1fr))}form,.alias-form,.spam-form,.scan-form{grid-template-columns:minmax(0,1fr)}.records div,.table div{grid-template-columns:minmax(0,1fr)}}
@media(max-width:420px){.service-strip{grid-template-columns:minmax(0,1fr)}.panel{padding:15px}.topbar h1{font-size:23px}}
</style>