Menu
Public repository / 054e5cda0171bd13facff3c16a38f2048a1052cc
AkurAI-Mail / src/framework/store.js
Latest change 054e5cda0171bd13facff3c16a38f2048a1052cc - OLI-249: compose, settings, admin console and sign-in on the framework; SvelteKit removed by Ólafur Búi Ólafsson
// store.js — the webmail application state on the AkurAI-Framework signals runtime (plain ESM).
//
// A port of the state and actions that lived in src/routes/webmail/+page.svelte. Everything the
// browser provides (fetch, storage, navigation, timers) is injected so the store runs under
// `bun test` without a DOM; islands read signals and call actions, nothing else.
import { signal } from "./vendor/signals.js";
import { createSyncState } from "./sync-state.js";
import { classifySyncError } from "../lib/webmail/sync.js";
import { FILTER_CHIPS, filterParams, isPlainView, mailboxCacheKey } from "../lib/webmail/filters.js";
import { DRAFT_RECOVERY_KEY, UNDO_SEND_SECONDS, emptyCompose, forwardBody, hasDraftContent, parseSendAt, replyBody, secondsUntil, stashDraft, takeDraft } from "../lib/webmail/compose.js";
import { RECENT_RECIPIENTS_KEY, extractEmail, mergeRecent, recipientSuggestions } from "../lib/webmail/recipients.js";
import { scheduledSendView } from "../lib/webmail/session.js";
import { moveSelection } from "./shortcuts.js";
import { loginUrl } from "./api.js";
export const PAGE_SIZE = 40;
export const SMART_MAILBOXES = [
{ id: "unread", label: "Unread", icon: "Mail" },
{ id: "flagged", label: "Flagged", icon: "Star" },
{ id: "attachment", label: "Attachments", icon: "Paperclip" },
{ id: "today", label: "Today", icon: "Inbox" },
{ id: "vip", label: "VIP", icon: "ShieldAlert" },
{ id: "priority", label: "Needs attention", icon: "ShieldAlert" },
];
export const RULE_FIELDS = ["from", "to", "subject", "preview"];
export const RULE_ACTIONS = ["Archive", "Spam", "Trash"];
/** @typedef {{ getItem(key: string): string | null, setItem(key: string, value: string): void, removeItem(key: string): void }} StorageLike */
/** @typedef {{ email: string, admin: boolean }} Session */
/**
* @typedef {object} StoreOptions
* @property {Session} session
* @property {string} hostname
* @property {{ mailbox(params: Record<string, string>): Promise<Response>, webmail(body: Record<string, unknown>): Promise<Response> }} api
* @property {StorageLike} [storage] localStorage: mailbox cache, recent recipients
* @property {StorageLike} [sessionStorage] draft kept across re-authentication
* @property {(url: string) => void} [navigate]
* @property {string} [pathname]
* @property {(fn: () => void, ms: number) => any} [setInterval]
* @property {(id: any) => void} [clearInterval]
* @property {() => number} [now]
*/
/** @param {StorageLike | undefined} storage @param {string} key */
function readJson(storage, key) {
try {
const raw = storage?.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
/**
* @param {StoreOptions} options
*/
export function createStore(options) {
const { session, hostname, api, storage, sessionStorage, navigate = () => {}, pathname = "/webmail", now = Date.now } = options;
const setTimer = options.setInterval ?? ((fn, ms) => setInterval(fn, ms));
const clearTimer = options.clearInterval ?? ((id) => clearInterval(id));
const current = session.email;
// ── state ──
const webmailState = signal(/** @type {any} */ (null));
const folder = signal("Inbox");
const query = signal("");
const selected = signal(/** @type {any} */ (null));
const notice = signal("");
const busy = signal(false);
const page = signal(0);
const pageCursors = signal(/** @type {string[]} */ ([""]));
const activeChips = signal(new Set());
const smartMailbox = signal("");
const signals = signal(/** @type {Record<string, any>} */ ({}));
const messageSignals = signal(/** @type {Record<string, string>} */ ({}));
const pendingSend = signal(/** @type {any} */ (null));
const undoSecondsLeft = signal(0);
const compose = signal(emptyCompose());
const composeOpen = signal(false);
const composeVersion = signal(0);
const showCcBcc = signal(false);
const settingsOpen = signal(false);
const deletePending = signal(false);
const shortcutsOpen = signal(false);
const scheduledSends = signal(/** @type {import("../lib/webmail/session.js").ScheduledSendView[]} */ ([]));
const sendLaterAt = signal("");
const recentRecipients = signal(/** @type {string[]} */ (readJson(storage, RECENT_RECIPIENTS_KEY) ?? []));
const sync = createSyncState(now);
/** @type {any} */
let undoTimer;
// ── derived (plain functions: cheap, always fresh) ──
const folderList = () => webmailState()?.folders ?? [];
const messages = () => webmailState()?.messages ?? [];
const totalMessages = () => webmailState()?.total ?? messages().length;
const nextCursor = () => webmailState()?.nextCursor ?? "";
const contacts = () => webmailState()?.contacts ?? [];
const signatures = () => webmailState()?.signatures ?? {};
const rules = () => webmailState()?.rules ?? [];
const preferences = () => webmailState()?.preferences ?? {};
const vacation = () => webmailState()?.vacation ?? {};
const vipSenders = () => webmailState()?.vipSenders ?? [];
const activeSignature = () => signatures()[current] ?? "";
const pageCount = () => Math.max(1, Math.ceil(totalMessages() / PAGE_SIZE));
const unreadCount = () => folderList().reduce((/** @type {number} */ total, /** @type {any} */ item) => total + (item.unread ?? 0), 0);
const suggestions = () => recipientSuggestions(contacts(), recentRecipients());
const selectedDate = () => (selected()?.date ? new Date(selected().date) : null);
/** @param {any} message */
const isVipMessage = (message) => message.isVip ?? vipSenders().includes(extractEmail(message.from || ""));
/** @param {string} messageId */
const signalForMessage = (messageId) => {
const convId = messageSignals()[messageId];
return convId ? (signals()[convId] ?? null) : null;
};
const signalForSelected = () => (selected() ? signalForMessage(selected().id) : null);
const isFirstRun = () => folder() === "Inbox" && totalMessages() === 0 && !query();
const isNoResults = () => totalMessages() === 0 && !!query();
const isEmptyMailbox = () => totalMessages() === 0 && !query() && folder() !== "Inbox";
// ── loading ──
/**
* @param {string} [nextFolder]
* @param {string} [cursor]
* @param {boolean} [resetPage]
*/
async function loadMailbox(nextFolder = folder(), cursor = "", resetPage = true) {
busy(true);
sync.status("syncing");
/** @type {Response | undefined} */
let response;
try {
/** @type {Record<string, string>} */
const params = { folder: nextFolder, query: query(), limit: String(PAGE_SIZE), ...filterParams(smartMailbox(), activeChips()) };
if (cursor) params.cursor = cursor;
response = await api.mailbox(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())) storage?.setItem(mailboxCacheKey(current, nextFolder), JSON.stringify(result));
sync.reportSuccess();
await loadSignals(nextFolder);
} catch (error) {
const cached = readJson(storage, mailboxCacheKey(current, nextFolder));
if (cached) {
webmailState(cached);
folder(nextFolder);
notice("Offline — showing cached mailbox.");
}
sync.reportError(classifySyncError(error, response), () => loadMailbox(nextFolder, cursor, resetPage));
} finally {
busy(false);
}
}
/** @param {string} [forFolder] */
async function loadSignals(forFolder = folder()) {
try {
const response = await api.mailbox({ folder: forFolder, action: "signals" });
if (!response.ok) return;
const result = await response.json();
if (result.ok) {
signals(result.signals ?? {});
messageSignals(result.messageSignals ?? {});
}
} catch {
// Signals are non-critical; silently skip.
}
}
/**
* @param {string} convId
* @param {string} signalType
* @param {string} action
* @param {any} [value]
*/
async function correctSignal(convId, signalType, action, value) {
try {
const payload = JSON.stringify({ conversationId: convId, signalType, action, value });
const response = await api.mailbox({ folder: folder(), action: "signal-correct", payload });
const result = await response.json();
if (result.ok) await loadSignals(folder());
} catch {
notice("Could not save correction.");
}
}
/** @param {string} id */
async function readMessage(id) {
busy(true);
try {
const response = await api.webmail({ 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);
}
}
/**
* Posts a webmail action; shows the outcome as a notice and reloads the folder unless told not to.
* @param {Record<string, unknown>} body
* @param {boolean} [refresh]
* @returns {Promise<any>}
*/
async function post(body, refresh = true) {
busy(true);
notice("");
try {
const response = await api.webmail(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);
}
}
/** @param {string} messageAction */
const messageAction = (messageAction) => post({ action: "message", id: selected().id, messageAction });
// ── navigation ──
/** @param {string} name */
function openFolder(name) {
smartMailbox("");
return loadMailbox(name);
}
/** @param {string} id */
function toggleChip(id) {
const next = new Set(activeChips());
if (next.has(id)) next.delete(id);
else next.add(id);
activeChips(next);
return loadMailbox(folder());
}
function clearChips() {
activeChips(new Set());
return loadMailbox(folder());
}
/** @param {string} id */
function chooseSmartMailbox(id) {
smartMailbox(smartMailbox() === id ? "" : id);
return loadMailbox(folder());
}
async function nextPage() {
if (!nextCursor() || busy()) return;
const cursors = [...pageCursors()];
cursors[page() + 1] = nextCursor();
pageCursors(cursors);
page(page() + 1);
await loadMailbox(folder(), nextCursor(), false);
}
async function previousPage() {
if (page() === 0 || busy()) return;
page(page() - 1);
await loadMailbox(folder(), pageCursors()[page()] ?? "", false);
}
/** @param {"next" | "prev"} direction */
function selectRelative(direction) {
const list = messages();
const index = selected() ? list.findIndex((/** @type {any} */ message) => message.id === selected().id) : -1;
const next = moveSelection(index, list.length, direction);
if (next >= 0) return readMessage(list[next].id);
}
/** @param {string} [search] */
function search(search = query()) {
query(search);
return loadMailbox(folder());
}
// ── message actions ──
/** @param {string | undefined} from */
async function toggleVip(from) {
const email = from ? extractEmail(from) : "";
if (!email) return;
await post({ action: "config", kind: "vip", value: { email, remove: vipSenders().includes(email) } }, true);
}
/**
* @param {{ messages: { id: string }[] }} thread
* @param {string} action
*/
async function threadAction(thread, action) {
busy(true);
try {
for (const message of thread.messages) await api.webmail({ action: "message", id: message.id, messageAction: action });
await loadMailbox(folder(), "", false);
} catch {
notice("Could not apply thread action.");
} finally {
busy(false);
}
}
function confirmDelete() {
if (!selected()) return;
deletePending(false);
return messageAction("delete");
}
/** @param {{ index: number }} file */
async function downloadAttachment(file) {
const result = await post({ action: "attachment", id: selected().id, index: file.index }, false);
return result.ok ? { href: `data:${result.contentType};base64,${result.data}`, filename: result.filename } : null;
}
// ── compose ──
/** @param {any} entry */
function formatAddress(entry) {
if (!entry?.address) return "";
const name = String(entry.name ?? "").replaceAll('"', '\\"');
return name ? `"${name}" <${entry.address}>` : entry.address;
}
/** @param {boolean} [all] */
function reply(all = false) {
const message = selected();
if (!message) return;
const primaryEntry = message.replyToAddresses?.[0] ?? message.fromAddresses?.[0] ?? { address: message.from };
const primary = formatAddress(primaryEntry) || message.from;
const excluded = new Set([current.toLowerCase(), String(primaryEntry.address ?? "").toLowerCase()]);
const cc = all
? [...(message.toAddresses ?? []), ...(message.ccAddresses ?? [])]
.filter((/** @type {any} */ entry) => {
const address = String(entry.address ?? "").toLowerCase();
if (!address || excluded.has(address)) return false;
excluded.add(address);
return true;
})
.map(formatAddress)
.join(", ")
: "";
const subject = String(message.subject ?? "");
compose({ to: primary, cc, bcc: "", subject: subject.startsWith("Re:") ? subject : `Re: ${subject}`, body: replyBody(message), attachments: [] });
showCcBcc(all);
composeOpen(true);
composeVersion(composeVersion() + 1);
}
function forward() {
const message = selected();
if (!message) return;
const subject = String(message.subject ?? "");
compose({ to: "", cc: "", bcc: "", subject: subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`, body: forwardBody(message), attachments: [] });
showCcBcc(false);
composeOpen(true);
composeVersion(composeVersion() + 1);
}
function resetCompose() {
compose(emptyCompose());
showCcBcc(false);
composeVersion(composeVersion() + 1);
}
/** @param {string} used */
function rememberRecipients(used) {
recentRecipients(mergeRecent(recentRecipients(), used));
storage?.setItem(RECENT_RECIPIENTS_KEY, JSON.stringify(recentRecipients()));
}
/** @param {any} pending */
function startUndoCountdown(pending) {
clearTimer(undoTimer);
pendingSend(pending);
undoSecondsLeft(secondsUntil(pending.send_at, now()));
undoTimer = setTimer(() => {
undoSecondsLeft(secondsUntil(pending.send_at, now()));
if (undoSecondsLeft() <= 0) {
clearTimer(undoTimer);
pendingSend(null);
loadMailbox(folder());
}
}, 250);
}
/** Sends with an undo window: the server holds the message for UNDO_SEND_SECONDS. */
async function send() {
const draft = compose();
const result = await post({ action: "send", message: { ...draft, from: current, signature: activeSignature(), undoDelaySecs: UNDO_SEND_SECONDS } }, false);
if (result.ok) {
rememberRecipients([draft.to, draft.cc, draft.bcc].filter(Boolean).join(", "));
composeOpen(false);
resetCompose();
if (result.pendingSend) startUndoCountdown(result.pendingSend);
else loadMailbox(folder());
}
return result;
}
async function undoSend() {
if (!pendingSend()) return;
clearTimer(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(), now());
if (parsed.error !== undefined) {
notice(parsed.error);
return { ok: false };
}
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();
}
return result;
}
async function loadScheduledSends() {
const result = await post({ action: "scheduled-sends" }, false);
if (result.ok) scheduledSends((result.scheduledSends ?? []).map(scheduledSendView));
}
/** @param {string} scheduledId */
async function cancelScheduledSend(scheduledId) {
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");
}
/** Keeps any open draft across the IdP round trip, then signs in again. */
function reauthenticate() {
if (composeOpen() && hasDraftContent(compose()) && sessionStorage) stashDraft(sessionStorage, DRAFT_RECOVERY_KEY, compose());
navigate(loginUrl(pathname));
}
/** Restores a draft stashed before an expired session sent the user to sign in. */
function restoreDraft() {
const draft = sessionStorage ? takeDraft(sessionStorage, DRAFT_RECOVERY_KEY) : null;
if (draft) {
compose(draft);
composeOpen(true);
composeVersion(composeVersion() + 1);
}
return !!draft;
}
// ── settings ──
/**
* @param {"signature" | "vacation" | "contact" | "rule" | "preferences" | "vip"} kind
* @param {Record<string, unknown>} value
*/
async function saveConfig(kind, value) {
const result = await post({ action: "config", kind, value }, false);
if (result.ok) await loadMailbox(folder());
return result;
}
/** @param {string} theme */
const setTheme = (theme) => saveConfig("preferences", { ...preferences(), theme });
/** @param {boolean} enabled */
const setShortcutsEnabled = (enabled) => saveConfig("preferences", { ...preferences(), shortcuts: enabled });
const applyRules = () => post({ action: "apply-rules", folder: folder() });
function destroy() {
clearTimer(undoTimer);
}
return {
session, hostname, current, sync,
chipDefs: FILTER_CHIPS, smartMailboxes: SMART_MAILBOXES,
// signals
webmailState, folder, query, selected, notice, busy, page, pageCursors, activeChips, smartMailbox, signals, messageSignals,
pendingSend, undoSecondsLeft, compose, composeOpen, composeVersion, showCcBcc, settingsOpen, deletePending, shortcutsOpen,
scheduledSends, sendLaterAt, recentRecipients,
// derived
folderList, messages, totalMessages, nextCursor, contacts, signatures, rules, preferences, vacation, vipSenders, activeSignature,
pageCount, unreadCount, suggestions, selectedDate, isVipMessage, signalForMessage, signalForSelected, isFirstRun, isNoResults, isEmptyMailbox,
// actions
loadMailbox, loadSignals, correctSignal, readMessage, post, messageAction, openFolder, toggleChip, clearChips, chooseSmartMailbox,
nextPage, previousPage, selectRelative, search, toggleVip, threadAction, confirmDelete, downloadAttachment,
reply, forward, resetCompose, send, undoSend, sendLater, loadScheduledSends, cancelScheduledSend, saveDraft, reauthenticate, restoreDraft,
saveConfig, setTheme, setShortcutsEnabled, applyRules, destroy,
};
}