Files
jcoffey-dev 136fc2540b
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m45s
Sieve playground: the server's interpreter in the browser
tools/sieve-playground builds the vendored sieve-rs 0.7.3 (vnd.inbuxa.*
names included) to WebAssembly, with a static page around it, for inbuxa
Admin's Debug button on a Sieve script: until now that button sent the
script to an outside site whose interpreter knows only upstream's
extension names, so any script that requires vnd.inbuxa.* failed to
compile there.

Ported from the playground in the upstream sieve-rs repository's web/
directory (commit 549a2d57f7), which targets sieve-rs 1.x:

- run.rs and handler.rs drive 0.7's event loop the way the server's
  delivery does, carrying on after a runtime error; llm_prompt compiles
  and answers false, as on a server without a model.
- The expression functions are the server's own modules, included by
  path; tests fail if the list, ApplyString or the few functions copied
  from misc.rs/unicode.rs stop matching crates/common.
- Built for wasm32-wasip1 rather than with wasm-bindgen: sieve-rs and
  mail-builder read the clock, which panics on wasm32-unknown-unknown.
  The page's worker answers the six WASI calls, giving the module the
  playground's current-time setting. shim/gethostname stands in for a
  crate with no WASI build. Nothing in vendor/ or crates/ changes.
- The page carries no upstream names or links; storage keys and the
  samples are renamed to match.

build.sh --admin <dir> stages the result into an inbuxa-admin checkout.
2026-10-09 01:13:53 -07:00

