AkurAI Build
Menu

Public repository / master

AkurAI-Mail / src/framework/webmail.js

Latest change 054e5cda0171bd13facff3c16a38f2048a1052cc - OLI-249: compose, settings, admin console and sign-in on the framework; SvelteKit removed by Ólafur Búi Ólafsson

// webmail.js — the webmail page on the AkurAI-Framework client runtime (native ESM, no build step).
//
// The HTML shell (app/webmail/index.html) marks its interactive regions with `data-island`;
// islands.js hydrates them on DOMContentLoaded and each island mounts once the store is ready.
// The store (store.js) holds all state; the islands only render signals and call actions.
import { effect } from "./vendor/signals.js";
import { hydrate, registerIsland } from "./vendor/islands.js";
import { h, replace } from "./dom.js";
import { icon } from "./icons.js";
import { createApi, loginUrl } from "./api.js";
import { createStore } from "./store.js";
import { mountList } from "./list-island.js";
import { mountReader } from "./reader-island.js";
import { mountCompose } from "./compose-island.js";
import { mountSettings } from "./settings-island.js";
import { SHORTCUT_HELP, createShortcuts } from "./shortcuts.js";
import { webmailSession } from "../lib/webmail/session.js";
import { SYNC_ERROR_MESSAGES, syncedLabel } from "../lib/webmail/sync.js";

/** @typedef {ReturnType<typeof createStore>} Store */

/**
 * Signs the user in (or sends them to the IdP), creates the store and loads the inbox.
 * @returns {Promise<Store>}
 */
async function boot() {
  const api = createApi();
  const session = webmailSession(await api.api("/api/auth/check"));
  if (!session.signedIn) {
    window.location.href = loginUrl(window.location.pathname);
    return new Promise(() => {});
  }
  const status = session.admin ? await api.api("/api/status") : { hostname: window.location.hostname };
  const store = createStore({ session, hostname: status.hostname, api, storage: localStorage, sessionStorage, navigate: (url) => { window.location.href = url; }, pathname: window.location.pathname });
  document.title = "Webmail · AkurAI Mail";
  effect(() => { document.documentElement.dataset.theme = store.preferences().theme ?? "light"; });
  installShortcuts(store);
  await store.loadMailbox("Inbox");
  store.loadScheduledSends();
  store.restoreDraft();
  return store;
}

/** @param {Store} store */
function installShortcuts(store) {
  const shortcuts = createShortcuts({
    window,
    enabled: true,
    hasSelection: () => !!store.selected(),
    persist: (enabled) => { store.setShortcutsEnabled(enabled); },
    onAction: (action, event) => {
      /** @type {Record<string, () => unknown>} */
      const handlers = {
        closeTyping: () => { if (store.composeOpen()) store.composeOpen(false); /** @type {HTMLElement | null} */ (event.target)?.blur?.(); },
        help: () => store.shortcutsOpen(true),
        close: () => {
          if (store.shortcutsOpen()) store.shortcutsOpen(false);
          else if (store.composeOpen()) store.composeOpen(false);
          else if (store.settingsOpen()) store.settingsOpen(false);
          else if (store.deletePending()) store.deletePending(false);
          else if (store.selected()) store.selected(null);
        },
        compose: () => store.composeOpen(true),
        search: () => /** @type {HTMLInputElement | null} */ (document.querySelector("[data-search]"))?.focus(),
        next: () => store.selectRelative("next"),
        prev: () => store.selectRelative("prev"),
        delete: () => store.deletePending(true),
        reply: () => store.reply(false),
        replyAll: () => store.reply(true),
        forward: () => store.forward(),
        archive: () => store.messageAction("archive"),
        star: () => store.messageAction(store.selected().flagged ? "unstar" : "star"),
        unread: () => store.messageAction("unread"),
        spam: () => store.messageAction("spam"),
      };
      handlers[action]?.();
    },
  });
  effect(() => { shortcuts.enabled(store.preferences().shortcuts !== false); });
  window.addEventListener("pagehide", shortcuts.destroy);
  return shortcuts;
}

