AkurAI Build
Menu

Public repository / 10d19fe0941c186ec244d1f0e2af6b19200dcb0c

AkurAI-Mail / src/routes/(admin)/antispam/+page.svelte

Latest change 22202d1bde69be4ace8a299dffd91f8ff24378df - Recover deployed webmail frontend (frontend parity with EC2 a6afffc) by Ólafur Búi Ólafsson

<script lang="ts">
  import { invalidateAll } from "$app/navigation";

  let { data } = $props();
  let antiSpam = $derived(data.antiSpam);
  let users = $derived(data.users);
  let spamValue = $state("");
  let spamField = $state("blocklist");
  let spamMode = $state("add");
  let spamMailbox = $state("");
  let result = $state<any>(null);
  let busy = $state(false);

  async function refresh() {
    await invalidateAll();
  }

  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 antiSpam?.policy?.[field] ?? [];
  }
</script>

<svelte:head><title>Anti-spam - AkurAI Mail Admin</title></svelte:head>

<header class="topbar">
  <div>
    <p class="kicker">Spam protection</p>
    <h1>Anti-spam</h1>
    <p>{antiSpam?.totalSpam ?? 0} quarantined · {antiSpam?.policy?.stats?.moved ?? 0} moved by policy</p>
  </div>
  <button class="primary" onclick={refresh} disabled={busy}>Refresh</button>
</header>

{#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="panel">
  <div class="panel-head">
    <div>
      <h2>Controls</h2>
    </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>{antiSpam?.policy?.enabled ? "Enabled" : "Disabled"}</strong>
      <button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { enabled: !antiSpam?.policy?.enabled } })} disabled={busy}>
        {antiSpam?.policy?.enabled ? "Disable" : "Enable"}
      </button>
    </article>
    <article>
      <span>Quarantine</span>
      <strong>{antiSpam?.policy?.autoMove ? "Auto move" : "Score only"}</strong>
      <button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { autoMove: !antiSpam?.policy?.autoMove } })} disabled={busy}>
        {antiSpam?.policy?.autoMove ? "Score only" : "Auto move"}
      </button>
    </article>
    <article>
      <span>Threshold</span>
      <strong>{antiSpam?.policy?.threshold ?? 5}</strong>
      <div class="stepper">
        <button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { threshold: Math.max(1, Number(antiSpam?.policy?.threshold ?? 5) - 1) } })} disabled={busy}>-</button>
        <button class="secondary" onclick={() => action({ action: "anti-spam-config", policy: { threshold: Math.min(20, Number(antiSpam?.policy?.threshold ?? 5) + 1) } })} disabled={busy}>+</button>
      </div>
    </article>
    <article>
      <span>Last scan</span>
      <strong>{antiSpam?.policy?.stats?.lastScan ? new Date(antiSpam.policy.stats.lastScan * 1000).toLocaleString() : "Never"}</strong>
      <small>{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 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 antiSpam?.mailboxes ?? [] as item}
      <div><span>{item.email}</span><em>{item.spam} spam</em></div>
    {/each}
  </div>
</section>

<style>
  .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}
  .panel,.notice{background:#fff;border:1px solid #dce4e1;border-radius:6px;min-width:0}
  .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}
  .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:6px;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}
  form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;margin-bottom:14px}.spam-form{grid-template-columns:minmax(140px,200px) minmax(110px,150px) minmax(0,1fr) auto}.scan-form{grid-template-columns:minmax(0,300px) 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}
  .split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px}.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:6px;padding:5px 8px;overflow-wrap:anywhere}
  .table{display:grid;gap:8px}.table div{display:grid;align-items:center;gap:10px;border-top:1px solid #edf1ef;padding-top:10px;min-width:0;grid-template-columns:minmax(0,1fr) auto}.table span,.table em{min-width:0;overflow-wrap:anywhere}.spam-counts{margin-top:14px}
  .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){.spam-grid,.split{grid-template-columns:minmax(0,1fr)}}
  @media(max-width:760px){.topbar{display:grid}.topbar h1{font-size:25px}.primary{width:100%}form,.spam-form,.scan-form{grid-template-columns:minmax(0,1fr)}}
  @media(max-width:420px){.panel{padding:15px}.topbar h1{font-size:23px}}
</style>