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();