/** The store, once signed in and loaded; islands mount against it. */
export const ready = boot();

/** @param {string} name @param {(el: HTMLElement, store: Store) => void} mount */
const island = (name, mount) => registerIsland(name, (/** @type {HTMLElement} */ el) => { ready.then((store) => mount(el, store)); });

island("message-list", mountList);
island("message-reader", mountReader);
island("compose-dialog", mountCompose);
island("settings-drawer", mountSettings);

island("webmail-rail", (el, store) => {
  effect(() => {
    replace(el, [
      h("a", { class: "brand", href: "/", "aria-label": "AkurAI Mail home" }, h("span", { class: "mark" }, "A"), h("span", null, h("strong", null, "AkurAI Mail"), h("small", null, store.hostname))),
      h("nav", { "aria-label": "Primary navigation" },
        store.session.admin ? h("a", { href: "/" }, icon("SlidersHorizontal", { size: 17, strokeWidth: 1.8 }), " ", h("span", null, "Admin")) : null,
        h("a", { class: "active", href: "/webmail" }, icon("Mail", { size: 17, strokeWidth: 1.8 }), " ", h("span", null, "Webmail"))),
      h("div", { class: "rail-footer" },
        h("span", { class: "secure-state" }, icon("ShieldAlert", { size: 15, strokeWidth: 1.8 }), " Private mailbox"),
        h("a", { class: "sign-out", href: "/auth/logout" }, icon("ArrowLeft", { size: 17, strokeWidth: 1.8 }), " Sign out")),
    ]);
  });
});

island("sync-banner", (el, store) => {
  const { sync } = store;
  const retry = () => sync.retryNow(() => store.loadMailbox(store.folder()));
  effect(() => {
    const syncing = sync.status() === "syncing";
    const kind = sync.errorKind();
    replace(el, [
      h("div", { class: "sync-status", role: "status" },
        syncing ? icon("RefreshCw", { size: 14, class: "spin" }) : icon("Check", { size: 14 }), " ",
        h("span", null, syncing ? "Syncing…" : syncedLabel(sync.lastSyncAt())),
        h("button", { class: "sync-now", disabled: syncing, onclick: retry }, "Sync now")),
      kind && !sync.dismissed()
        ? h("div", { class: `sync-banner${kind === "auth-expired" ? " auth" : ""}`, role: "alert" },
          icon(kind === "offline" ? "WifiOff" : "TriangleAlert", { size: 16 }),
          h("span", { class: "sync-banner-text" }, SYNC_ERROR_MESSAGES[/** @type {keyof typeof SYNC_ERROR_MESSAGES} */ (kind)] ?? ""),
          kind === "auth-expired" ? h("button", { class: "sync-action", onclick: () => store.reauthenticate() }, "Sign in again") : h("button", { class: "sync-action", onclick: retry }, "Retry now"),
          h("button", { class: "icon-button", "aria-label": "Dismiss sync error", onclick: sync.dismiss }, icon("X", { size: 15 })))
        : null,
    ]);
  });
});