1019 lines
36 KiB
JavaScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import { Engine } from "./engine.js";
import { EML_ID, defineThemes, registerEml } from "./eml-language.js";
import { ResultView, h } from "./result.js";
import { SettingsDrawer, normalizeSettings } from "./settings.js";
import { LONG_LINK, clearShareHash, decodeSharedWorkspace, encodeShareUrl, hasSharedWorkspace, portable, validatePortable } from "./share.js";
import { LANGUAGE_ID, markersFor, registerSieve } from "./sieve-language.js";
import { dismissWelcome, isWelcomeDismissed, loadPrefs, newId, openStore, savePrefs } from "./store.js";
const $ = (selector) => document.querySelector(selector);
const COMPILE_DELAY = 300;
const SAVE_DELAY = 400;
const NARROW = window.matchMedia("(max-width: 900px)");
const DARK = window.matchMedia("(prefers-color-scheme: dark)");
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
const BLANK_SCRIPT = `require ["fileinto", "imap4flags"];
# Write your Sieve script here. Press Run to test it.
# Everything runs in your browser: nothing is sent to or stored on a server.
if header :contains "Subject" "hello" {
addflag "\\\\Flagged";
fileinto "INBOX";
}
`;
const BLANK_MESSAGE = `From: Sender <[email protected]>
To: Jane Doe <[email protected]>
Subject: Hello from the Sieve playground
Date: Mon, 14 Sep 2026 10:00:00 +0000
Message-ID: <[email protected]>
Content-Type: text/plain; charset="utf-8"
Hello! This is a test message.
`;
const state = {
monaco: null,
engine: null,
store: null,
prefs: loadPrefs(),
defaults: null,
capabilities: [],
samples: [],
workspace: null,
workspaces: [],
compileSeq: 0,
runSeq: 0,
diagnostics: [],
lastOutput: null,
lastRunMessage: "",
stale: true,
contentVersion: 0,
};
let scriptEditor;
let messageEditor;
let result;
let settingsDrawer;
let saveTimer;
let compileTimer;
function toast(message) {
const el = $("#toast");
el.textContent = message;
el.hidden = false;
clearTimeout(toast.timer);
toast.timer = setTimeout(() => (el.hidden = true), 2600);
}
document.addEventListener("toast", (event) => toast(event.detail));
window.addEventListener("unhandledrejection", (event) => {
if (event.reason?.name === "Canceled" && event.reason?.message === "Canceled") event.preventDefault();
});
function setStatus(id, text, stateName) {
const el = $(id);
el.textContent = text;
if (stateName) el.dataset.state = stateName;
else delete el.dataset.state;
}
function theme() {
return DARK.matches ? "playground-dark" : "playground-light";
}
function settings() {
return state.workspace?.settings || state.defaults;
}
function editorOptions(extra) {
return {
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
lineNumbersMinChars: 3,
scrollBeyondLastLine: false,
tabSize: 4,
insertSpaces: true,
fixedOverflowWidgets: true,
stickyScroll: { enabled: false },
padding: { top: 8 },
theme: theme(),
...extra,
};
}
function modelFor(item, language) {
if (!item.model || item.model.isDisposed()) {
const model = state.monaco.editor.createModel(item.source, language);
Object.defineProperty(item, "model", { value: model, writable: true, enumerable: false, configurable: true });
Object.defineProperty(item, "source", {
get: () => (model.isDisposed() ? "" : model.getValue()),
set: (value) => model.setValue(value),
enumerable: true,
configurable: true,
});
model.onDidChangeContent(() => onContentChanged(language === LANGUAGE_ID));
}
return item.model;
}
function disposeModels(workspace) {
if (!workspace) return;
for (const item of [...workspace.scripts, ...workspace.messages]) item.model?.dispose();
}
function serializable(workspace) {
return {
id: workspace.id,
name: workspace.name,
created: workspace.created,
updated: workspace.updated,
scripts: workspace.scripts.map(({ name, source }) => ({ name, source })),
messages: workspace.messages.map(({ name, source }) => ({ name, source })),
activeScript: workspace.activeScript,
activeMessage: workspace.activeMessage,
settings: workspace.settings,
};
}
function makeWorkspace({ name, scripts, messages, settings: overrides }) {
const now = Date.now();
return {
id: newId(),
name,
created: now,
updated: now,
scripts: scripts.length ? scripts : [{ name: "main", source: BLANK_SCRIPT }],
messages: messages.length ? messages : [{ name: "message.eml", source: BLANK_MESSAGE }],
activeScript: 0,
activeMessage: 0,
settings: normalizeSettings({ ...state.defaults, ...(overrides || {}) }, state.defaults),
};
}
function uniqueName(base) {
const names = new Set(state.workspaces.map((ws) => ws.name));
if (!names.has(base)) return base;
let n = 2;
while (names.has(`${base} ${n}`)) n++;
return `${base} ${n}`;
}
async function saveNow() {
clearTimeout(saveTimer);
saveTimer = null;
const ws = state.workspace;
if (!ws) return;
ws.updated = Date.now();
const data = serializable(ws);
const index = state.workspaces.findIndex((item) => item.id === ws.id);
if (index >= 0) state.workspaces[index] = data;
else state.workspaces.push(data);
try {
await state.store.put(data);
} catch (err) {
toast(`Could not save: ${err.message}`);
}
}
function saveSoon() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveNow, SAVE_DELAY);
}
function onContentChanged(isScript) {
state.contentVersion++;
saveSoon();
markStale();
if (isScript) compileSoon();
}
function markStale() {
state.stale = true;
$("#run-button").classList.add("stale");
}
async function loadSamples() {
try {
const response = await fetch(new URL("./samples/index.json", import.meta.url));
state.samples = await response.json();
} catch (_) {
state.samples = [];
}
}
async function sampleWorkspace(sample) {
const read = async (file) => {
const response = await fetch(new URL(`./samples/${file}`, import.meta.url));
if (!response.ok) throw new Error(`Could not load ${file}`);
return response.text();
};
const scripts = await Promise.all(sample.scripts.map(async (s) => ({ name: s.name, source: await read(s.file) })));
const messages = await Promise.all(sample.messages.map(async (m) => ({ name: m.name, source: await read(m.file) })));
return makeWorkspace({ name: uniqueName(sample.title), scripts, messages, settings: sample.settings });
}
async function openWorkspace(data, { run = true } = {}) {
if (state.workspace) await saveNow();
disposeModels(state.workspace);
const ws = {
...data,
scripts: data.scripts.map((s) => ({ ...s })),
messages: data.messages.map((m) => ({ ...m })),
settings: normalizeSettings({ ...state.defaults, ...(data.settings || {}) }, state.defaults),
};
ws.activeScript = Math.min(ws.activeScript || 0, ws.scripts.length - 1);
ws.activeMessage = Math.min(ws.activeMessage || 0, ws.messages.length - 1);
state.workspace = ws;
state.lastOutput = null;
state.prefs.lastWorkspace = ws.id;
savePrefs(state.prefs);
scriptEditor.setModel(modelFor(ws.scripts[ws.activeScript], LANGUAGE_ID));
messageEditor.setModel(modelFor(ws.messages[ws.activeMessage], EML_ID));
renderScriptTabs();
renderMessageTabs();
renderWorkspaceMenu();
renderEnvelope();
result.renderEmpty();
await saveNow();
if (run) await runScript();
else await compile();
}
function renderWorkspaceMenu() {
$("#workspace-name").textContent = state.workspace?.name || "Workspace";
const listEl = $("#workspace-list");
const sorted = [...state.workspaces].sort((a, b) => b.updated - a.updated);
listEl.replaceChildren(
...sorted.map((ws) =>
h("button", { type: "button", role: "menuitem", class: ws.id === state.workspace?.id ? "current" : "", onclick: async () => { closeMenus(); if (ws.id !== state.workspace.id) await openWorkspace(ws); } },
ws.name,
h("span", { class: "item-sub" }, `${ws.scripts.length} script${ws.scripts.length === 1 ? "" : "s"} · edited ${relativeTime(ws.updated)}`),
),
),
);
}
function renderExamplesMenu() {
$("#examples-list").replaceChildren(
...state.samples.map((sample) =>
h("button", { type: "button", role: "menuitem", onclick: async () => { closeMenus(); try { await openWorkspace(await sampleWorkspace(sample)); toast(`Opened "${sample.title}"`); } catch (err) { toast(err.message); } } },
sample.title,
h("span", { class: "item-sub" }, sample.description),
),
),
);
}
function relativeTime(ms) {
const minutes = Math.round((Date.now() - ms) / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} h ago`;
return new Date(ms).toLocaleDateString();
}
function tabButton({ label, active, error, closable, kind, onSelect, onClose, onRename }) {
const button = h("button", { type: "button", role: "tab", class: `tab${active ? " active" : ""}`, "aria-selected": active ? "true" : "false", title: onRename ? "Double-click to rename" : undefined, onclick: onSelect, ondblclick: onRename },
error ? h("span", { class: "dot", title: "Has errors" }) : null,
label,
kind ? h("span", { class: "tab-kind" }, kind) : null,
);
if (closable) {
const close = h("span", { class: "tab-close", role: "button", title: "Remove", onclick: (event) => { event.stopPropagation(); onClose(); } });
close.innerHTML = '<svg viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
button.append(close);
}
return button;
}
function renderScriptTabs() {
const ws = state.workspace;
const errors = new Set(state.diagnostics.map((d) => d.script));
$("#script-tabs").replaceChildren(
...ws.scripts.map((script, index) =>
tabButton({
label: script.name,
kind: index === 0 ? "main" : "include",
active: index === ws.activeScript,
error: errors.has(index),
closable: index > 0,
onSelect: () => selectScript(index),
onRename: index > 0 ? () => renameScript(index) : undefined,
onClose: () => removeScript(index),
}),
),
);
}
function renderMessageTabs() {
const ws = state.workspace;
$("#message-tabs").replaceChildren(
...ws.messages.map((message, index) =>
tabButton({
label: message.name,
active: index === ws.activeMessage,
closable: ws.messages.length > 1,
onSelect: () => selectMessage(index),
onRename: () => renameMessage(index),
onClose: () => removeMessage(index),
}),
),
);
}
function selectScript(index) {
const ws = state.workspace;
ws.activeScript = index;
scriptEditor.setModel(modelFor(ws.scripts[index], LANGUAGE_ID));
renderScriptTabs();
renderProblems();
saveSoon();
}
function selectMessage(index) {
const ws = state.workspace;
ws.activeMessage = index;
state.contentVersion++;
messageEditor.setModel(modelFor(ws.messages[index], EML_ID));
renderMessageTabs();
markStale();
saveSoon();
}
async function addScript(name, source) {
const ws = state.workspace;
const chosen = name ?? (await promptText("New include script", "Scripts include it by this name, for example include \"common\".", nextName(ws.scripts.map((s) => s.name), "common")));
if (!chosen) return;
ws.scripts.push({ name: chosen, source: source ?? 'require ["variables"];\n\n' });
selectScript(ws.scripts.length - 1);
compileSoon();
}
async function addMessage(name, source) {
const ws = state.workspace;
ws.messages.push({ name: name || nextName(ws.messages.map((m) => m.name), "message.eml"), source: source ?? BLANK_MESSAGE });
selectMessage(ws.messages.length - 1);
}
function nextName(existing, base) {
if (!existing.includes(base)) return base;
const dot = base.lastIndexOf(".");
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : "";
let n = 2;
while (existing.includes(`${stem}-${n}${ext}`)) n++;
return `${stem}-${n}${ext}`;
}
async function renameScript(index) {
const ws = state.workspace;
const name = await promptText("Rename include script", "Other scripts include it by this name.", ws.scripts[index].name);
if (!name) return;
ws.scripts[index].name = name;
renderScriptTabs();
saveSoon();
compileSoon();
}
async function renameMessage(index) {
const ws = state.workspace;
const name = await promptText("Rename message", "", ws.messages[index].name);
if (!name) return;
ws.messages[index].name = name;
renderMessageTabs();
saveSoon();
}
function removeScript(index) {
const ws = state.workspace;
if (index === 0) return;
if (ws.scripts[index].source.trim() && !confirm(`Remove the script "${ws.scripts[index].name}"?`)) return;
ws.scripts[index].model?.dispose();
ws.scripts.splice(index, 1);
const active = ws.activeScript >= index ? ws.activeScript - 1 : ws.activeScript;
selectScript(Math.min(Math.max(active, 0), ws.scripts.length - 1));
compileSoon();
}
function removeMessage(index) {
const ws = state.workspace;
if (ws.messages.length < 2) return;
if (!confirm(`Remove the message "${ws.messages[index].name}"?`)) return;
ws.messages[index].model?.dispose();
ws.messages.splice(index, 1);
const active = ws.activeMessage >= index ? ws.activeMessage - 1 : ws.activeMessage;
selectMessage(Math.min(Math.max(active, 0), ws.messages.length - 1));
}
function renderEnvelope() {
const s = settings();
$("#envelope-from").value = s.envelopeFrom || "";
$("#envelope-from").placeholder = "taken from the message";
$("#envelope-to").value = (s.envelopeTo || []).join(", ");
$("#envelope-to").placeholder = s.userAddress || "the user address";
$("#envelope-summary").textContent = `${s.envelopeFrom || "sender from message"} → ${(s.envelopeTo || []).join(", ") || s.userAddress}`;
}
function updateSettings(next) {
state.contentVersion++;
state.workspace.settings = normalizeSettings(next, state.defaults);
renderEnvelope();
saveSoon();
markStale();
compileSoon();
}
function request(extra = {}) {
const ws = state.workspace;
return {
scripts: ws.scripts.map(({ name, source }) => ({ name, source })),
message: ws.messages[ws.activeMessage]?.source || "",
settings: ws.settings,
seenIds: [],
now: Math.floor(Date.now() / 1000),
...extra,
};
}
function compileSoon() {
clearTimeout(compileTimer);
compileTimer = setTimeout(compile, COMPILE_DELAY);
}
async function compile() {
clearTimeout(compileTimer);
const seq = ++state.compileSeq;
const ws = state.workspace;
let output;
try {
output = await state.engine.compile({ scripts: ws.scripts.map(({ name, source }) => ({ name, source })), settings: ws.settings });
} catch (err) {
if (seq === state.compileSeq) setStatus("#compile-status", `Compiler unavailable: ${err.message}`, "error");
return;
}
if (seq !== state.compileSeq || ws !== state.workspace) return;
applyDiagnostics(output.diagnostics);
}
function applyDiagnostics(diagnostics) {
const ws = state.workspace;
state.diagnostics = diagnostics;
ws.scripts.forEach((script, index) => {
const model = modelFor(script, LANGUAGE_ID);
state.monaco.editor.setModelMarkers(model, "sieve", markersFor(state.monaco, model, diagnostics.filter((d) => d.script === index)));
});
renderScriptTabs();
renderProblems();
if (diagnostics.length) {
setStatus("#compile-status", `${diagnostics.length} error${diagnostics.length === 1 ? "" : "s"}`, "error");
} else {
setStatus("#compile-status", "No problems", "ready");
}
}
function renderProblems() {
const ws = state.workspace;
const el = $("#problems");
const runtime = state.lastOutput?.error && state.lastOutput.error.line > 0 ? [state.lastOutput.error] : [];
const items = [...state.diagnostics, ...runtime];
el.dataset.state = items.length ? "active" : "idle";
el.replaceChildren(
...items.map((d) => {
const scriptName = ws.scripts[d.script]?.name || "main";
const where = d.line > 0 ? `${scriptName}:${d.line}:${Math.max(d.column, 1)}` : `${scriptName}`;
const capability = /Undeclared capability '([^']+)'/.exec(d.message);
return h("button", { type: "button", class: `problem ${d.severity}`, onclick: () => revealLine(d.script, d.line, d.column) },
h("span", { class: "sev" }),
h("span", { class: "where" }, where),
h("span", {}, d.message, d.line > 0 ? "" : " (position unknown)"),
capability ? h("span", { class: "fix" }, "Quick fix available") : null,
);
}),
);
}
function revealLine(scriptIndex, line, column) {
if (scriptIndex !== state.workspace.activeScript) selectScript(scriptIndex);
showPanel("script");
const lineNumber = Math.max(1, line || 1);
scriptEditor.revealLineInCenter(lineNumber);
scriptEditor.setPosition({ lineNumber, column: Math.max(1, column || 1) });
scriptEditor.focus();
}
async function runScript({ redeliver = false } = {}) {
if (!state.workspace) return;
const button = $("#run-button");
button.disabled = true;
await saveNow();
const ws = state.workspace;
const version = state.contentVersion;
const seq = ++state.runSeq;
setStatus("#engine-status", "Running…", "busy");
const seenIds = redeliver && state.lastOutput ? state.lastOutput.duplicateIds : [];
const req = request({ seenIds });
const started = performance.now();
let output;
try {
output = await state.engine.run(req);
} catch (err) {
if (seq !== state.runSeq) return;
button.disabled = false;
if (ws !== state.workspace) return;
result.renderFailure(err.message);
setEngineStatus();
return;
}
if (seq !== state.runSeq) return;
const elapsed = performance.now() - started;
button.disabled = false;
setEngineStatus();
if (ws !== state.workspace) return;
if (version === state.contentVersion) {
button.classList.remove("stale");
state.stale = false;
}
applyDiagnostics(output.diagnostics);
ws.scripts.forEach((script, index) => {
const runtimeMarkers = output.error && output.error.line > 0 && output.error.script === index ? markersFor(state.monaco, modelFor(script, LANGUAGE_ID), [output.error]) : [];
state.monaco.editor.setModelMarkers(modelFor(script, LANGUAGE_ID), "sieve-runtime", runtimeMarkers);
});
if (output.diagnostics.length) {
state.lastOutput = null;
result.renderCompileErrors(output.diagnostics.length, () => revealLine(output.diagnostics[0].script, output.diagnostics[0].line, output.diagnostics[0].column));
setStatus("#run-stats", "");
return;
}
state.lastOutput = output;
state.lastRunMessage = req.message;
result.render(output);
renderProblems();
selectResultView("actions");
setStatus("#run-stats", `${elapsed < 1 ? "<1" : Math.round(elapsed)} ms${redeliver ? " · redelivery" : ""}`);
if (NARROW.matches) showPanel("result");
}
function selectResultView(view) {
for (const tab of document.querySelectorAll("#result-tabs .tab")) tab.classList.toggle("active", tab.dataset.view === view);
for (const pane of document.querySelectorAll(".result-view")) pane.classList.toggle("active", pane.dataset.view === view);
if (view === "messages") requestAnimationFrame(() => result.onShown());
if (NARROW.matches) showPanel("result");
}
function showPanel(name) {
for (const button of document.querySelectorAll(".mobile-tabs button")) button.classList.toggle("active", button.dataset.panel === name);
for (const panel of document.querySelectorAll(".panel")) panel.classList.toggle("active", panel.dataset.panel === name);
}
function closeMenus() {
for (const menu of document.querySelectorAll(".menu")) {
menu.querySelector(".menu-panel").hidden = true;
menu.querySelector(".menu-trigger").setAttribute("aria-expanded", "false");
}
}
function setupMenus() {
for (const menu of document.querySelectorAll(".menu")) {
const trigger = menu.querySelector(".menu-trigger");
const panel = menu.querySelector(".menu-panel");
trigger.addEventListener("click", (event) => {
event.stopPropagation();
const open = panel.hidden;
closeMenus();
if (open) {
if (menu.id === "workspace-menu") renderWorkspaceMenu();
panel.hidden = false;
trigger.setAttribute("aria-expanded", "true");
panel.querySelector("button")?.focus();
}
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenus();
});
document.addEventListener(
"pointerdown",
(event) => {
if (!event.target.closest(".menu")) closeMenus();
},
true,
);
document.addEventListener("click", async (event) => {
const target = event.target.closest("[data-action]");
if (!target) return;
closeMenus();
const action = ACTIONS[target.dataset.action];
if (action) await action();
});
}
const ACTIONS = {
"new-workspace": async () => {
const name = await promptText("New workspace", "A workspace holds scripts, test messages and settings.", uniqueName("Untitled"));
if (!name) return;
await openWorkspace(makeWorkspace({ name, scripts: [], messages: [] }));
},
"duplicate-workspace": async () => {
const ws = state.workspace;
await openWorkspace(makeWorkspace({ name: uniqueName(`${ws.name} copy`), scripts: serializable(ws).scripts, messages: serializable(ws).messages, settings: ws.settings }));
toast("Workspace duplicated");
},
"rename-workspace": async () => {
const name = await promptText("Rename workspace", "", state.workspace.name);
if (!name) return;
state.workspace.name = name;
await saveNow();
renderWorkspaceMenu();
},
"delete-workspace": async () => {
const ws = state.workspace;
if (!confirm(`Delete the workspace "${ws.name}"? This cannot be undone.`)) return;
await state.store.delete(ws.id);
state.workspaces = state.workspaces.filter((item) => item.id !== ws.id);
disposeModels(ws);
state.workspace = null;
const next = [...state.workspaces].sort((a, b) => b.updated - a.updated)[0];
if (next) await openWorkspace(next);
else await openWorkspace(makeWorkspace({ name: uniqueName("Untitled"), scripts: [], messages: [] }));
toast("Workspace deleted");
},
"export-workspace": async () => {
$("#share-dialog").close();
const data = portable(state.workspace, state.defaults);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const link = h("a", { href: URL.createObjectURL(blob), download: `${state.workspace.name.replace(/[^\w.-]+/g, "-")}.sieve-playground.json` });
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
},
"import-workspace": async () => {
$("#file-input").value = "";
$("#file-input").click();
},
};
async function importFile(file) {
const text = await file.text();
const lower = file.name.toLowerCase();
if (lower.endsWith(".json") || /^\s*\{/.test(text)) {
try {
const data = validatePortable(JSON.parse(text));
await openWorkspace(makeWorkspace({ ...data, name: uniqueName(data.name) }));
toast(`Imported "${data.name}"`);
} catch (err) {
toast(`Import failed: ${err.message}`);
}
} else if (lower.endsWith(".sieve") || lower.endsWith(".siv") || /^\s*(require|#|if\b)/.test(text)) {
const name = file.name.replace(/\.(sieve|siv|txt)$/i, "");
const ws = state.workspace;
if (ws.scripts.length === 1 && ws.scripts[0].source === BLANK_SCRIPT) {
ws.scripts[0].model.setValue(text);
} else if (confirm(`Replace the main script with "${file.name}"? Choose Cancel to add it as an include script instead.`)) {
selectScript(0);
ws.scripts[0].model.setValue(text);
} else {
await addScript(name, text);
}
toast(`Loaded ${file.name}`);
} else {
await addMessage(file.name, text);
toast(`Added ${file.name} as a test message`);
}
}
function setupFiles() {
$("#file-input").addEventListener("change", async (event) => {
for (const file of event.target.files) await importFile(file);
});
let depth = 0;
window.addEventListener("dragenter", (event) => {
if (!event.dataTransfer?.types.includes("Files")) return;
depth++;
document.body.classList.add("dragging-file");
});
window.addEventListener("dragleave", () => {
depth = Math.max(0, depth - 1);
if (depth === 0) document.body.classList.remove("dragging-file");
});
window.addEventListener("dragover", (event) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
});
window.addEventListener("drop", async (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
depth = 0;
document.body.classList.remove("dragging-file");
for (const file of event.dataTransfer.files) await importFile(file);
});
}
function setEngineStatus() {
if (state.engine.failed) setStatus("#engine-status", state.engine.failed, "error");
else setStatus("#engine-status", "Engine ready", "ready");
}
function promptText(title, text, value) {
const dialog = $("#prompt-dialog");
$("#prompt-title").textContent = title;
$("#prompt-text").textContent = text;
$("#prompt-text").hidden = !text;
const input = $("#prompt-input");
input.value = value || "";
return new Promise((resolve) => {
dialog.onclose = () => {
const name = input.value.trim();
resolve(dialog.returnValue === "ok" && name ? name.slice(0, 80) : null);
};
input.onkeydown = (event) => {
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault();
dialog.close("ok");
}
};
dialog.returnValue = "";
dialog.showModal();
input.select();
});
}
function setupShare() {
const dialog = $("#share-dialog");
const includeMessages = $("#share-messages");
const refresh = async () => {
try {
const url = await encodeShareUrl(portable(state.workspace, state.defaults, { includeMessages: includeMessages.checked }));
$("#share-url").value = url;
$("#share-privacy").hidden = !includeMessages.checked;
$("#share-size").textContent = url.length > LONG_LINK
? `This link is ${(url.length / 1024).toFixed(1)} KB long. Some chat apps and email clients cut long links: consider sharing without messages or exporting to a file.`
: `Link length: ${url.length.toLocaleString()} characters.`;
} catch (err) {
$("#share-url").value = "";
$("#share-size").textContent = `Could not create a link: ${err.message}`;
}
};
includeMessages.addEventListener("change", refresh);
$("#share-button").addEventListener("click", async () => {
await saveNow();
await refresh();
dialog.showModal();
$("#share-url").select();
});
$("#share-copy").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText($("#share-url").value);
$("#share-copy").textContent = "Copied";
setTimeout(() => ($("#share-copy").textContent = "Copy link"), 1500);
} catch (_) {
$("#share-url").select();
toast("Press Ctrl+C to copy the selected link");
}
});
}
function setupGutters() {
const workbench = $("#workbench");
let columns = Array.isArray(state.prefs.columns) && state.prefs.columns.length === 3 ? state.prefs.columns : [1.15, 0.95, 1];
const apply = () => {
["--col-a", "--col-b", "--col-c"].forEach((name, i) => workbench.style.setProperty(name, `minmax(220px, ${columns[i]}fr)`));
};
apply();
for (const gutter of document.querySelectorAll(".gutter")) {
const index = Number(gutter.dataset.gutter);
const resize = (deltaFraction) => {
const total = columns[index] + columns[index + 1];
const left = Math.min(Math.max(columns[index] + deltaFraction, total * 0.15), total * 0.85);
columns = columns.map((value, i) => (i === index ? left : i === index + 1 ? total - left : value));
apply();
};
gutter.addEventListener("pointerdown", (event) => {
event.preventDefault();
gutter.setPointerCapture(event.pointerId);
gutter.classList.add("dragging");
const sum = columns.reduce((a, b) => a + b, 0);
const width = workbench.clientWidth - 12;
let last = event.clientX;
const move = (e) => {
resize(((e.clientX - last) / width) * sum);
last = e.clientX;
};
const up = () => {
gutter.classList.remove("dragging");
gutter.removeEventListener("pointermove", move);
gutter.removeEventListener("pointerup", up);
state.prefs.columns = columns;
savePrefs(state.prefs);
};
gutter.addEventListener("pointermove", move);
gutter.addEventListener("pointerup", up);
});
gutter.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
resize((event.key === "ArrowLeft" ? -0.05 : 0.05) * columns.reduce((a, b) => a + b, 0));
state.prefs.columns = columns;
savePrefs(state.prefs);
});
gutter.addEventListener("dblclick", () => {
columns = [1.15, 0.95, 1];
apply();
state.prefs.columns = columns;
savePrefs(state.prefs);
});
}
}
function setupKeys() {
const runKey = state.monaco.KeyMod.CtrlCmd | state.monaco.KeyCode.Enter;
const saveKey = state.monaco.KeyMod.CtrlCmd | state.monaco.KeyCode.KeyS;
for (const editor of [scriptEditor, messageEditor]) {
editor.addAction({ id: "playground.run", label: "Playground: Run script", keybindings: [runKey], run: () => runScript() });
editor.addAction({ id: "playground.redeliver", label: "Playground: Run again as a redelivery", run: () => runScript({ redeliver: true }) });
editor.addAction({ id: "playground.save", label: "Playground: Save workspace", keybindings: [saveKey], run: async () => { await saveNow(); toast("Saved in this browser"); } });
editor.addAction({ id: "playground.share", label: "Playground: Share workspace", run: () => $("#share-button").click() });
editor.addAction({ id: "playground.settings", label: "Playground: Open settings", run: () => settingsDrawer.open() });
}
scriptEditor.addAction({ id: "playground.format", label: "Playground: Format script", run: (editor) => editor.getAction("editor.action.formatDocument")?.run() });
document.addEventListener("keydown", async (event) => {
const mod = IS_MAC ? event.metaKey : event.ctrlKey;
if (mod && event.key === "Enter") {
event.preventDefault();
await runScript();
} else if (mod && event.key.toLowerCase() === "s") {
event.preventDefault();
await saveNow();
toast("Saved in this browser");
}
});
$("#run-button").title = `Run (${IS_MAC ? "⌘" : "Ctrl"}+Enter)`;
}
function setupEnvelope() {
$("#envelope-from").addEventListener("change", (event) => updateSettings({ ...settings(), envelopeFrom: event.target.value.trim() }));
$("#envelope-to").addEventListener("change", (event) =>
updateSettings({ ...settings(), envelopeTo: event.target.value.split(",").map((s) => s.trim()).filter(Boolean) }),
);
}
function setupWelcome() {
const dialog = $("#welcome-dialog");
$("#privacy-link").addEventListener("click", () => dialog.showModal());
dialog.querySelector("button.primary").addEventListener("click", dismissWelcome);
dialog.addEventListener("cancel", dismissWelcome);
dialog.addEventListener("close", dismissWelcome);
if (!isWelcomeDismissed()) dialog.showModal();
}
async function initialWorkspace() {
if (hasSharedWorkspace()) {
try {
const data = await decodeSharedWorkspace();
clearShareHash();
const ws = makeWorkspace({ ...data, name: uniqueName(`${data.name} (shared)`) });
toast("Opened a shared workspace. Your own workspaces are in the workspace menu.");
return ws;
} catch (err) {
clearShareHash();
toast(`The shared link is invalid: ${err.message}`);
}
}
const last = state.workspaces.find((ws) => ws.id === state.prefs.lastWorkspace);
if (last) return last;
const recent = [...state.workspaces].sort((a, b) => b.updated - a.updated)[0];
if (recent) return recent;
const welcome = state.samples.find((sample) => sample.id === "welcome");
if (welcome) {
try {
return await sampleWorkspace(welcome);
} catch (_) {
return makeWorkspace({ name: "Untitled", scripts: [], messages: [] });
}
}
return makeWorkspace({ name: "Untitled", scripts: [], messages: [] });
}
function clearReloadFlag() {
try {
sessionStorage.removeItem("inbuxa-sieve.reloaded");
} catch (_) {
return;
}
}
async function boot() {
clearReloadFlag();
let monaco;
try {
monaco = await window.monacoReady;
} catch (err) {
setStatus("#engine-status", "Editor failed to load", "error");
console.error(err);
return;
}
state.monaco = monaco;
defineThemes(monaco);
registerEml(monaco);
registerSieve(monaco, () => settings() || {});
scriptEditor = monaco.editor.create($("#script-editor"), editorOptions({ language: LANGUAGE_ID, ariaLabel: "Sieve script", wordBasedSuggestions: "off", "semanticHighlighting.enabled": false }));
messageEditor = monaco.editor.create($("#message-editor"), editorOptions({ language: EML_ID, wordWrap: "on", ariaLabel: "Test message" }));
state.engine = new Engine();
state.engine.onStatus((kind, detail) => {
if (kind === "failed") setStatus("#engine-status", detail, "error");
else if (kind === "restarting" && detail !== "timeout") setStatus("#engine-status", "Restarting engine…", "busy");
});
try {
const [version, defaults, capabilities, store] = await Promise.all([
state.engine.version(),
state.engine.defaults(),
state.engine.capabilities(),
openStore(),
loadSamples(),
]);
state.defaults = defaults;
state.capabilities = capabilities;
state.store = store;
$("#engine-version").textContent = `v${version}`;
setStatus("#engine-status", "Engine ready", "ready");
} catch (err) {
setStatus("#engine-status", `Engine failed: ${err.message}`, "error");
console.error(err);
return;
}
state.workspaces = await state.store.list().catch(() => []);
result = new ResultView({
monaco,
root: $("#result-body"),
onRevealLine: revealLine,
onRunAgain: () => runScript({ redeliver: true }),
onSelectView: selectResultView,
getOriginal: () => state.lastRunMessage,
getTheme: theme,
});
settingsDrawer = new SettingsDrawer({
drawer: $("#settings-drawer"),
backdrop: $("#settings-backdrop"),
body: $("#settings-body"),
resetButton: $("#settings-reset"),
jsonButton: $("#settings-json"),
defaults: state.defaults,
capabilities: state.capabilities,
get: () => state.workspace.settings,
set: updateSettings,
});
$("#run-button").addEventListener("click", () => runScript());
$("#settings-button").addEventListener("click", () => settingsDrawer.open());
$("#add-script").addEventListener("click", () => addScript());
$("#add-message").addEventListener("click", () => addMessage());
for (const tab of document.querySelectorAll("#result-tabs .tab")) tab.addEventListener("click", () => selectResultView(tab.dataset.view));
for (const button of document.querySelectorAll(".mobile-tabs button")) button.addEventListener("click", () => showPanel(button.dataset.panel));
DARK.addEventListener("change", () => monaco.editor.setTheme(theme()));
window.addEventListener("hashchange", async () => {
if (!hasSharedWorkspace()) return;
await openWorkspace(await initialWorkspace());
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && saveTimer) saveNow();
});
window.addEventListener("pagehide", () => {
if (saveTimer) saveNow();
});
setupMenus();
setupShare();
setupFiles();
setupGutters();
setupKeys();
setupEnvelope();
setupWelcome();
renderExamplesMenu();
await openWorkspace(await initialWorkspace());
}
boot();