island("webmail-topbar", (el, store) => {
  const search = /** @type {HTMLInputElement} */ (h("input", { "data-search": "", placeholder: "Search this mailbox",
    oninput: () => store.query(search.value),
    onkeydown: (/** @type {KeyboardEvent} */ event) => { if (event.key === "Enter") store.search(search.value); } }));
  const actions = h("div", { class: "top-actions" });
  const clearSlot = h("span");
  replace(el, [
    h("div", { class: "identity" }, h("span", { class: "eyebrow" }, "Mailbox"), h("strong", null, store.current || "AkurAI Webmail")),
    h("label", { class: "search-control" }, icon("Search", { size: 18, strokeWidth: 1.8 }), h("span", { class: "sr" }, "Search mail"), search, clearSlot),
    actions,
  ]);
  effect(() => {
    if (search.value !== store.query()) search.value = store.query();
    replace(clearSlot, store.query() ? h("button", { class: "icon-button clear-search", "aria-label": "Clear search", onclick: () => store.search("") }, icon("X", { size: 15 })) : null);
  });
  effect(() => {
    const busy = store.busy();
    replace(actions, [
      h("button", { class: "icon-button", "aria-label": "Refresh mailbox", title: "Refresh mailbox", disabled: busy, onclick: () => store.loadMailbox(store.folder()) }, icon("RefreshCw", { size: 18, class: busy ? "spin" : "" })),
      h("button", { class: "icon-button", "aria-label": "Keyboard shortcuts", title: "Keyboard shortcuts (?)", onclick: () => store.shortcutsOpen(true) }, icon("Keyboard", { size: 18 })),
      h("button", { class: "icon-button settings-button", "aria-label": "Open settings", title: "Settings", onclick: () => { store.settingsOpen(true); store.loadScheduledSends(); } }, icon("Settings2", { size: 18 })),
      h("button", { class: "primary-action", onclick: () => store.composeOpen(true) }, icon("PenLine", { size: 17, strokeWidth: 2 }), " ", h("span", null, "Compose")),
    ]);
  });
});

island("mail-canvas", (el, store) => {
  effect(() => { el.classList.toggle("reading", !!store.selected()); });
  effect(() => { el.closest(".mail-app")?.setAttribute("aria-busy", String(store.busy())); });
});

island("webmail-overlays", (el, store) => {
  effect(() => {
    const pending = store.pendingSend();
    const notice = store.notice();
    replace(el, [
      pending ? h("div", { class: "toast undo-send-toast", role: "status", "aria-live": "polite" }, h("span", null, `Message sending in ${store.undoSecondsLeft()}s`), " ", h("button", { class: "subtle-button", onclick: () => store.undoSend() }, "Undo")) : null,
      notice ? h("div", { class: "toast", role: "status", "aria-live": "polite" }, h("span", null, notice), h("button", { "aria-label": "Dismiss notification", onclick: () => store.notice("") }, icon("X", { size: 16 }))) : null,
      store.deletePending()
        ? h("div", { class: "overlay confirm-overlay" },
          h("div", { class: "confirm-modal", role: "alertdialog", "aria-modal": "true", "aria-labelledby": "delete-title" },
            h("div", { class: "danger-icon" }, icon("Trash2", { size: 20 })),
            h("h2", { id: "delete-title" }, "Delete this message?"),
            h("p", null, "It will move to Trash. You can restore it there if needed."),
            h("div", null, h("button", { class: "subtle-button", onclick: () => store.deletePending(false) }, "Keep message"), h("button", { class: "danger-action", onclick: () => store.confirmDelete() }, "Move to Trash"))))
        : null,
      store.shortcutsOpen()
        ? h("div", { class: "overlay shortcuts-overlay", role: "presentation", onclick: () => store.shortcutsOpen(false) },
          h("div", { class: "shortcuts-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "shortcuts-title", tabindex: "-1", onclick: (/** @type {Event} */ event) => event.stopPropagation() },
            h("div", { class: "shortcuts-head" }, h("span", { id: "shortcuts-title" }, icon("Keyboard", { size: 18 }), " Keyboard shortcuts"), h("button", { class: "icon-button", "aria-label": "Close shortcuts", onclick: () => store.shortcutsOpen(false) }, icon("X", { size: 18 }))),
            h("dl", { class: "shortcuts-list" }, SHORTCUT_HELP.map((row) => h("div", null, h("dt", null, row.key.split(" / ").flatMap((key, i) => [i ? " / " : null, h("kbd", null, key)])), h("dd", null, row.label)))),
            h("p", { class: "shortcuts-note" }, "Shortcuts are disabled while typing in the composer or search field.")))
        : null,
    ]);
  });
});

// islands.js hydrated the document when it was imported, before these registrations existed;
// a second pass only touches islands that are not hydrated yet.
hydrate();