diff --git a/package-lock.json b/package-lock.json index b3e2aa4..cea7ce9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.8.0", "happy-dom": "^20.11.1", + "monaco-editor": "0.52.2", "prettier": "^3.9.6", "tailwindcss": "^4.3.3", "typescript": "~6.0.3", @@ -5099,6 +5100,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 6e22001..a90e2ee 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.8.0", "happy-dom": "^20.11.1", + "monaco-editor": "0.52.2", "prettier": "^3.9.6", "tailwindcss": "^4.3.3", "typescript": "~6.0.3", diff --git a/sieve-playground.ts b/sieve-playground.ts new file mode 100644 index 0000000..998fb02 --- /dev/null +++ b/sieve-playground.ts @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs LLC +// SPDX-License-Identifier: AGPL-3.0-only +/** + * inbuxa: ships the Sieve playground with the console. `sieve-playground/` + * holds the playground built by inbuxa-server's tools/sieve-playground (its + * page, scripts and the server's Sieve interpreter as WebAssembly); Monaco, + * the editor it loads, comes from node_modules. Both go into + * `sieve-playground//`, named after their contents: the server caches + * every file but the console's index.html for good, so a new build has to + * live at a new path. The console finds it through __SIEVE_PLAYGROUND__. + */ +import { createHash } from 'node:crypto' +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { Plugin } from 'vite' + +const ROOT = import.meta.dirname +const PLAYGROUND = path.join(ROOT, 'sieve-playground') +const MONACO = path.join(ROOT, 'node_modules', 'monaco-editor') + +const CONTENT_TYPES: Record = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.wasm': 'application/wasm', +} + +function walk(dir: string, prefix: string, out: Map) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const source = path.join(dir, entry.name) + const target = `${prefix}${entry.name}` + if (entry.isDirectory()) walk(source, `${target}/`, out) + else out.set(target, source) + } +} + +/** Published path → file on disk. */ +function collect(): Map { + const files = new Map() + walk(PLAYGROUND, '', files) + files.delete('SOURCE.md') + + const monaco = JSON.parse(readFileSync(path.join(MONACO, 'package.json'), 'utf8')) as { + version: string + } + const vendor = `vendor/monaco-${monaco.version}` + const index = readFileSync(path.join(PLAYGROUND, 'index.html'), 'utf8') + if (!index.includes(`./${vendor}/vs/loader.js`)) { + throw new Error( + `sieve-playground/index.html does not load ${vendor}: monaco-editor in package.json and the playground disagree.`, + ) + } + // Only the editor core, as the playground's own build did. + files.set(`${vendor}/vs/loader.js`, path.join(MONACO, 'min', 'vs', 'loader.js')) + walk(path.join(MONACO, 'min', 'vs', 'base'), `${vendor}/vs/base/`, files) + walk(path.join(MONACO, 'min', 'vs', 'editor'), `${vendor}/vs/editor/`, files) + files.set(`${vendor}/LICENSE`, path.join(MONACO, 'LICENSE')) + return files +} + +export function sievePlayground(): Plugin { + const files = collect() + const hash = createHash('sha256') + for (const name of [...files.keys()].sort()) { + hash.update(name).update('\0').update(readFileSync(files.get(name)!)).update('\0') + } + const dir = `sieve-playground/${hash.digest('hex').slice(0, 16)}` + + return { + name: 'inbuxa-sieve-playground', + config: () => ({ + define: { __SIEVE_PLAYGROUND__: JSON.stringify(`${dir}/index.html`) }, + }), + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const prefix = `/${dir}/` + const source = url.pathname.startsWith(prefix) + ? files.get(decodeURIComponent(url.pathname.slice(prefix.length))) + : undefined + if (!source) return next() + res.setHeader('Content-Type', CONTENT_TYPES[path.extname(source)] ?? 'application/octet-stream') + res.end(readFileSync(source)) + }) + }, + generateBundle() { + for (const [name, source] of files) { + this.emitFile({ type: 'asset', fileName: `${dir}/${name}`, source: readFileSync(source) }) + } + }, + } +} diff --git a/sieve-playground/SOURCE.md b/sieve-playground/SOURCE.md new file mode 100644 index 0000000..44aff16 --- /dev/null +++ b/sieve-playground/SOURCE.md @@ -0,0 +1,10 @@ +# Sieve playground + +Generated: do not edit. This directory is built by +`tools/sieve-playground/build.sh --admin` in inbuxa-server, from commit +`136fc2540b8009199cf54c5186784a4eae83613f`, with the server's vendored sieve-rs 0.7.3. Change the +source there, rebuild, and commit the result here. + +The WebAssembly module is that commit's `tools/sieve-playground` crate, built +for `wasm32-wasip1`; its source, and the site's, are in that directory under +AGPL-3.0-only. diff --git a/sieve-playground/app.js b/sieve-playground/app.js new file mode 100644 index 0000000..2eae273 --- /dev/null +++ b/sieve-playground/app.js @@ -0,0 +1,1018 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 +To: Jane Doe +Subject: Hello from the Sieve playground +Date: Mon, 14 Sep 2026 10:00:00 +0000 +Message-ID: +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 = ''; + 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(); diff --git a/sieve-playground/eml-language.js b/sieve-playground/eml-language.js new file mode 100644 index 0000000..b43f96c --- /dev/null +++ b/sieve-playground/eml-language.js @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +export const EML_ID = "eml"; + +class EmlState { + constructor(inHeaders, boundaries) { + this.inHeaders = inHeaders; + this.boundaries = boundaries; + } + + clone() { + return new EmlState(this.inHeaders, this.boundaries.slice()); + } + + equals(other) { + return ( + other instanceof EmlState && + other.inHeaders === this.inHeaders && + other.boundaries.length === this.boundaries.length && + other.boundaries.every((b, i) => b === this.boundaries[i]) + ); + } +} + +const HEADER = /^([A-Za-z0-9!#$%&'*+.^_`|~-]+)(\s*:)/; +const BOUNDARY_PARAM = /boundary\s*=\s*"?([^";\s]+)"?/i; + +export function registerEml(monaco) { + monaco.languages.register({ id: EML_ID, extensions: [".eml"], aliases: ["Email"] }); + monaco.languages.setTokensProvider(EML_ID, { + getInitialState: () => new EmlState(true, []), + tokenize(line, state) { + const next = state.clone(); + const tokens = []; + + if (next.boundaries.length) { + const trimmed = line.trimEnd(); + const hit = next.boundaries.find((b) => trimmed === `--${b}` || trimmed === `--${b}--`); + if (hit) { + tokens.push({ startIndex: 0, scopes: "keyword.boundary" }); + if (trimmed.endsWith("--") && trimmed === `--${hit}--`) { + next.boundaries = next.boundaries.filter((b) => b !== hit); + next.inHeaders = false; + } else { + next.inHeaders = true; + } + return { tokens, endState: next }; + } + } + + if (next.inHeaders) { + if (line.trim() === "") { + next.inHeaders = false; + return { tokens: [{ startIndex: 0, scopes: "" }], endState: next }; + } + const boundary = BOUNDARY_PARAM.exec(line); + if (boundary && !next.boundaries.includes(boundary[1])) next.boundaries.push(boundary[1]); + const header = HEADER.exec(line); + if (header) { + tokens.push({ startIndex: 0, scopes: "attribute.name.header" }); + tokens.push({ startIndex: header[1].length, scopes: "delimiter" }); + tokens.push({ startIndex: header[0].length, scopes: "string.header" }); + } else if (/^\s/.test(line)) { + tokens.push({ startIndex: 0, scopes: "string.header" }); + } else { + tokens.push({ startIndex: 0, scopes: "invalid" }); + } + return { tokens, endState: next }; + } + + tokens.push({ startIndex: 0, scopes: /^>/.test(line) ? "comment.quote" : "" }); + return { tokens, endState: next }; + }, + }); +} + +export function defineThemes(monaco) { + const rules = (dark) => [ + { token: "keyword", foreground: dark ? "c792ea" : "7a3eb1", fontStyle: "bold" }, + { token: "type", foreground: dark ? "82aaff" : "1f5fbf" }, + { token: "attribute.name", foreground: dark ? "f78c6c" : "b35300" }, + { token: "variable", foreground: dark ? "ffcb6b" : "a0680a", fontStyle: "bold" }, + { token: "string", foreground: dark ? "c3e88d" : "2f7d32" }, + { token: "string.heredoc", foreground: dark ? "89ddff" : "0a7f94", fontStyle: "bold" }, + { token: "string.escape", foreground: dark ? "89ddff" : "0a7f94" }, + { token: "number", foreground: dark ? "f78c6c" : "b35300" }, + { token: "comment", foreground: dark ? "6a7390" : "7d8595", fontStyle: "italic" }, + { token: "attribute.name.header", foreground: dark ? "82aaff" : "1f5fbf", fontStyle: "bold" }, + { token: "string.header", foreground: dark ? "d7dae0" : "3a3f4b" }, + { token: "keyword.boundary", foreground: dark ? "c792ea" : "7a3eb1" }, + { token: "comment.quote", foreground: dark ? "6a7390" : "7d8595" }, + { token: "invalid", foreground: dark ? "ff7b82" : "c6313a" }, + ]; + monaco.editor.defineTheme("playground-light", { + base: "vs", + inherit: true, + rules: rules(false), + colors: { "editor.background": "#ffffff", "editorGutter.background": "#ffffff" }, + }); + monaco.editor.defineTheme("playground-dark", { + base: "vs-dark", + inherit: true, + rules: rules(true), + colors: { "editor.background": "#1b1d24", "editorGutter.background": "#1b1d24" }, + }); +} diff --git a/sieve-playground/engine.js b/sieve-playground/engine.js new file mode 100644 index 0000000..4fd3c2e --- /dev/null +++ b/sieve-playground/engine.js @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +const RUN_TIMEOUT_MS = 8000; +const MAX_RESTARTS = 3; +const RESTART_WINDOW_MS = 60000; +const BASE_BACKOFF_MS = 500; + +export class EngineUnavailable extends Error {} + +export class Engine { + constructor() { + this.pending = new Map(); + this.nextId = 1; + this.listeners = new Set(); + this.restarts = []; + this.failed = null; + this.spawn(); + } + + onStatus(listener) { + this.listeners.add(listener); + } + + emit(state, detail) { + for (const listener of this.listeners) listener(state, detail); + } + + spawn() { + const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" }); + this.worker = worker; + this.ready = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + this.ready.catch(() => {}); + worker.onmessage = (event) => { + if (worker !== this.worker) return; + const data = event.data; + if (data.type === "ready") { + this.resolveReady(); + } else if (data.type === "init-error") { + this.crash(`Engine failed to load: ${data.error}`, { loadFailure: true }); + } else { + this.settle(data); + } + }; + worker.onerror = (event) => { + event.preventDefault(); + if (worker === this.worker) { + this.crash(`Engine failed to load: ${event.message || "the worker script could not be loaded"}`, { loadFailure: true }); + } + }; + } + + crash(reason, { loadFailure = false } = {}) { + this.worker.terminate(); + this.rejectReady(new EngineUnavailable(reason)); + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(new EngineUnavailable(reason)); + } + this.pending.clear(); + + let delay = 0; + if (loadFailure) { + const now = Date.now(); + this.restarts = this.restarts.filter((time) => now - time < RESTART_WINDOW_MS); + if (this.restarts.length >= MAX_RESTARTS) { + this.failed = reason; + this.emit("failed", reason); + return; + } + this.restarts.push(now); + delay = BASE_BACKOFF_MS * 2 ** (this.restarts.length - 1); + } + this.emit("restarting", reason); + const failedReady = this.ready; + this.ready = new Promise((resolve, reject) => { + setTimeout(() => { + this.spawn(); + this.ready.then(resolve, reject); + }, delay); + }); + this.ready.catch(() => {}); + failedReady.catch(() => {}); + } + + settle({ id, result, error, fatal }) { + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + clearTimeout(entry.timer); + if (error) { + entry.reject(new Error(error)); + if (fatal) this.crash(`The engine crashed: ${error}`); + } else { + entry.resolve(result); + } + } + + async call(op, payload, timeout) { + if (this.failed) throw new EngineUnavailable(this.failed); + await this.ready; + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = timeout + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`The script did not finish within ${timeout / 1000} seconds and was stopped.`)); + this.crash("timeout"); + }, timeout) + : null; + this.pending.set(id, { resolve, reject, timer }); + this.worker.postMessage({ id, op, payload }); + }); + } + + version() { + return this.call("version"); + } + + capabilities() { + return this.call("capabilities"); + } + + defaults() { + return this.call("defaults"); + } + + compile(request) { + return this.call("compile", request, RUN_TIMEOUT_MS); + } + + run(request) { + return this.call("run", request, RUN_TIMEOUT_MS); + } +} diff --git a/sieve-playground/favicon.svg b/sieve-playground/favicon.svg new file mode 100644 index 0000000..95fabf8 --- /dev/null +++ b/sieve-playground/favicon.svg @@ -0,0 +1 @@ + diff --git a/sieve-playground/index.html b/sieve-playground/index.html new file mode 100644 index 0000000..dd578f0 --- /dev/null +++ b/sieve-playground/index.html @@ -0,0 +1,260 @@ + + + + + + + + Sieve playground · inbuxa Admin + + + + + + + + +
+ + + Sieve playground + + inbuxa Admin + +
+ + + + + + + + + +
+
+ + + +
+
+
+ 1Script +
+ +
+
+
+
+ + + +
+
+ 2Message +
+ +
+
+
Drop an .eml or .sieve file
+
+
+ + Envelope + + +
+ + + + +
+
+
+ + + +
+
+ 3Result +
+ + + +
+
+
+
+
+
+
+
+
+ +
+ Loading engine… + + + + + sieve-rs +
+ + + + + +
+

Sieve playground

+

+ Write and test Sieve email filters against real messages, with the same Sieve interpreter your + inbuxa server runs. +

+
    +
  • + + Everything runs in your browser. Scripts are compiled and executed locally with WebAssembly. +
  • +
  • + + Nothing is stored on a server. Your scripts, messages and settings are saved only in this browser. +
  • +
  • + + Sharing is up to you. A share link carries the workspace inside the link itself; nothing is uploaded. +
  • +
+
+ + +
+
+
+ + +
+

Share workspace

+

+ The workspace is compressed into the link itself. Nothing is uploaded: whoever opens the link gets their own copy. +

+ +

Messages may contain personal information. Check them before sharing.

+ +

+
+ + + +
+
+
+ + +
+

+

+ +
+ + + +
+
+
+ + + + + + + + + + + diff --git a/sieve-playground/result.js b/sieve-playground/result.js new file mode 100644 index 0000000..a2a1013 --- /dev/null +++ b/sieve-playground/result.js @@ -0,0 +1,434 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +import { EML_ID } from "./eml-language.js"; + +export function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [key, value] of Object.entries(attrs || {})) { + if (value === undefined || value === null || value === false) continue; + if (key === "class") el.className = value; + else if (key === "dataset") Object.assign(el.dataset, value); + else if (key.startsWith("on")) el.addEventListener(key.slice(2), value); + else el.setAttribute(key, value === true ? "" : value); + } + for (const child of children.flat()) { + if (child === null || child === undefined || child === false) continue; + el.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return el; +} + +const ICONS = { + keep: '', + fileinto: '', + discard: '', + reject: '', + redirect: '', + vacation: '', + notification: '', + notify: '', + created: '', + envelope: '', + error: '', +}; + +function icon(kind) { + const span = h("span", { class: "event-icon", "aria-hidden": "true" }); + span.innerHTML = `${ICONS[kind] || ICONS.created}`; + return span; +} + +const ROLE_BY_KIND = { + keep: "Delivered", + fileinto: "Delivered", + vacation: "Vacation reply", + redirect: "Redirected", + notification: "Notification", +}; + +export function messageRoles(output) { + const roles = new Map(); + for (const event of output.events) { + if (!event.messageId || event.kind === "created") continue; + const role = ROLE_BY_KIND[event.kind]; + if (!role) continue; + const set = roles.get(event.messageId) || new Set(); + set.add(role); + roles.set(event.messageId, set); + } + return roles; +} + +function roleLabel(roles, id) { + const set = roles.get(id); + if (!set || set.size === 0) return "Generated"; + return [...set].join(" · "); +} + +function summarize(output) { + const lines = []; + const events = output.events; + const byKind = (kind) => events.filter((event) => event.kind === kind); + const flags = (event) => event.detail.find((d) => d.label === "flags")?.value; + const withFlags = (event) => (flags(event) ? [" with flags ", h("b", {}, flags(event))] : []); + + for (const event of byKind("fileinto")) { + const folder = event.summary.replace(/^File into /, ""); + lines.push(["Filed into ", h("b", {}, folder), ...withFlags(event)]); + } + for (const event of byKind("keep")) { + lines.push([event.afterError ? "Kept in " : "Delivered to ", h("b", {}, "INBOX"), ...withFlags(event), event.afterError ? " because the script failed" : ""]); + } + for (const event of byKind("discard")) lines.push([h("b", {}, "Discarded"), " without notifying anyone"]); + for (const event of byKind("reject")) { + const reason = event.detail.find((d) => d.label === "reason")?.value || ""; + lines.push([h("b", {}, "Rejected"), reason ? `: "${reason}"` : ""]); + } + const redirects = byKind("redirect").map((event) => event.summary.replace(/^Redirect to /, "")); + if (redirects.length) lines.push(["Redirected to ", h("b", {}, redirects.join(", "))]); + for (const event of byKind("vacation")) lines.push(["Vacation reply sent to ", h("b", {}, event.summary.replace(/^Send a vacation reply to /, ""))]); + for (const event of byKind("notification")) lines.push(["Notification sent to ", h("b", {}, event.summary.replace(/^Send a notification to /, ""))]); + for (const event of byKind("notify")) lines.push([event.summary]); + if (output.messageChanged) lines.push(["The message was ", h("b", {}, "modified"), " by the script"]); + if (lines.length === 0) lines.push(["No actions were taken"]); + return lines; +} + +export class ResultView { + constructor({ monaco, root, onRevealLine, onRunAgain, onSelectView, getOriginal, getTheme }) { + this.monaco = monaco; + this.actionsView = root.querySelector("#actions-view"); + this.messagesView = root.querySelector("#messages-view"); + this.variablesView = root.querySelector("#variables-view"); + this.countEl = document.querySelector("#messages-count"); + this.onRevealLine = onRevealLine; + this.onRunAgain = onRunAgain; + this.onSelectView = onSelectView; + this.getOriginal = getOriginal; + this.getTheme = getTheme; + this.output = null; + this.selected = null; + this.mode = "preview"; + this.sourceEditor = null; + this.diffEditor = null; + this.renderEmpty(); + } + + renderEmpty() { + this.output = null; + this.countEl.textContent = ""; + this.actionsView.replaceChildren( + h("div", { class: "empty" }, h("div", {}, h("strong", {}, "Nothing has run yet"), "Press ", h("kbd", {}, "Run"), " or ", h("kbd", {}, navigator.platform.includes("Mac") ? "⌘ Enter" : "Ctrl Enter"), " to filter the message through the script.")), + ); + this.messagesView.replaceChildren(h("div", { class: "empty" }, h("div", {}, h("strong", {}, "No messages yet"), "Modified messages, vacation replies and notifications appear here after a run."))); + this.variablesView.replaceChildren(h("div", { class: "empty" }, h("div", {}, h("strong", {}, "No variables yet"), "Global variables and duplicate ids appear here after a run."))); + this.disposeEditors(); + } + + renderFailure(message) { + this.renderEmpty(); + this.actionsView.replaceChildren(h("div", { class: "banner" }, message)); + } + + renderCompileErrors(count, onShow) { + this.renderEmpty(); + this.actionsView.replaceChildren( + h("div", { class: "banner" }, `The script has ${count} error${count === 1 ? "" : "s"} and cannot run.`, h("button", { type: "button", onclick: onShow }, "Show")), + ); + } + + render(output) { + this.output = output; + this.roles = messageRoles(output); + this.countEl.textContent = output.messages.length ? String(output.messages.length) : ""; + this.renderActions(); + if (!output.messages.some((message) => message.id === this.selected)) { + const delivered = output.messages.find((message) => roleLabel(this.roles, message.id).includes("Delivered")); + this.selected = (delivered || output.messages[0])?.id ?? null; + } + this.renderMessages(); + this.renderVariables(); + } + + renderActions() { + const output = this.output; + const nodes = []; + + if (output.error) { + const where = output.error.line > 0 ? ` (line ${output.error.line})` : ""; + nodes.push( + h("div", { class: "banner warn" }, h("span", {}, h("b", {}, "Runtime error: "), output.error.message, where), + output.error.line > 0 ? h("button", { type: "button", onclick: () => this.onRevealLine(output.error.script, output.error.line, output.error.column) }, "Go to line") : null), + ); + } + + nodes.push( + h("div", { class: "outcome" }, + h("div", { class: "outcome-title" }, "Outcome"), + h("div", { class: "outcome-lines" }, summarize(output).map((parts) => h("div", {}, ...parts))), + h("div", { class: "outcome-actions" }, + output.messages.length ? h("button", { class: "btn", type: "button", onclick: () => this.onSelectView("messages") }, `View ${output.messages.length} message${output.messages.length === 1 ? "" : "s"}`) : null, + h("button", { class: "btn", type: "button", title: "Run again with the duplicate ids and vacation history from this run", onclick: () => this.onRunAgain() }, "Run again as a redelivery"), + ), + ), + ); + + const list = h("ol", { class: "timeline" }); + for (const event of output.events) { + const chips = event.detail.map((detail) => + h("span", { class: `chip${detail.label === "warning" ? " warn" : ""}` }, h("span", { class: "k" }, detail.label), detail.value), + ); + if (event.isFinal) chips.unshift(h("span", { class: "chip tag" }, event.afterError ? "implicit keep" : "final action")); + if (event.messageId > 0 && event.kind !== "created") { + chips.push(h("button", { class: "chip chip-link", type: "button", onclick: () => this.showMessage(event.messageId) }, `message #${event.messageId}`)); + } + if (event.kind === "created") { + chips.push(h("button", { class: "chip chip-link", type: "button", onclick: () => this.showMessage(event.messageId) }, "view")); + } + list.append( + h("li", { class: `event${event.isFinal ? " final" : ""}${event.afterError || event.kind === "created" ? " muted" : ""}`, dataset: { kind: event.kind } }, + icon(event.kind), + h("div", { class: "event-card" }, h("div", { class: "event-summary" }, event.summary), chips.length ? h("div", { class: "event-meta" }, chips) : null), + ), + ); + } + nodes.push(list); + this.actionsView.replaceChildren(...nodes); + } + + showMessage(id) { + this.selected = id; + this.renderMessages(); + this.onSelectView("messages"); + } + + renderMessages() { + const output = this.output; + this.disposeEditors(); + if (!output || output.messages.length === 0) { + this.messagesView.replaceChildren( + h("div", { class: "empty" }, h("div", {}, h("strong", {}, "No messages were generated"), "The original message was not modified and no replies or notifications were sent.")), + ); + return; + } + + const list = h("div", { class: "message-list" }, + output.messages.map((message) => + h("button", { type: "button", class: `message-pill${message.id === this.selected ? " active" : ""}`, onclick: () => { this.selected = message.id; this.renderMessages(); } }, + h("span", { class: "role" }, `#${message.id} ${roleLabel(this.roles, message.id)}`), + h("span", { class: "subj" }, message.subject || "(no subject)"), + ), + ), + ); + + const message = output.messages.find((m) => m.id === this.selected) || output.messages[0]; + const canDiff = roleLabel(this.roles, message.id).includes("Delivered") || roleLabel(this.roles, message.id).includes("Redirected"); + if (this.mode === "diff" && !canDiff) this.mode = "preview"; + + const setMode = (mode) => { this.mode = mode; this.renderMessages(); }; + const toolbar = h("div", { class: "message-toolbar" }, + h("div", { class: "segmented", role: "tablist" }, + h("button", { type: "button", class: this.mode === "preview" ? "active" : "", onclick: () => setMode("preview") }, "Preview"), + h("button", { type: "button", class: this.mode === "source" ? "active" : "", onclick: () => setMode("source") }, "Source"), + h("button", { type: "button", class: this.mode === "diff" ? "active" : "", disabled: !canDiff, title: canDiff ? "Compare with the input message" : "Only available for the delivered message", onclick: () => setMode("diff") }, "Changes"), + ), + h("span", { class: "toolbar-gap" }), + h("span", { class: "item-sub" }, `${message.size.toLocaleString()} bytes`), + h("button", { class: "btn ghost", type: "button", onclick: () => copyText(message.raw) }, "Copy"), + ); + + const frame = h("div", { class: `message-frame${this.mode === "preview" ? "" : " code"}` }); + this.messagesView.replaceChildren(list, toolbar, frame); + + if (this.mode === "preview") { + frame.append(this.preview(message)); + this.pendingMount = null; + } else { + this.pendingMount = { frame, message, mode: this.mode }; + if (this.messagesView.classList.contains("active")) this.mountEditor(); + } + } + + onShown() { + if (this.pendingMount) this.mountEditor(); + this.layout(); + } + + mountEditor() { + const { frame, message, mode } = this.pendingMount; + this.pendingMount = null; + if (!frame.isConnected) return; + const text = message.raw.replace(/\r\n/g, "\n"); + if (mode === "source") { + if (!this.sourceHost) { + this.sourceHost = h("div", { class: "viewer-host" }); + this.sourceEditor = this.monaco.editor.create(this.sourceHost, viewerOptions()); + } + frame.append(this.sourceHost); + this.swapModels(() => this.sourceEditor.setModel(this.monaco.editor.createModel(text, EML_ID))); + this.sourceEditor.layout(); + } else { + if (!this.diffHost) { + this.diffHost = h("div", { class: "viewer-host" }); + this.diffEditor = this.monaco.editor.createDiffEditor(this.diffHost, { ...viewerOptions(), enableSplitViewResizing: false }); + } + frame.append(this.diffHost); + this.diffEditor.updateOptions({ renderSideBySide: frame.clientWidth > 700 }); + this.swapModels(() => + this.diffEditor.setModel({ + original: this.monaco.editor.createModel(this.getOriginal().replace(/\r\n/g, "\n"), EML_ID), + modified: this.monaco.editor.createModel(text, EML_ID), + }), + ); + this.diffEditor.layout(); + } + } + + swapModels(attach) { + const previous = this.viewerModels || []; + attach(); + const source = this.sourceEditor?.getModel(); + const diff = this.diffEditor?.getModel(); + this.viewerModels = [source, diff?.original, diff?.modified].filter(Boolean); + const keep = new Set(this.viewerModels); + for (const model of previous) if (!keep.has(model)) model.dispose(); + } + + preview(message) { + const container = h("div", { class: "preview" }); + const original = parseHeaderNames(this.getOriginal()); + const main = ["From", "To", "Cc", "Subject", "Date"]; + let showAll = false; + const table = h("table", { class: "headers-table" }); + const drawHeaders = () => { + const rows = message.headers + .filter((header) => showAll || main.some((name) => name.toLowerCase() === header.name.toLowerCase())) + .map((header) => { + const isNew = !original.has(`${header.name.toLowerCase()}:${header.value}`); + return h("tr", { class: isNew && message.id > 0 && roleLabel(this.roles, message.id).includes("Delivered") ? "changed" : "" }, h("th", {}, header.name), h("td", {}, header.value)); + }); + table.replaceChildren(...rows); + toggle.textContent = showAll ? "Show fewer headers" : `Show all ${message.headers.length} headers`; + }; + const toggle = h("button", { type: "button", class: "headers-toggle", onclick: () => { showAll = !showAll; drawHeaders(); } }); + drawHeaders(); + container.append(table, toggle); + + if (message.html && !message.text) { + container.append(htmlFrame(message.html)); + } else if (message.html) { + let showHtml = false; + const body = h("div", {}); + const switcher = h("div", { class: "segmented" }); + const draw = () => { + switcher.replaceChildren( + h("button", { type: "button", class: showHtml ? "" : "active", onclick: () => { showHtml = false; draw(); } }, "Text"), + h("button", { type: "button", class: showHtml ? "active" : "", onclick: () => { showHtml = true; draw(); } }, "HTML"), + ); + body.replaceChildren(showHtml ? htmlFrame(message.html) : h("pre", { class: "body-text" }, message.text)); + }; + draw(); + container.append(h("div", { style: "margin-bottom:8px" }, switcher), body); + } else { + container.append(h("pre", { class: "body-text" }, message.text || "(empty body)")); + } + + if (message.attachments.length) { + container.append( + h("div", { class: "attachments" }, + message.attachments.map((a) => h("span", { class: "attachment" }, a.name, h("span", { class: "muted" }, a.contentType), h("span", { class: "muted" }, formatSize(a.size)))), + ), + ); + } + return container; + } + + renderVariables() { + const output = this.output; + const nodes = []; + if (output.globalVariables.length) { + nodes.push( + h("table", { class: "vars" }, + h("thead", {}, h("tr", {}, h("th", {}, "Global variable"), h("th", {}, "Value"))), + h("tbody", {}, output.globalVariables.map((v) => h("tr", {}, h("td", {}, v.name), h("td", {}, v.value)))), + ), + ); + } else { + nodes.push(h("div", { class: "vars-note" }, "No global variables were set. Declare a variable with ", h("code", {}, 'global ["name"];'), " (requires \"include\") to inspect its final value here.")); + } + nodes.push( + h("table", { class: "vars" }, + h("thead", {}, h("tr", {}, h("th", {}, "Duplicate and vacation ids seen"))), + h("tbody", {}, output.duplicateIds.length ? output.duplicateIds.map((id) => h("tr", {}, h("td", {}, id))) : h("tr", {}, h("td", {}, "None"))), + ), + ); + this.variablesView.replaceChildren(...nodes); + } + + setTheme(theme) { + this.monaco.editor.setTheme(theme); + } + + layout() { + this.sourceEditor?.layout(); + this.diffEditor?.layout(); + } + + disposeEditors() { + this.pendingMount = null; + this.sourceHost?.remove(); + this.diffHost?.remove(); + } +} + +function viewerOptions() { + return { + readOnly: true, + automaticLayout: true, + stickyScroll: { enabled: false }, + minimap: { enabled: false }, + wordWrap: "on", + scrollBeyondLastLine: false, + lineNumbersMinChars: 3, + fontSize: 12.5, + renderLineHighlight: "none", + }; +} + +function htmlFrame(html) { + const csp = ''; + return h("iframe", { class: "body-html", sandbox: "", referrerpolicy: "no-referrer", srcdoc: `${csp}${html}`, title: "HTML body" }); +} + +function parseHeaderNames(raw) { + const set = new Set(); + const headerBlock = raw.replace(/\r\n/g, "\n").split(/\n\n/)[0]; + const unfolded = headerBlock.replace(/\n[ \t]+/g, " "); + for (const line of unfolded.split("\n")) { + const index = line.indexOf(":"); + if (index > 0) set.add(`${line.slice(0, index).trim().toLowerCase()}:${line.slice(index + 1).trim().split(/\s+/).join(" ")}`); + } + return set; +} + +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + document.dispatchEvent(new CustomEvent("toast", { detail: "Copied to the clipboard" })); + } catch (_) { + document.dispatchEvent(new CustomEvent("toast", { detail: "Copy failed: clipboard access was blocked" })); + } +} diff --git a/sieve-playground/samples/expressions.eml b/sieve-playground/samples/expressions.eml new file mode 100644 index 0000000..3a92104 --- /dev/null +++ b/sieve-playground/samples/expressions.eml @@ -0,0 +1,9 @@ +From: Team Lead +To: jane@example.org, bob@example.net, carol@example.com +Cc: dave@example.org +Subject: MEETING MOVED TO FRIDAY +Date: Sat, 12 Sep 2026 09:00:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +The weekly meeting moves to Friday 10:00. diff --git a/sieve-playground/samples/expressions.sieve b/sieve-playground/samples/expressions.sieve new file mode 100644 index 0000000..dac8110 --- /dev/null +++ b/sieve-playground/samples/expressions.sieve @@ -0,0 +1,44 @@ +# inbuxa expressions and loops +# +# "vnd.inbuxa.expressions" adds "let" and "eval" with arithmetic, +# string functions and arrays. "vnd.inbuxa.while" adds loops. Both are +# inbuxa extensions to Sieve. + +require ["variables", "editheader", "fileinto", "mailbox", "imap4flags", + "vnd.inbuxa.expressions", "vnd.inbuxa.while"]; + +# Header fields are available as values: the display name of the sender, +# falling back to the address when the name is empty. +let "sender" "header.from.name"; +if eval "is_empty(sender)" { + let "sender" "header.from.addr"; +} +addheader "X-Sender-Name" "${sender}"; + +let "recipients" "header.to:cc[*].addr[*]"; +let "total" "count(recipients)"; +let "shouting" "is_uppercase(header.subject)"; + +if eval "total > 3" { + addflag "$ManyRecipients"; +} + +let "i" "0"; +let "domains" "''"; +while "i < total" { + let "domain" "email_part(recipients[i], 'domain')"; + let "domains" "domains + domain + ' '"; + let "i" "i + 1"; +} +addheader "X-Recipient-Domains" "${domains}"; + +if eval "shouting" { + addflag "$Shouting"; + let "subject" "to_lowercase(header.subject)"; + deleteheader "Subject"; + addheader "Subject" "${subject}"; +} + +if eval "contains_ignore_case(header.subject, 'meeting')" { + fileinto :create "Calendar"; +} diff --git a/sieve-playground/samples/forward.eml b/sieve-playground/samples/forward.eml new file mode 100644 index 0000000..7d4b61b --- /dev/null +++ b/sieve-playground/samples/forward.eml @@ -0,0 +1,14 @@ +From: Billing +To: jane@example.org +Subject: Urgent: invoice 2026-0915 overdue +Importance: high +Date: Thu, 10 Sep 2026 08:00:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +Dear customer, + +Invoice 2026-0915 is now overdue. Please arrange payment at your +earliest convenience. + +Supplier Accounts diff --git a/sieve-playground/samples/forward.sieve b/sieve-playground/samples/forward.sieve new file mode 100644 index 0000000..32d8c67 --- /dev/null +++ b/sieve-playground/samples/forward.sieve @@ -0,0 +1,29 @@ +# Redirect, notify and copy +# +# Sends a copy of invoices to accounting, notifies Jane by email about +# urgent messages, and rewrites the subject with "editheader" before the +# message is kept. + +require ["copy", "enotify", "variables", "editheader", "redirect-dsn", + "fileinto", "fcc", "mailbox", "envelope"]; + +if header :contains "Subject" "invoice" { + redirect :copy :notify "failure,delay" :ret "hdrs" "accounting@example.org"; +} + +set "from" ""; +if anyof (header :is "Importance" "high", header :contains "Subject" "urgent") { + if header :matches "From" "*" { + set "from" "${1}"; + } + notify :importance "1" + :message "Urgent mail from ${from}" + "mailto:jane.mobile@example.org?subject=Urgent%20mail"; +} + +if header :matches "Subject" "*" { + deleteheader "Subject"; + addheader "Subject" "[Finance] ${1}"; +} + +fileinto :copy :create "Finance"; diff --git a/sieve-playground/samples/include-common.sieve b/sieve-playground/samples/include-common.sieve new file mode 100644 index 0000000..b40e085 --- /dev/null +++ b/sieve-playground/samples/include-common.sieve @@ -0,0 +1,13 @@ +require ["include", "variables"]; + +global ["priority", "project"]; + +set "project" "general"; +if header :matches "Subject" "[*]*" { + set :lower "project" "${1}"; +} + +set "priority" "normal"; +if header :contains "Subject" ["blocker", "outage"] { + set "priority" "high"; +} diff --git a/sieve-playground/samples/include-main.sieve b/sieve-playground/samples/include-main.sieve new file mode 100644 index 0000000..6cbc7a8 --- /dev/null +++ b/sieve-playground/samples/include-main.sieve @@ -0,0 +1,17 @@ +# Includes and global variables +# +# The "common" tab is an included script. It sets global variables that +# this script reads back. Add your own tabs with the + button: a tab named +# "foo" is available as include "foo". + +require ["include", "variables", "fileinto", "mailbox"]; + +global ["priority", "project"]; + +include :personal "common"; + +if string :is "${priority}" "high" { + fileinto :create "Projects/${project}/Priority"; +} else { + fileinto :create "Projects/${project}"; +} diff --git a/sieve-playground/samples/include.eml b/sieve-playground/samples/include.eml new file mode 100644 index 0000000..1714619 --- /dev/null +++ b/sieve-playground/samples/include.eml @@ -0,0 +1,8 @@ +From: Ops Team +To: jane@example.org +Subject: [Apollo] Outage in the staging cluster +Date: Fri, 11 Sep 2026 14:30:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +The staging cluster is down since 14:10 UTC. Investigating. diff --git a/sieve-playground/samples/index.json b/sieve-playground/samples/index.json new file mode 100644 index 0000000..0e8ec29 --- /dev/null +++ b/sieve-playground/samples/index.json @@ -0,0 +1,55 @@ +[ + { + "id": "welcome", + "title": "Welcome tour", + "description": "Duplicates, spam, lists, flags, MIME footers and vacation in one script.", + "scripts": [{ "name": "main", "file": "welcome.sieve" }], + "messages": [{ "name": "q3-report.eml", "file": "welcome.eml" }] + }, + { + "id": "lists", + "title": "Mailing lists", + "description": "Regex captures, body tests and plus addressing.", + "scripts": [{ "name": "main", "file": "lists.sieve" }], + "messages": [{ "name": "announcement.eml", "file": "lists.eml" }] + }, + { + "id": "spam", + "title": "Spam and viruses", + "description": "spamtest, virustest, reject and discard driven by the scores in Settings.", + "scripts": [{ "name": "main", "file": "spam.sieve" }], + "messages": [{ "name": "lottery.eml", "file": "spam.eml" }], + "settings": { "spamScore": 6 } + }, + { + "id": "vacation", + "title": "Out of office", + "description": "Vacation replies with date windows and auto-reply safeguards.", + "scripts": [{ "name": "main", "file": "vacation.sieve" }], + "messages": [{ "name": "lunch.eml", "file": "vacation.eml" }] + }, + { + "id": "forward", + "title": "Redirect and notify", + "description": "redirect :copy with DSN options, enotify and editheader.", + "scripts": [{ "name": "main", "file": "forward.sieve" }], + "messages": [{ "name": "invoice.eml", "file": "forward.eml" }] + }, + { + "id": "include", + "title": "Includes", + "description": "Split a filter across scripts with include and global variables.", + "scripts": [ + { "name": "main", "file": "include-main.sieve" }, + { "name": "common", "file": "include-common.sieve" } + ], + "messages": [{ "name": "outage.eml", "file": "include.eml" }] + }, + { + "id": "expressions", + "title": "Expressions and loops", + "description": "The vnd.inbuxa.expressions and vnd.inbuxa.while extensions: let, eval, loops and built-in functions.", + "scripts": [{ "name": "main", "file": "expressions.sieve" }], + "messages": [{ "name": "meeting.eml", "file": "expressions.eml" }] + } +] diff --git a/sieve-playground/samples/lists.eml b/sieve-playground/samples/lists.eml new file mode 100644 index 0000000..8099559 --- /dev/null +++ b/sieve-playground/samples/lists.eml @@ -0,0 +1,16 @@ +From: Ferris +To: rust-users@lists.rust-lang.example +Subject: [ANN] Release 1.99 is out +Date: Mon, 7 Sep 2026 17:02:11 +0000 +Message-ID: +In-Reply-To: +List-Id: Rust users +List-Unsubscribe: +Content-Type: text/plain; charset="utf-8" + +Hello everyone, + +The new release is out. See the notes for the full list of changes, +including a security advisory for older toolchains. + +The release team diff --git a/sieve-playground/samples/lists.sieve b/sieve-playground/samples/lists.sieve new file mode 100644 index 0000000..94def2e --- /dev/null +++ b/sieve-playground/samples/lists.sieve @@ -0,0 +1,34 @@ +# Sort mailing lists into folders +# +# Uses "regex" to pull the list name out of the List-Id header and +# "variables" to build the folder name. Messages that mention a keyword +# in the body are flagged, and replies to your own posts are marked read. + +require ["fileinto", "mailbox", "imap4flags", "variables", "regex", "body", + "subaddress", "envelope"]; + +if header :regex "List-Id" "<([a-z0-9-]+)\\.([a-z0-9.-]+)>" { + set :lower "list" "${1}"; + set :lower "host" "${2}"; + + if body :text :contains ["release", "security advisory"] { + addflag "\\Flagged"; + } + + if header :contains "In-Reply-To" "@example.org>" { + addflag "\\Seen"; + } + + fileinto :create "Lists/${host}/${list}"; + stop; +} + +# Plus addressing: mail sent to jane+shopping@example.org goes to "shopping". +if envelope :detail :matches "to" "*" { + if string :is "${1}" "" { + keep; + } else { + set :lower "folder" "${1}"; + fileinto :create "${folder}"; + } +} diff --git a/sieve-playground/samples/spam.eml b/sieve-playground/samples/spam.eml new file mode 100644 index 0000000..b0a0ef0 --- /dev/null +++ b/sieve-playground/samples/spam.eml @@ -0,0 +1,8 @@ +From: "Prize Department" +To: jane@example.org +Subject: You have WON 1,000,000 dollars!!! +Date: Sun, 6 Sep 2026 03:12:45 +0000 +Message-ID: +Content-Type: text/html; charset="utf-8" + +

Congratulations!

Click here to claim your prize.

diff --git a/sieve-playground/samples/spam.sieve b/sieve-playground/samples/spam.sieve new file mode 100644 index 0000000..1d7fb1c --- /dev/null +++ b/sieve-playground/samples/spam.sieve @@ -0,0 +1,28 @@ +# Quarantine spam and viruses +# +# The spam and virus scores come from Settings (Spam and virus). Try +# changing them and running the script again. + +require ["spamtest", "spamtestplus", "virustest", "relational", + "comparator-i;ascii-numeric", "fileinto", "mailbox", "special-use", + "imap4flags", "reject", "editheader", "variables"]; + +if virustest :value "eq" :comparator "i;ascii-numeric" "5" { + reject "Message rejected: a virus was detected."; + stop; +} + +if spamtest :value "ge" :comparator "i;ascii-numeric" "8" { + discard; + stop; +} + +set "score" ""; +if spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50" { + if spamtest :percent :matches "*" { + set "score" "${1}"; + } + addheader "X-Spam-Score" "${score}%"; + addflag "$Junk"; + fileinto :specialuse "\\Junk" :create "Junk"; +} diff --git a/sieve-playground/samples/vacation.eml b/sieve-playground/samples/vacation.eml new file mode 100644 index 0000000..c1abeee --- /dev/null +++ b/sieve-playground/samples/vacation.eml @@ -0,0 +1,10 @@ +From: Bob Smith +To: Jane Doe +Subject: Lunch next week? +Date: Wed, 9 Sep 2026 11:20:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +Hi Jane, are you free for lunch next Tuesday? + +Bob diff --git a/sieve-playground/samples/vacation.sieve b/sieve-playground/samples/vacation.sieve new file mode 100644 index 0000000..20a604b --- /dev/null +++ b/sieve-playground/samples/vacation.sieve @@ -0,0 +1,37 @@ +# Out of office with dates +# +# Replies only while the current date is inside the vacation window, +# never to mailing lists or automated senders, and at most once every +# three days per sender. Change the dates to see the reply disappear. + +require ["vacation", "vacation-seconds", "date", "relational", "variables", + "envelope"]; + +set "subject" ""; +if currentdate :value "ge" "date" "2026-01-01" { + if currentdate :value "le" "date" "2026-12-31" { + if not anyof (exists "List-Id", + header :contains "Precedence" ["bulk", "list", "junk"], + header :is "Auto-Submitted" ["auto-replied", "auto-generated"], + envelope :localpart :is "from" ["mailer-daemon", "noreply", "no-reply"]) { + if header :matches "Subject" "*" { + set "subject" "${1}"; + } + vacation :days 3 + :from "Jane Doe " + :addresses ["jane@example.org", "j.doe@example.org"] + :subject "Out of office: ${subject}" + :handle "summer-vacation" + text: +Hello, + +I am on vacation and will read your message about "${subject}" when +I am back. For urgent matters please contact support@example.org. + +Best regards, +Jane +. +; + } + } +} diff --git a/sieve-playground/samples/welcome.eml b/sieve-playground/samples/welcome.eml new file mode 100644 index 0000000..70e7ff3 --- /dev/null +++ b/sieve-playground/samples/welcome.eml @@ -0,0 +1,33 @@ +Return-Path: +Received: from mail.example.com (mail.example.com [192.0.2.25]) + by mx.example.org with ESMTPS id 4F2A1B3C + for ; Tue, 8 Sep 2026 09:41:07 +0000 +From: Alice Martin +To: Jane Doe +Subject: Q3 report and team offsite +Date: Tue, 8 Sep 2026 09:41:02 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="playground-boundary" + +--playground-boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Hi Jane, + +The Q3 report is attached. Could you review the numbers before +Thursday? We will go through them at the team offsite. + +Thanks, +Alice + +--playground-boundary +Content-Type: application/pdf; name="q3-report.pdf" +Content-Disposition: attachment; filename="q3-report.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjQKJcOkw7zDtsOfCjEgMCBvYmoKPDwgL1R5cGUgL0NhdGFsb2cgPj4KZW5kb2Jq +CnRyYWlsZXIKPDwgL1Jvb3QgMSAwIFIgPj4KJSVFT0YK + +--playground-boundary-- diff --git a/sieve-playground/samples/welcome.sieve b/sieve-playground/samples/welcome.sieve new file mode 100644 index 0000000..a7188f2 --- /dev/null +++ b/sieve-playground/samples/welcome.sieve @@ -0,0 +1,105 @@ +# Welcome to the Sieve playground! +# +# Sieve (RFC 5228) is the standard language for filtering email on the +# mail server. This script runs in your browser on the same interpreter +# your inbuxa server runs. +# +# Press Run (Ctrl+Enter or Cmd+Enter) to filter the message in the middle +# column, then open the Messages tab on the right to see what changed. +# Hover over any command to read its documentation. +# +# Everything runs locally in your browser: scripts and messages are saved +# in this browser only and are never sent to or stored on a server. + +require ["envelope", "fileinto", "mailbox", "special-use", "imap4flags", + "variables", "relational", "comparator-i;ascii-numeric", + "spamtest", "spamtestplus", "date", "duplicate", "extlists", + "editheader", "mime", "foreverypart", "extracttext", "replace", + "vacation"]; + +# Drop the message if the very same one was delivered in the last hour. +# Press "Run again" to simulate a second delivery. +if duplicate :seconds 3600 { + discard; + stop; +} + +# Anything the spam filter scored 80% or higher goes to Junk. +if spamtest :percent :value "ge" :comparator "i;ascii-numeric" "80" { + fileinto :specialuse "\\Junk" :create "Junk"; + stop; +} + +# Mailing lists get a folder of their own, such as "Lists/rust-users". +if header :matches "List-Id" "*<*.*>*" { + set :lower "list" "${2}"; + fileinto :create "Lists/${list}"; + stop; +} + +# Capture a few details about the message with variables and wildcard +# matches. Variables first assigned inside a block only live in that block, +# so declare the ones used later at the top level. +set "domain" "unknown"; +set "subject" ""; +set "sender" "there"; +if address :domain :matches "from" "*" { + set :lower "domain" "${1}"; +} +if header :matches "Subject" "*" { + set "subject" "${1}"; +} +if header :matches "From" "* <*>" { + set "sender" "${1}"; +} + +# People in your address book are flagged as important. +if address :all :list "from" "addrbook:default" { + addflag "\\Flagged"; +} + +# Tag messages that carry a PDF anywhere in their MIME structure. +if header :mime :anychild :contenttype "Content-Type" "application/pdf" { + addflag "$HasPDF"; +} + +# Note when the message arrived outside office hours. +if anyof (currentdate :value "lt" :comparator "i;ascii-numeric" "hour" "08", + currentdate :value "ge" :comparator "i;ascii-numeric" "hour" "18") { + addheader "X-After-Hours" "yes"; +} + +# Stamp the message so you can see it went through the filter. +addheader :last "X-Sieve-Filter" "inbuxa Sieve playground"; + +# Append a footer to every plain text part. +foreverypart { + if header :mime :subtype "Content-Type" "plain" { + extracttext "body"; + replace text: +${body} +-- +Filtered on its way in by a Sieve script. +. +; + } +} + +# Let the sender know you are away, at most once a week per sender. +vacation :days 7 :subject "Away: ${subject}" text: +Hi ${sender}, + +Thanks for your message. I am out of the office this week and will +get back to you as soon as I return. + +-- +This reply was sent by a Sieve script. +. +; + +# Important mail stays in the inbox, the rest is sorted by domain. +if hasflag "\\Flagged" { + keep; +} else { + fileinto :create "Contacts/${domain}"; +} diff --git a/sieve-playground/settings.js b/sieve-playground/settings.js new file mode 100644 index 0000000..951c954 --- /dev/null +++ b/sieve-playground/settings.js @@ -0,0 +1,337 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +import { h } from "./result.js"; + +const list = (text) => text.split(/[,\n]/).map((s) => s.trim()).filter(Boolean); +const lines = (text) => text.split("\n").map((s) => s.trim()).filter((s) => s && !s.startsWith("#")); + +function pairs(text) { + return lines(text).map((line) => { + const index = line.indexOf("="); + return index < 0 ? { name: line, value: "" } : { name: line.slice(0, index).trim(), value: line.slice(index + 1).trim() }; + }); +} + +const USIZE_MAX = 4294967295; +const RANGES = { + spamScore: [0, 10], + virusScore: [0, 5], + maxMatchVariables: [0, 63], + defaultVacationExpiry: [0, Number.MAX_SAFE_INTEGER], + defaultDuplicateExpiry: [0, Number.MAX_SAFE_INTEGER], +}; +const NULLABLE_INTEGERS = new Set(["currentTime"]); +const SHAPES = { + mailboxes: { name: "string", specialUse: "strings" }, + lists: { name: "string", values: "strings" }, + environment: { name: "string", value: "string" }, + globalVariables: { name: "string", value: "string" }, + metadata: { mailbox: "string", annotation: "string", value: "string" }, +}; + +const toStrings = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === "string") : []); + +function toInteger(value) { + const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value; + return typeof number === "number" && Number.isFinite(number) ? Math.trunc(number) : null; +} + +function normalizeValue(key, value, fallback) { + if (NULLABLE_INTEGERS.has(key)) { + if (value === null || value === undefined || value === "") return null; + const number = toInteger(value); + return number === null ? fallback : Math.min(Math.max(number, -8640000000000), 8640000000000); + } + if (value === undefined) return structuredClone(fallback); + if (typeof fallback === "number") { + const number = toInteger(value); + if (number === null) return fallback; + const [min, max] = RANGES[key] || [0, USIZE_MAX]; + return Math.min(Math.max(number, min), max); + } + if (typeof fallback === "boolean") return typeof value === "boolean" ? value : fallback; + if (typeof fallback === "string") return typeof value === "string" ? value : fallback; + if (Array.isArray(fallback)) { + if (!Array.isArray(value)) return structuredClone(fallback); + const shape = SHAPES[key]; + if (!shape) return toStrings(value); + return value + .filter((item) => item && typeof item === "object" && !Array.isArray(item)) + .map((item) => Object.fromEntries(Object.entries(shape).map(([field, type]) => [field, type === "strings" ? toStrings(item[field]) : typeof item[field] === "string" ? item[field] : ""]))) + .filter((item) => Object.values(item).some((field) => (Array.isArray(field) ? field.length : field))); + } + return structuredClone(fallback); +} + +export function normalizeSettings(settings, defaults) { + const source = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {}; + return Object.fromEntries(Object.entries(defaults).map(([key, fallback]) => [key, normalizeValue(key, source[key], fallback)])); +} + +const FORMATS = { + text: { read: (v) => v ?? "", write: (v) => v }, + list: { read: (v) => (v || []).join(", "), write: list }, + number: { read: (v) => String(v ?? ""), write: (v) => Math.min(Math.max(Number.parseInt(v, 10) || 0, 0), USIZE_MAX) }, + bool: { read: (v) => !!v, write: (v) => !!v }, + pairs: { read: (v) => (v || []).map((p) => `${p.name ?? ""} = ${p.value ?? ""}`).join("\n"), write: pairs }, + mailboxes: { + read: (v) => (v || []).map((m) => [m.name ?? "", ...(m.specialUse || [])].join(" ")).join("\n"), + write: (text) => lines(text).map((line) => { + const parts = line.split(/\s+(?=\\)/); + return { name: parts[0], specialUse: parts.slice(1) }; + }), + }, + lists: { + read: (v) => (v || []).map((l) => `${l.name} = ${(l.values || []).join(", ")}`).join("\n"), + write: (text) => pairs(text).map((p) => ({ name: p.name, values: list(p.value) })), + }, + metadata: { + read: (v) => (v || []).map((m) => `${m.mailbox ? `${m.mailbox} ` : ""}${m.annotation} = ${m.value}`).join("\n"), + write: (text) => pairs(text).map((p) => { + const split = /^(.*\S)\s+(\/\S*)$/.exec(p.name); + return split ? { mailbox: split[1], annotation: split[2], value: p.value } : { mailbox: "", annotation: p.name, value: p.value }; + }), + }, + time: { + read: (v) => { + if (v === null || v === undefined) return ""; + const date = new Date(v * 1000); + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + }, + write: (v) => (v ? Math.floor(new Date(v).getTime() / 1000) : null), + }, +}; + +const SPAM_LABELS = ["0: not scanned", "1: not spam", "2", "3", "4", "5: maybe spam", "6", "7", "8", "9", "10: definitely spam"]; +const VIRUS_LABELS = ["0: not scanned", "1: clean", "2: virus replaced", "3: virus cured", "4: possibly infected", "5: infected"]; + +const GROUPS = [ + { + title: "Identity and envelope", + sub: "who receives the message", + open: true, + fields: [ + { key: "userAddress", label: "User address", format: "text" }, + { key: "userFullName", label: "User full name", format: "text" }, + { row: [ + { key: "envelopeFrom", label: "Envelope from", format: "text", placeholder: "taken from the message" }, + { key: "envelopeTo", label: "Envelope to", format: "list", placeholder: "the user address" }, + ] }, + { row: [ + { key: "envelopeId", label: "Envelope id", format: "text" }, + { key: "localHostname", label: "Local hostname", format: "text" }, + ] }, + { key: "currentTime", label: "Current time", format: "time", type: "datetime-local", help: "Leave empty to use the real clock. Affects currentdate and Date headers." }, + ], + }, + { + title: "Mailboxes", + sub: "for fileinto, mailboxexists and special-use", + fields: [{ key: "mailboxes", label: "One mailbox per line, followed by its special-use attributes", format: "mailboxes", textarea: 7 }], + }, + { + title: "Spam and virus", + sub: "spamtest and virustest", + fields: [{ row: [ + { key: "spamScore", label: "Spam score", format: "number", options: SPAM_LABELS }, + { key: "virusScore", label: "Virus status", format: "number", options: VIRUS_LABELS }, + ] }], + }, + { + title: "Environment and variables", + sub: "environment, global variables, metadata", + fields: [ + { key: "environment", label: "Environment items (name = value)", format: "pairs", textarea: 6 }, + { key: "globalVariables", label: "Initial global variables (name = value)", format: "pairs", textarea: 3 }, + { key: "metadata", label: "Metadata: [mailbox] /annotation = value", format: "metadata", textarea: 3, help: "Without a mailbox the annotation is a server annotation." }, + ], + }, + { + title: "Lists and notifications", + sub: "extlists, enotify, vacation", + fields: [ + { key: "lists", label: "External lists (list = value, value)", format: "lists", textarea: 3 }, + { key: "validNotificationUris", label: "Allowed notification URI schemes", format: "list" }, + { row: [ + { key: "vacationDefaultSubject", label: "Default vacation subject", format: "text" }, + { key: "vacationSubjectPrefix", label: "Vacation subject prefix", format: "text" }, + ] }, + { row: [ + { key: "defaultVacationExpiry", label: "Default vacation period (s)", format: "number" }, + { key: "defaultDuplicateExpiry", label: "Default duplicate period (s)", format: "number" }, + ] }, + { key: "vacationUseOrigRcpt", label: "Vacation uses the original recipient", format: "bool" }, + { key: "protectedHeaders", label: "Protected headers (editheader cannot change them)", format: "list" }, + ], + }, + { title: "Extensions", sub: "capabilities allowed at run time", capabilities: true }, + { + title: "Runtime limits", + sub: "per run", + fields: [ + { row: [{ key: "cpuLimit", label: "Max instructions", format: "number" }] }, + { row: [{ key: "maxRedirects", label: "Max redirects", format: "number" }, { key: "maxOutMessages", label: "Max outgoing messages", format: "number" }] }, + { row: [{ key: "maxNestedIncludes", label: "Max nested includes", format: "number" }, { key: "maxVariableSize", label: "Max variable size", format: "number" }] }, + { row: [{ key: "maxHeaderSize", label: "Max header size", format: "number" }, { key: "maxReceivedHeaders", label: "Max Received headers", format: "number" }] }, + ], + }, + { + title: "Compiler limits", + sub: "applied when the script is compiled", + fields: [ + { row: [{ key: "maxScriptSize", label: "Max script size", format: "number" }, { key: "maxStringSize", label: "Max string size", format: "number" }] }, + { row: [{ key: "maxNestedBlocks", label: "Max nested blocks", format: "number" }, { key: "maxNestedTests", label: "Max nested tests", format: "number" }] }, + { row: [{ key: "maxNestedForeverypart", label: "Max nested foreverypart", format: "number" }, { key: "maxMatchVariables", label: "Max match variables", format: "number" }] }, + { row: [{ key: "maxLocalVariables", label: "Max local variables", format: "number" }, { key: "maxVariableNameSize", label: "Max variable name size", format: "number" }] }, + { row: [{ key: "maxIncludes", label: "Max includes", format: "number" }] }, + ], + }, +]; + +export class SettingsDrawer { + constructor({ drawer, backdrop, body, resetButton, jsonButton, defaults, capabilities, get, set }) { + Object.assign(this, { drawer, backdrop, body, resetButton, jsonButton, defaults, capabilities, get, set }); + this.jsonMode = false; + this.openGroups = new Set(GROUPS.filter((g) => g.open).map((g) => g.title)); + for (const el of drawer.querySelectorAll("[data-close]")) el.addEventListener("click", () => this.close()); + backdrop.addEventListener("click", () => this.close()); + drawer.addEventListener("keydown", (event) => { + if (event.key === "Escape") this.close(); + }); + resetButton.addEventListener("click", () => { + if (!confirm("Reset all settings of this workspace to their defaults?")) return; + this.set(structuredClone(this.defaults)); + this.render(); + }); + jsonButton.addEventListener("click", () => { + this.jsonMode = !this.jsonMode; + this.render(); + }); + } + + open() { + this.jsonMode = false; + this.render(); + this.drawer.hidden = false; + this.backdrop.hidden = false; + this.drawer.querySelector("input, textarea, select, button")?.focus(); + } + + close() { + if (this.jsonMode && !this.applyJson()) return; + this.drawer.hidden = true; + this.backdrop.hidden = true; + } + + update(key, value) { + this.set({ ...this.get(), [key]: value }); + } + + render() { + this.jsonButton.textContent = this.jsonMode ? "Back to form" : "Edit as JSON"; + if (this.jsonMode) { + const area = h("textarea", { class: "json-editor", spellcheck: "false", rows: 30, style: "min-height:60vh;margin-top:12px" }); + area.value = JSON.stringify(this.get(), null, 2); + const error = h("p", { class: "dialog-warning", hidden: true }); + area.addEventListener("change", () => this.applyJson()); + this.jsonArea = area; + this.jsonError = error; + this.body.replaceChildren(area, error); + return; + } + const settings = this.get(); + this.body.replaceChildren( + ...GROUPS.map((group) => { + const details = h("details", { class: "settings-group", open: this.openGroups.has(group.title) }, + h("summary", {}, group.title, h("span", { class: "item-sub" }, group.sub)), + h("div", { class: "settings-fields" }, group.capabilities ? this.capabilitiesField(settings) : group.fields.map((field) => this.field(field, settings))), + ); + details.addEventListener("toggle", () => { + if (details.open) this.openGroups.add(group.title); + else this.openGroups.delete(group.title); + }); + return details; + }), + ); + } + + applyJson() { + try { + const parsed = JSON.parse(this.jsonArea.value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Settings must be a JSON object"); + this.set(normalizeSettings({ ...this.defaults, ...parsed }, this.defaults)); + this.jsonError.hidden = true; + return true; + } catch (err) { + this.jsonError.textContent = `Invalid JSON: ${err.message}`; + this.jsonError.hidden = false; + return false; + } + } + + field(spec, settings) { + if (spec.row) return h("div", { class: "field-row" }, spec.row.map((item) => this.field(item, settings))); + const format = FORMATS[spec.format]; + const value = format.read(settings[spec.key]); + const id = `setting-${spec.key}`; + if (spec.format === "bool") { + const input = h("input", { type: "checkbox", id }); + input.checked = value; + input.addEventListener("change", () => this.update(spec.key, input.checked)); + return h("label", { class: "check" }, input, spec.label); + } + let input; + if (spec.options) { + input = h("select", { id }, spec.options.map((label, index) => h("option", { value: index }, label))); + input.value = value || "0"; + } else if (spec.textarea) { + input = h("textarea", { id, rows: spec.textarea, spellcheck: "false" }); + input.value = value; + } else { + input = h("input", { id, type: spec.type || (spec.format === "number" ? "number" : "text"), spellcheck: "false", autocomplete: "off", placeholder: spec.placeholder, min: spec.format === "number" ? 0 : undefined }); + input.value = value; + } + input.addEventListener("change", () => { + this.update(spec.key, format.write(input.value)); + input.value = format.read(this.get()[spec.key]); + }); + return h("label", { class: "field", for: id }, h("span", {}, spec.label), input, spec.help ? h("small", {}, spec.help) : null); + } + + capabilitiesField(settings) { + const enabled = new Set(settings.capabilities || []); + const boxes = this.capabilities.map((name) => { + const input = h("input", { type: "checkbox", value: name }); + input.checked = enabled.has(name); + input.addEventListener("change", () => { + const next = new Set(this.get().capabilities || []); + if (input.checked) next.add(name); + else next.delete(name); + this.update("capabilities", this.capabilities.filter((c) => next.has(c))); + }); + return h("label", { class: "check" }, input, name); + }); + const setAll = (on) => { + this.update("capabilities", on ? [...this.capabilities] : []); + this.render(); + }; + const noCheck = h("input", { type: "checkbox" }); + noCheck.checked = !!settings.noCapabilityCheck; + noCheck.addEventListener("change", () => this.update("noCapabilityCheck", noCheck.checked)); + return [ + h("div", { class: "caps-actions" }, + h("button", { type: "button", onclick: () => setAll(true) }, "Enable all"), + h("button", { type: "button", onclick: () => setAll(false) }, "Disable all"), + ), + h("div", { class: "caps" }, boxes), + h("label", { class: "check" }, noCheck, "Allow commands without a matching require (not portable)"), + ]; + } +} diff --git a/sieve-playground/share.js b/sieve-playground/share.js new file mode 100644 index 0000000..b25aece --- /dev/null +++ b/sieve-playground/share.js @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +const SHARE_PARAM = "w"; +const FORMAT = 1; +export const LONG_LINK = 8000; + +function toBase64Url(bytes) { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(text) { + const base64 = text.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64 + "===".slice((base64.length + 3) % 4)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +async function pipe(bytes, stream) { + const response = new Response(new Blob([bytes]).stream().pipeThrough(stream)); + return new Uint8Array(await response.arrayBuffer()); +} + +export function settingsDiff(settings, defaults) { + const diff = {}; + for (const [key, value] of Object.entries(settings || {})) { + if (JSON.stringify(value) !== JSON.stringify(defaults[key])) diff[key] = value; + } + return diff; +} + +export function portable(workspace, defaults, { includeMessages = true } = {}) { + return { + v: FORMAT, + name: workspace.name, + scripts: workspace.scripts.map(({ name, source }) => ({ name, source })), + messages: includeMessages ? workspace.messages.map(({ name, source }) => ({ name, source })) : [], + settings: settingsDiff(workspace.settings, defaults), + }; +} + +export async function encodeShareUrl(data) { + const json = new TextEncoder().encode(JSON.stringify(data)); + const packed = await pipe(json, new CompressionStream("deflate-raw")); + const url = new URL(location.href); + url.search = ""; + url.hash = `${SHARE_PARAM}=${toBase64Url(packed)}`; + return url.href; +} + +export function hasSharedWorkspace() { + return new URLSearchParams(location.hash.slice(1)).has(SHARE_PARAM); +} + +export async function decodeSharedWorkspace() { + const value = new URLSearchParams(location.hash.slice(1)).get(SHARE_PARAM); + if (!value) return null; + const json = await pipe(fromBase64Url(value), new DecompressionStream("deflate-raw")); + return validatePortable(JSON.parse(new TextDecoder().decode(json))); +} + +export function clearShareHash() { + history.replaceState(null, "", location.pathname + location.search); +} + +export function validatePortable(data) { + if (!data || typeof data !== "object" || !Array.isArray(data.scripts)) { + throw new Error("This is not a Sieve playground workspace."); + } + const clean = (items) => + (Array.isArray(items) ? items : []) + .filter((item) => item && typeof item.source === "string") + .map((item, index) => ({ name: String(item.name || `item-${index + 1}`).slice(0, 80), source: item.source })); + const scripts = clean(data.scripts); + if (scripts.length === 0) throw new Error("The workspace has no scripts."); + return { + name: String(data.name || "Shared workspace").slice(0, 80), + scripts, + messages: clean(data.messages), + settings: data.settings && typeof data.settings === "object" ? data.settings : {}, + }; +} diff --git a/sieve-playground/sieve-docs.js b/sieve-playground/sieve-docs.js new file mode 100644 index 0000000..0651bca --- /dev/null +++ b/sieve-playground/sieve-docs.js @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +const RFC = (n, section) => `https://www.rfc-editor.org/rfc/rfc${n}${section ? `#section-${section}` : ""}`; + +export const CONTROL = { + require: { sig: "require ", doc: "Declares the extensions the script uses. Every extension must be required before its commands, tests or tags appear.", rfc: RFC(5228, "3.2") }, + if: { sig: "if ", doc: "Runs the block when the test is true. Can be followed by `elsif` and `else`.", rfc: RFC(5228, "3.1") }, + elsif: { sig: "elsif ", doc: "Runs the block when all previous tests in the chain were false and this test is true.", rfc: RFC(5228, "3.1") }, + else: { sig: "else ", doc: "Runs the block when every previous test in the chain was false.", rfc: RFC(5228, "3.1") }, + stop: { sig: "stop", doc: "Ends the script. The implicit keep still applies unless another action cancelled it.", rfc: RFC(5228, "3.3") }, + include: { sig: 'include [:personal / :global] [:once] [:optional] ', doc: "Runs another script. In the playground, add a script tab with that name.", rfc: RFC(6609, "3.2"), capability: "include" }, + return: { sig: "return", doc: "Stops the current included script and continues in the script that included it.", rfc: RFC(6609, "3.3"), capability: "include" }, + global: { sig: "global ", doc: "Shares variables between the including and included scripts.", rfc: RFC(6609, "3.4"), capability: "include" }, + foreverypart: { sig: "foreverypart [:name ] ", doc: "Runs the block once for each MIME part of the message. Tests with `:mime` and commands such as `replace` act on the current part.", rfc: RFC(5703, "3"), capability: "foreverypart" }, + break: { sig: "break [:name ]", doc: "Leaves the innermost (or the named) `foreverypart` or `while` loop.", rfc: RFC(5703, "3"), capability: "foreverypart" }, + while: { sig: "while ", doc: "inbuxa extension. Repeats the block while the expression is true. Bounded by the CPU limit.", capability: "vnd.inbuxa.while" }, + continue: { sig: "continue", doc: "inbuxa extension. Skips to the next iteration of the enclosing loop.", capability: "vnd.inbuxa.while" }, +}; + +export const ACTIONS = { + keep: { sig: "keep [:flags ]", doc: "Files the message into the default mailbox (INBOX). This is what happens implicitly when no other action cancels it.", rfc: RFC(5228, "4.3") }, + discard: { sig: "discard", doc: "Silently throws the message away and cancels the implicit keep.", rfc: RFC(5228, "4.4") }, + fileinto: { sig: "fileinto [:copy] [:flags ] [:create] [:specialuse ] [:mailboxid ] ", doc: "Delivers the message into a mailbox and cancels the implicit keep (unless `:copy`).", rfc: RFC(5228, "4.1"), capability: "fileinto" }, + redirect: { sig: "redirect [:copy] [:notify ] [:ret ] [:bytimerelative / :bytimeabsolute ] ", doc: "Forwards the message to another address and cancels the implicit keep (unless `:copy`). Limited by the maximum redirects setting.", rfc: RFC(5228, "4.2") }, + reject: { sig: "reject ", doc: "Refuses the message and sends the reason back to the sender.", rfc: RFC(5429, "2.2"), capability: "reject" }, + ereject: { sig: "ereject ", doc: "Refuses the message at the SMTP level when possible.", rfc: RFC(5429, "2.1"), capability: "ereject" }, + vacation: { sig: "vacation [:days / :seconds ] [:subject ] [:from ] [:addresses ] [:mime] [:handle ] ", doc: "Sends an automatic reply, at most once per sender in the given period. Tracked with duplicate ids: try Run again.", rfc: RFC(5230, "4"), capability: "vacation" }, + notify: { sig: "notify [:from ] [:importance <1|2|3>] [:options ] [:message ] ", doc: "Sends a notification, for example to a `mailto:` URI.", rfc: RFC(5435, "3"), capability: "enotify" }, + set: { sig: "set [:lower / :upper / :lowerfirst / :upperfirst / :quotewildcard / :quoteregex / :encodeurl / :length] ", doc: "Stores a value in a variable. Use `${name}` to read it back inside strings.", rfc: RFC(5229, "4"), capability: "variables" }, + setflag: { sig: "setflag [] ", doc: "Replaces the flags that will be stored with the message.", rfc: RFC(5232, "3.1"), capability: "imap4flags" }, + addflag: { sig: "addflag [] ", doc: "Adds flags that will be stored with the message, such as `\\Seen` or `\\Flagged`.", rfc: RFC(5232, "3.2"), capability: "imap4flags" }, + removeflag: { sig: "removeflag [] ", doc: "Removes flags from the set stored with the message.", rfc: RFC(5232, "3.3"), capability: "imap4flags" }, + addheader: { sig: "addheader [:last] ", doc: "Adds a header field, at the top unless `:last` is given.", rfc: RFC(5293, "4"), capability: "editheader" }, + deleteheader: { sig: 'deleteheader [:index [:last]] [] [] []', doc: "Removes header fields, optionally only those matching the values.", rfc: RFC(5293, "5"), capability: "editheader" }, + replace: { sig: "replace [:mime] [:subject ] [:from ] ", doc: "Replaces the message body, or the current part inside `foreverypart`.", rfc: RFC(5703, "5"), capability: "replace" }, + enclose: { sig: "enclose [:subject ] [:headers ] ", doc: "Wraps the original message as an attachment of a new message.", rfc: RFC(5703, "6"), capability: "enclose" }, + extracttext: { sig: "extracttext [:first ] [] ", doc: "Stores the text of the current MIME part in a variable. Only valid inside `foreverypart`.", rfc: RFC(5703, "7"), capability: "extracttext" }, + convert: { sig: "convert ", doc: "Converts MIME parts from one media type to another.", rfc: RFC(6558, "3"), capability: "convert" }, + error: { sig: "error ", doc: "Aborts the script with an error. The implicit keep applies.", rfc: RFC(5463, "5"), capability: "ihave" }, + let: { sig: "let ", doc: "inbuxa extension. Evaluates an expression and stores the result, for example `let \"n\" \"len(header.subject) * 2\"`.", capability: "vnd.inbuxa.expressions" }, +}; + +export const TESTS = { + address: { sig: "address [] [] [] [:index [:last]] ", doc: "Compares addresses in header fields such as From or To. Address parts: `:all`, `:localpart`, `:domain`, `:user`, `:detail`.", rfc: RFC(5228, "5.1") }, + allof: { sig: "allof ()", doc: "True when every test in the list is true.", rfc: RFC(5228, "5.2") }, + anyof: { sig: "anyof ()", doc: "True when at least one test in the list is true.", rfc: RFC(5228, "5.3") }, + envelope: { sig: "envelope [] [] [] ", doc: "Compares the SMTP envelope. Set the envelope below the message.", rfc: RFC(5228, "5.4"), capability: "envelope" }, + exists: { sig: "exists ", doc: "True when every listed header field is present.", rfc: RFC(5228, "5.5") }, + false: { sig: "false", doc: "Always false.", rfc: RFC(5228, "5.6") }, + header: { sig: "header [:mime] [] [] [:index [:last]] ", doc: "Compares header field values. With `:mime` it looks at MIME part headers.", rfc: RFC(5228, "5.7") }, + not: { sig: "not ", doc: "Inverts a test.", rfc: RFC(5228, "5.8") }, + size: { sig: "size <:over / :under> ", doc: "Compares the message size. Numbers accept K, M and G suffixes.", rfc: RFC(5228, "5.9") }, + true: { sig: "true", doc: "Always true.", rfc: RFC(5228, "5.10") }, + body: { sig: "body [] [] [:raw / :content / :text] ", doc: "Compares the message body.", rfc: RFC(5173, "4"), capability: "body" }, + currentdate: { sig: "currentdate [:zone ] [] [] ", doc: "Compares the current date. Date parts include `year`, `month`, `day`, `date`, `hour`, `weekday`, `iso8601`. The clock can be fixed in Settings.", rfc: RFC(5260, "5"), capability: "date" }, + date: { sig: "date [:zone / :originalzone] [] []
", doc: "Compares a date taken from a header field.", rfc: RFC(5260, "4"), capability: "date" }, + duplicate: { sig: "duplicate [:handle ] [:header / :uniqueid ] [:seconds ] [:last]", doc: "True when the same message (by Message-ID or the given id) was seen before. Use Run again to simulate a redelivery.", rfc: RFC(7352, "3"), capability: "duplicate" }, + environment: { sig: "environment [] [] ", doc: "Compares environment items such as `domain`, `host`, `phase` or `remote-ip`. Configure them in Settings.", rfc: RFC(5183, "4"), capability: "environment" }, + hasflag: { sig: "hasflag [] [] [] ", doc: "Tests the flags set by the script so far.", rfc: RFC(5232, "4"), capability: "imap4flags" }, + ihave: { sig: "ihave ", doc: "True when the listed extensions are available.", rfc: RFC(5463, "4"), capability: "ihave" }, + mailboxexists: { sig: "mailboxexists ", doc: "True when every mailbox exists. Mailboxes are listed in Settings.", rfc: RFC(5490, "3.1"), capability: "mailbox" }, + mailboxidexists: { sig: "mailboxidexists ", doc: "True when every mailbox id exists.", rfc: RFC(9042, "4.2"), capability: "mailboxid" }, + metadata: { sig: "metadata [] [] ", doc: "Compares a mailbox annotation.", rfc: RFC(5490, "3.2"), capability: "mboxmetadata" }, + metadataexists: { sig: "metadataexists ", doc: "True when the mailbox has the annotations.", rfc: RFC(5490, "3.3"), capability: "mboxmetadata" }, + servermetadata: { sig: "servermetadata [] [] ", doc: "Compares a server annotation. Configure annotations in Settings.", rfc: RFC(5490, "3.4"), capability: "servermetadata" }, + servermetadataexists: { sig: "servermetadataexists ", doc: "True when the server has the annotations.", rfc: RFC(5490, "3.5"), capability: "servermetadata" }, + spamtest: { sig: "spamtest [:percent] [] [] ", doc: "Compares the spam score (0 to 10, or a percentage with `:percent`). Set the score in Settings.", rfc: RFC(5235, "3.2"), capability: "spamtest" }, + virustest: { sig: "virustest [] [] ", doc: "Compares the virus status (0 unknown, 1 clean, 5 infected). Set it in Settings.", rfc: RFC(5235, "3.3"), capability: "virustest" }, + specialuse_exists: { sig: "specialuse_exists [] ", doc: "True when a mailbox with the special-use attributes exists.", rfc: RFC(8579, "3"), capability: "special-use" }, + string: { sig: "string [] [] ", doc: "Compares arbitrary strings, usually containing variables.", rfc: RFC(5229, "5"), capability: "variables" }, + valid_notify_method: { sig: "valid_notify_method ", doc: "True when the notification URIs are valid. Allowed schemes are configured in Settings.", rfc: RFC(5435, "5"), capability: "enotify" }, + notify_method_capability: { sig: "notify_method_capability [] [] ", doc: "Queries a capability of a notification method.", rfc: RFC(5435, "6"), capability: "enotify" }, + valid_ext_list: { sig: "valid_ext_list ", doc: "True when the external lists exist. Lists are configured in Settings.", rfc: RFC(6134, "2.6"), capability: "extlists" }, + eval: { sig: "eval ", doc: "inbuxa extension. True when the expression evaluates to a non-zero, non-empty value.", capability: "vnd.inbuxa.expressions" }, +}; + +export const TAGS = { + is: "Exact match.", + contains: "Substring match.", + matches: "Wildcard match: `*` matches any sequence, `?` one character. Captures go to `${1}`, `${2}`...", + regex: "Regular expression match. Captures go to `${1}`, `${2}`... Requires \"regex\".", + value: "Relational comparison: \"gt\", \"ge\", \"lt\", \"le\", \"eq\" or \"ne\". Requires \"relational\".", + count: "Compares the number of values. Requires \"relational\".", + list: "Matches against an external list, such as \"addrbook:default\". Requires \"extlists\".", + comparator: "Sets the comparator: \"i;ascii-casemap\" (default), \"i;octet\" or \"i;ascii-numeric\".", + all: "Compares the whole address.", + localpart: "Compares the part before the @.", + domain: "Compares the part after the @.", + user: "Compares the user part of a subaddress (before +). Requires \"subaddress\".", + detail: "Compares the detail part of a subaddress (after +). Requires \"subaddress\".", + over: "True when the size is larger than the limit.", + under: "True when the size is smaller than the limit.", + copy: "Keeps the implicit keep in place. Requires \"copy\".", + flags: "Flags to store with the message. Requires \"imap4flags\".", + create: "Creates the mailbox if it does not exist. Requires \"mailbox\".", + specialuse: "Selects the mailbox by its special use, such as \"\\\\Junk\". Requires \"special-use\".", + mailboxid: "Selects the mailbox by id. Requires \"mailboxid\".", + days: "Minimum days between replies to the same sender.", + seconds: "Minimum seconds between replies or duplicates.", + subject: "Subject of the generated message.", + from: "From address of the generated message.", + addresses: "Your own addresses, so the reply is not sent to yourself.", + handle: "Name used to track replies or duplicates independently.", + mime: "For tests: look at MIME part headers. For vacation and replace: the text is a full MIME entity.", + anychild: "With `:mime`, look at every child part.", + type: "With `:mime`, compare only the media type.", + subtype: "With `:mime`, compare only the media subtype.", + contenttype: "With `:mime`, compare type/subtype.", + param: "With `:mime`, compare Content-Type parameters.", + index: "Selects the nth occurrence of a header field. Requires \"index\".", + last: "Counts `:index` from the end.", + lower: "Lowercases the value.", + upper: "Uppercases the value.", + lowerfirst: "Lowercases the first character.", + upperfirst: "Uppercases the first character.", + quotewildcard: "Escapes `*`, `?` and `\\` for use in :matches.", + quoteregex: "Escapes regex special characters.", + encodeurl: "Percent-encodes the value.", + length: "Replaces the value with its length.", + percent: "Uses the percentage spam score. Requires \"spamtestplus\".", + zone: "Time zone offset such as \"+0100\".", + originalzone: "Keeps the time zone from the header.", + raw: "Compares the raw body.", + content: "Compares body parts of the given media types.", + text: "Compares the decoded text body.", + importance: "\"1\" high, \"2\" normal, \"3\" low.", + message: "Notification text.", + options: "Method-specific notification options.", + personal: "Includes a personal script.", + global: "Includes a global script.", + once: "Includes the script only once.", + optional: "Does not fail when the script is missing.", + notify: "DSN notification: \"never\" or a list of \"success\", \"failure\", \"delay\". Requires \"redirect-dsn\".", + ret: "DSN return of content: \"full\" or \"hdrs\". Requires \"redirect-dsn\".", + bytimerelative: "Deliver-by time in seconds. Requires \"redirect-deliverby\".", + bytimeabsolute: "Deliver-by date. Requires \"redirect-deliverby\".", + bymode: "\"notify\" or \"return\" when the deliver-by time passes.", + bytrace: "Requests deliver-by trace information.", + uniqueid: "Custom id for the duplicate test.", + header: "Header field used as the duplicate id.", + first: "Maximum number of characters to extract.", + name: "Names a foreverypart loop.", + fcc: "Stores a copy of the generated message in a mailbox. Requires \"fcc\".", +}; + +export const DATE_PARTS = ["year", "month", "day", "date", "julian", "hour", "minute", "second", "time", "iso8601", "std11", "zone", "weekday"]; + +export const ENVIRONMENT_ITEMS = ["domain", "host", "location", "name", "phase", "remote-host", "remote-ip", "version"]; + +export const FUNCTIONS = { + trim: 1, trim_start: 1, trim_end: 1, len: 1, count: 1, is_empty: 1, is_number: 1, is_ascii: 1, + to_lowercase: 1, to_uppercase: 1, is_email: 1, thread_name: 1, html_to_text: 1, is_uppercase: 1, + is_lowercase: 1, has_digits: 1, count_spaces: 1, count_uppercase: 1, count_lowercase: 1, + count_chars: 1, dedup: 1, lines: 1, is_ip_addr: 1, is_ipv4_addr: 1, is_ipv6_addr: 1, winnow: 1, + sort: 2, email_part: 2, eq_ignore_case: 2, contains: 2, contains_ignore_case: 2, starts_with: 2, + ends_with: 2, uri_part: 2, split: 2, rsplit: 2, split_once: 2, rsplit_once: 2, strip_prefix: 2, + strip_suffix: 2, is_intersect: 2, substring: 3, split_n: 3, llm_prompt: 3, +}; + +export function lookup(word) { + const key = word.toLowerCase(); + if (CONTROL[key]) return { kind: "control", ...CONTROL[key] }; + if (ACTIONS[key]) return { kind: "action", ...ACTIONS[key] }; + if (TESTS[key]) return { kind: "test", ...TESTS[key] }; + return null; +} diff --git a/sieve-playground/sieve-language.js b/sieve-playground/sieve-language.js new file mode 100644 index 0000000..6141bc4 --- /dev/null +++ b/sieve-playground/sieve-language.js @@ -0,0 +1,384 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +import { ACTIONS, CONTROL, DATE_PARTS, ENVIRONMENT_ITEMS, FUNCTIONS, TAGS, TESTS, lookup } from "./sieve-docs.js"; + +export const LANGUAGE_ID = "sieve"; + +const CAPABILITY_ERROR = /Undeclared capability '([^']+)'/; + +function monarch() { + return { + ignoreCase: true, + defaultToken: "", + keywords: [...Object.keys(CONTROL), ...Object.keys(ACTIONS), "foreach"], + tests: Object.keys(TESTS), + tokenizer: { + root: [ + [/#.*$/, "comment"], + [/\/\*/, "comment", "@comment"], + [/text:/, { token: "string.heredoc", next: "@heredoc" }], + [/"/, { token: "string.quote", next: "@string" }], + [/:[A-Za-z_][A-Za-z0-9_-]*/, "attribute.name"], + [/\d+[KMGkmg]?\b/, "number"], + [/[A-Za-z_][A-Za-z0-9_.-]*/, { cases: { "@keywords": "keyword", "@tests": "type", "@default": "identifier" } }], + [/[{}()[\]]/, "@brackets"], + [/[;,]/, "delimiter"], + [/\s+/, "white"], + ], + comment: [ + [/[^/*]+/, "comment"], + [/\*\//, "comment", "@pop"], + [/[/*]/, "comment"], + ], + string: [ + [/\$\{[^}"]*\}/, "variable"], + [/[^"\\$]+/, "string"], + [/\\./, "string.escape"], + [/\$/, "string"], + [/"/, { token: "string.quote", next: "@pop" }], + ], + heredoc: [ + [/\.\s*$/, { token: "string.heredoc", next: "@pop" }], + [/.*$/, "string"], + ], + }, + }; +} + +const configuration = { + comments: { lineComment: "#", blockComment: ["/*", "*/"] }, + brackets: [["{", "}"], ["[", "]"], ["(", ")"]], + autoClosingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"', notIn: ["string", "comment"] }, + { open: "/*", close: " */", notIn: ["string"] }, + ], + surroundingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + ], + indentationRules: { + increaseIndentPattern: /\{[^}"]*$/, + decreaseIndentPattern: /^\s*\}/, + }, + folding: { markers: { start: /\{\s*$/, end: /^\s*\}/ } }, + wordPattern: /(-?\d*\.\d\w*)|([^\s`~!@#%^&*()=+[{\]}\\|;:'",<>/?]+)/g, +}; + +function docMarkdown(word, entry) { + const parts = [`\`\`\`sieve\n${entry.sig}\n\`\`\``, entry.doc]; + const links = []; + if (entry.capability) links.push(`Requires \`"${entry.capability}"\``); + if (entry.rfc) links.push(`[Specification](${entry.rfc})`); + if (links.length) parts.push(links.join(" · ")); + return parts.join("\n\n"); +} + +const isComment = (mode) => mode === "lineComment" || mode === "blockComment"; + +export class SieveScanner { + constructor() { + this.mode = "code"; + this.statement = ""; + this.variableOpen = false; + } + + feedLine(line, hasNewline = true) { + const startMode = this.mode; + let opens = 0; + let closes = 0; + + if (this.mode === "heredoc" && /^\.\s*$/.test(line)) { + this.mode = "code"; + this.variableOpen = false; + this.statement += line; + } else { + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + const start = i; + const mode = this.mode; + switch (this.mode) { + case "code": + if (ch === '"') { + this.mode = "string"; + this.variableOpen = false; + } else if (ch === "#") { + this.mode = "lineComment"; + } else if (ch === "/" && line[i + 1] === "*") { + this.mode = "blockComment"; + i++; + } else if (ch === ";" || ch === "{" || ch === "}") { + this.statement = ""; + if (ch === "{") opens++; + if (ch === "}") closes++; + continue; + } else if ((ch === "t" || ch === "T") && line.slice(i, i + 5).toLowerCase() === "text:" && !/[A-Za-z0-9_]/.test(line[i - 1] || "")) { + this.mode = "heredocHeader"; + i += 4; + } + break; + case "string": + case "heredoc": + if (ch === "\\" && this.mode === "string") { + i++; + } else if (ch === '"' && this.mode === "string") { + this.mode = "code"; + this.variableOpen = false; + } else if (ch === "$" && line[i + 1] === "{") { + this.variableOpen = true; + i++; + } else if (ch === "}") { + this.variableOpen = false; + } + break; + case "blockComment": + if (ch === "*" && line[i + 1] === "/") { + this.mode = "code"; + i++; + } + break; + default: + break; + } + if (!isComment(mode) && !isComment(this.mode)) this.statement += line.slice(start, i + 1); + } + if (hasNewline) { + if (!isComment(this.mode)) this.statement += "\n"; + if (this.mode === "lineComment") this.mode = "code"; + if (this.mode === "heredocHeader") this.mode = "heredoc"; + } + } + + return { startMode, opens, closes }; + } +} + +function context(model, position) { + const before = model.getValueInRange({ startLineNumber: 1, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column }); + const lines = before.split("\n"); + const scanner = new SieveScanner(); + lines.forEach((line, index) => scanner.feedLine(line, index < lines.length - 1)); + const mode = isComment(scanner.mode) ? "comment" : scanner.mode; + const statement = scanner.statement; + const inString = mode === "string" || mode === "heredoc"; + const command = (statement.match(/^\s*([A-Za-z_]+)/) || [])[1]?.toLowerCase() || ""; + const line = model.getLineContent(position.lineNumber).slice(0, position.column - 1); + return { statement, inString, inComment: mode === "comment", inVariable: inString && scanner.variableOpen, command, line }; +} + +function variableNames(model) { + const names = new Set(); + const pattern = /\b(?:set|let|global|extracttext)\s+(?::[A-Za-z]+\s+(?:\d+\s+)?)*(\[[^\]]*\]|"[^"]*")/gi; + for (const match of model.getValue().matchAll(pattern)) { + for (const name of match[1].matchAll(/"([^"]+)"/g)) names.add(name[1]); + } + return [...names]; +} + +function replaceRange(model, position) { + const word = model.getWordUntilPosition(position); + return { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; +} + +export function registerSieve(monaco, getSettings) { + monaco.languages.register({ id: LANGUAGE_ID, extensions: [".sieve", ".siv"], aliases: ["Sieve"] }); + monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, monarch()); + monaco.languages.setLanguageConfiguration(LANGUAGE_ID, configuration); + + const { CompletionItemKind: Kind, CompletionItemInsertTextRule: Rule } = monaco.languages; + + monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, { + triggerCharacters: [":", '"', "$", "{"], + provideCompletionItems(model, position, completionContext) { + const ctx = context(model, position); + const range = replaceRange(model, position); + const settings = getSettings(); + const items = []; + + if (ctx.inComment) return { suggestions: [] }; + if (ctx.inVariable) { + for (const name of variableNames(model)) items.push({ label: name, kind: Kind.Variable, insertText: name, range }); + return { suggestions: items }; + } + const trigger = completionContext?.triggerCharacter; + if ((trigger === "$" || trigger === "{") || (trigger === '"' && !ctx.inString)) return { suggestions: [] }; + + if (ctx.inString) { + if (ctx.command === "require" || /\bihave\b/.test(ctx.statement)) { + for (const capability of settings.capabilities || []) { + items.push({ label: capability, kind: Kind.Module, insertText: capability, range }); + } + } else if (/^\s*(fileinto|mailboxexists)\b/.test(ctx.statement) && !/:(flags|specialuse|mailboxid)\s+"[^"]*$/.test(ctx.statement)) { + for (const mailbox of settings.mailboxes || []) { + items.push({ label: mailbox.name, kind: Kind.Folder, insertText: mailbox.name, detail: mailbox.specialUse?.join(" "), range }); + } + } else if (/\benvironment\b/.test(ctx.statement)) { + const names = new Set([...ENVIRONMENT_ITEMS, ...(settings.environment || []).map((item) => item.name)]); + for (const name of names) items.push({ label: name, kind: Kind.Constant, insertText: name, range }); + } else if (/\b(currentdate|date)\b/.test(ctx.statement)) { + for (const part of DATE_PARTS) items.push({ label: part, kind: Kind.EnumMember, insertText: part, range }); + } else if (/\b(let|eval|while)\b/.test(ctx.statement)) { + for (const [name, arity] of Object.entries(FUNCTIONS)) { + const args = Array.from({ length: arity }, (_, i) => `\${${i + 1}}`).join(", "); + items.push({ label: name, kind: Kind.Function, detail: `${arity} argument${arity === 1 ? "" : "s"}`, insertText: `${name}(${args})`, insertTextRules: Rule.InsertAsSnippet, range }); + } + for (const name of ["header.subject", "header.from.addr", "header.from.name", "header.to[*].addr[*]", "envelope.from", "envelope.to", "env.remote_ip"]) { + items.push({ label: name, kind: Kind.Variable, insertText: name, range }); + } + } else if (/\b(addflag|setflag|removeflag|hasflag)\b|:flags\s+/.test(ctx.statement)) { + for (const flag of ["\\\\Seen", "\\\\Flagged", "\\\\Answered", "\\\\Deleted", "\\\\Draft", "$Junk", "$NotJunk"]) { + items.push({ label: flag, kind: Kind.EnumMember, insertText: flag, range }); + } + } + return { suggestions: items }; + } + + if (trigger === ":" && !/:[A-Za-z_-]*$/.test(ctx.line)) return { suggestions: [] }; + if (/:[A-Za-z_-]*$/.test(ctx.line)) { + for (const [tag, doc] of Object.entries(TAGS)) { + items.push({ label: `:${tag}`, kind: Kind.Property, insertText: tag, documentation: { value: doc }, range }); + } + return { suggestions: items }; + } + + const snippets = { + if: "if ${1:header :contains \"Subject\" \"${2:text}\"} {\n\t$0\n}", + require: "require [\"${1:fileinto}\"];", + fileinto: "fileinto \"${1:INBOX}\";", + redirect: "redirect \"${1:user@example.org}\";", + vacation: "vacation :days ${1:7} :subject \"${2:Out of office}\" text:\n${3:I am away.}\n.\n;", + foreverypart: "foreverypart {\n\t$0\n}", + while: "while \"${1:i < 10}\" {\n\t$0\n}", + set: "set \"${1:name}\" \"${2:value}\";", + let: "let \"${1:name}\" \"${2:expression}\";", + }; + for (const [group, kind] of [[CONTROL, Kind.Keyword], [ACTIONS, Kind.Function], [TESTS, Kind.Interface]]) { + for (const [name, entry] of Object.entries(group)) { + items.push({ + label: name, + kind, + detail: entry.sig, + documentation: { value: docMarkdown(name, entry) }, + insertText: snippets[name] || name, + insertTextRules: snippets[name] ? Rule.InsertAsSnippet : undefined, + range, + }); + } + } + return { suggestions: items }; + }, + }); + + monaco.languages.registerHoverProvider(LANGUAGE_ID, { + provideHover(model, position) { + const word = model.getWordAtPosition(position); + if (!word) return null; + const line = model.getLineContent(position.lineNumber); + const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); + if (line[word.startColumn - 2] === ":") { + const doc = TAGS[word.word.toLowerCase()]; + if (!doc) return null; + return { range, contents: [{ value: `**:${word.word}**` }, { value: doc }] }; + } + const token = monaco.editor.tokenize(line, LANGUAGE_ID)[0]?.filter((t) => t.offset <= word.startColumn - 1).pop(); + if (token && /string|comment/.test(token.type)) return null; + const entry = lookup(word.word); + if (!entry) return null; + return { range, contents: [{ value: docMarkdown(word.word, entry) }] }; + }, + }); + + monaco.languages.registerCodeActionProvider(LANGUAGE_ID, { + provideCodeActions(model, _range, ctx) { + const actions = []; + for (const marker of ctx.markers) { + const match = CAPABILITY_ERROR.exec(marker.message); + if (!match) continue; + const edit = requireEdit(monaco, model, match[1]); + if (!edit) continue; + actions.push({ + title: `Add "${match[1]}" to require`, + kind: "quickfix", + diagnostics: [marker], + isPreferred: true, + edit: { edits: [{ resource: model.uri, textEdit: edit, versionId: model.getVersionId() }] }, + }); + } + return { actions, dispose() {} }; + }, + }); + + monaco.languages.registerDocumentFormattingEditProvider(LANGUAGE_ID, { + provideDocumentFormattingEdits(model, options) { + const unit = options.insertSpaces ? " ".repeat(options.tabSize) : "\t"; + const scanner = new SieveScanner(); + let depth = 0; + const lines = model.getLinesContent().map((raw) => { + const trimmed = raw.trim(); + const leadingCloses = scanner.mode === "code" ? (trimmed.match(/^\}+/) || [""])[0].length : 0; + const { startMode, opens, closes } = scanner.feedLine(raw); + if (startMode !== "code") return raw; + const indent = Math.max(0, depth - leadingCloses); + depth = Math.max(0, depth + opens - closes); + return trimmed === "" ? "" : unit.repeat(indent) + trimmed; + }); + return [{ range: model.getFullModelRange(), text: lines.join(model.getEOL()) }]; + }, + }); +} + +export function requireEdit(monaco, model, capability) { + const text = model.getValue(); + const quoted = `"${capability}"`; + const listMatch = /require\s*\[([^\]]*)\]\s*;/i.exec(text); + if (listMatch) { + const closeOffset = listMatch.index + listMatch[0].lastIndexOf("]"); + const inner = listMatch[1].trim(); + const pos = model.getPositionAt(closeOffset); + return { range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column), text: inner ? `, ${quoted}` : quoted }; + } + const singleMatch = /require\s*("[^"]*")\s*;/i.exec(text); + if (singleMatch) { + const start = model.getPositionAt(singleMatch.index); + const end = model.getPositionAt(singleMatch.index + singleMatch[0].length); + return { range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), text: `require [${singleMatch[1]}, ${quoted}];` }; + } + let line = 1; + const lines = model.getLinesContent(); + while (line <= lines.length && /^\s*(#.*)?$/.test(lines[line - 1])) line++; + return { range: new monaco.Range(line, 1, line, 1), text: `require [${quoted}];\n\n` }; +} + +export function markersFor(monaco, model, diagnostics) { + return diagnostics.map((diagnostic) => { + const known = diagnostic.line > 0 && diagnostic.line <= model.getLineCount(); + const line = known ? diagnostic.line : 1; + const maxColumn = model.getLineMaxColumn(line); + const startColumn = known ? Math.min(Math.max(diagnostic.column, 1), maxColumn) : 1; + return { + severity: diagnostic.severity === "warning" ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Error, + message: known ? diagnostic.message : `${diagnostic.message} (position unknown)`, + startLineNumber: line, + endLineNumber: line, + startColumn: startColumn === maxColumn ? Math.max(1, startColumn - 1) : startColumn, + endColumn: maxColumn, + source: "sieve", + }; + }); +} diff --git a/sieve-playground/sieve_playground.wasm b/sieve-playground/sieve_playground.wasm new file mode 100755 index 0000000..0a11cf0 Binary files /dev/null and b/sieve-playground/sieve_playground.wasm differ diff --git a/sieve-playground/store.js b/sieve-playground/store.js new file mode 100644 index 0000000..ea53358 --- /dev/null +++ b/sieve-playground/store.js @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +const DB_NAME = "inbuxa-sieve-playground"; +const DB_VERSION = 1; +const STORE = "workspaces"; +const PREFS_KEY = "inbuxa-sieve.prefs"; +const FALLBACK_KEY = "inbuxa-sieve.workspaces"; +const WELCOME_KEY = "inbuxa-sieve.welcome-dismissed"; + +function openDb() { + return new Promise((resolve, reject) => { + if (!("indexedDB" in self)) { + reject(new Error("IndexedDB unavailable")); + return; + } + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE, { keyPath: "id" }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function wrap(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +class IdbStore { + constructor(db) { + this.db = db; + } + + tx(mode) { + return this.db.transaction(STORE, mode).objectStore(STORE); + } + + list() { + return wrap(this.tx("readonly").getAll()); + } + + get(id) { + return wrap(this.tx("readonly").get(id)); + } + + put(workspace) { + return wrap(this.tx("readwrite").put(workspace)); + } + + delete(id) { + return wrap(this.tx("readwrite").delete(id)); + } +} + +class MemoryStore { + constructor() { + this.items = new Map(); + try { + for (const ws of JSON.parse(localStorage.getItem(FALLBACK_KEY) || "[]")) { + this.items.set(ws.id, ws); + } + } catch (_) { + this.items.clear(); + } + } + + persist() { + try { + localStorage.setItem(FALLBACK_KEY, JSON.stringify([...this.items.values()])); + } catch (_) { + return; + } + } + + async list() { + return [...this.items.values()]; + } + + async get(id) { + return this.items.get(id); + } + + async put(workspace) { + this.items.set(workspace.id, workspace); + this.persist(); + } + + async delete(id) { + this.items.delete(id); + this.persist(); + } +} + +export async function openStore() { + try { + return new IdbStore(await openDb()); + } catch (_) { + return new MemoryStore(); + } +} + +export function loadPrefs() { + try { + return JSON.parse(localStorage.getItem(PREFS_KEY) || "{}"); + } catch (_) { + return {}; + } +} + +export function savePrefs(prefs) { + try { + localStorage.setItem(PREFS_KEY, JSON.stringify({ ...loadPrefs(), ...prefs })); + } catch (_) { + return; + } +} + +export function isWelcomeDismissed() { + try { + return localStorage.getItem(WELCOME_KEY) !== null; + } catch (_) { + return false; + } +} + +export function dismissWelcome() { + try { + localStorage.setItem(WELCOME_KEY, new Date().toISOString()); + } catch (_) { + return; + } +} + +export function newId() { + if (self.crypto && crypto.randomUUID) return crypto.randomUUID(); + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} diff --git a/sieve-playground/style.css b/sieve-playground/style.css new file mode 100644 index 0000000..2ffbc31 --- /dev/null +++ b/sieve-playground/style.css @@ -0,0 +1,1506 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f0f2f5; + --surface-3: #e6e9ee; + --border: #dde1e7; + --text: #1c1f26; + --text-2: #5a6272; + --text-3: #8a91a0; + --accent: #4f5bd5; + --accent-2: #3f4ac0; + --accent-soft: #e8eafc; + --ok: #1f8a4c; + --ok-soft: #e3f5ea; + --warn: #a86400; + --warn-soft: #fdf1dc; + --err: #c6313a; + --err-soft: #fbe6e7; + --shadow: 0 8px 28px rgba(20, 24, 40, 0.14); + --radius: 8px; + --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --topbar-h: 52px; + --statusbar-h: 26px; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14161b; + --surface: #1b1d24; + --surface-2: #22252e; + --surface-3: #2b2f3a; + --border: #2f3340; + --text: #e6e8ee; + --text-2: #a4abba; + --text-3: #737b8c; + --accent: #8b94ff; + --accent-2: #a3aaff; + --accent-soft: #2a2d4d; + --ok: #5fd08f; + --ok-soft: #1c3527; + --warn: #f0b458; + --warn-soft: #3a2e1a; + --err: #ff7b82; + --err-soft: #3d2023; + --shadow: 0 10px 32px rgba(0, 0, 0, 0.5); + color-scheme: dark; + } +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + font: 14px/1.45 var(--sans); + overflow: hidden; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +a { + color: var(--accent); +} + +svg { + width: 16px; + height: 16px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +[hidden] { + display: none !important; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + cursor: pointer; + white-space: nowrap; +} + +.btn:hover { + background: var(--surface-2); +} + +.btn.ghost { + border-color: transparent; + background: transparent; +} + +.btn.ghost:hover { + background: var(--surface-2); +} + +.btn.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; + font-weight: 600; +} + +.btn.primary:hover { + background: var(--accent-2); +} + +.btn.primary svg { + fill: currentColor; +} + +.btn.primary.stale { + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.btn:disabled { + opacity: 0.55; + cursor: default; +} + +.icon-btn { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-2); + cursor: pointer; +} + +.icon-btn:hover { + background: var(--surface-3); + color: var(--text); +} + +.topbar { + display: flex; + align-items: center; + gap: 10px; + height: var(--topbar-h); + padding: 0 12px 0 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex: none; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + text-decoration: none; +} + +.brand-mark { + width: 22px; + height: 22px; + color: var(--accent); + stroke: none; +} + +.brand-mark path { + fill: currentColor; +} + +.brand-name { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.brand-by { + color: var(--text-3); + font-size: 12px; + text-decoration: none; + margin-top: 3px; +} + +.brand-by:hover { + color: var(--accent); +} + +.toolbar { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; + margin-left: 12px; +} + +.toolbar-gap { + flex: 1; +} + +.menu { + position: relative; +} + +.menu-label { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; +} + +.chevron { + width: 12px; + height: 12px; + color: var(--text-3); +} + +.menu-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 50; + min-width: 230px; + max-height: 70vh; + overflow: auto; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.menu-panel.wide { + width: 340px; +} + +.menu-panel button { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 7px 10px; + border: 0; + border-radius: 5px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.menu-panel button:hover, +.menu-panel button:focus-visible { + background: var(--surface-2); + outline: none; +} + +.menu-panel button.current { + color: var(--accent); + font-weight: 600; +} + +.menu-panel .item-sub { + color: var(--text-3); + font-size: 12px; + font-weight: 400; +} + +.menu-section { + padding: 6px 10px 4px; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.menu-sep { + height: 1px; + margin: 6px 4px; + background: var(--border); +} + +.mobile-tabs { + display: none; +} + +.workbench { + display: grid; + grid-template-columns: var(--col-a, 1fr) 6px var(--col-b, 1fr) 6px var(--col-c, 1fr); + flex: 1; + min-height: 0; + padding: 8px; + gap: 0; +} + +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.panel-head { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + padding: 0 6px 0 10px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); + flex: none; +} + +.panel-title { + display: inline-flex; + align-items: center; + gap: 7px; + margin-right: 6px; + font-weight: 600; + white-space: nowrap; +} + +.step { + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + font-weight: 700; +} + +.tabs { + display: flex; + align-items: stretch; + gap: 2px; + height: 100%; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + flex: 1; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 6px 0; + padding: 0 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-2); + cursor: pointer; + white-space: nowrap; +} + +.tab:hover { + background: var(--surface-3); + color: var(--text); +} + +.tab.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 0 0 1px var(--border); +} + +.tab .tab-close { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 3px; + color: var(--text-3); + visibility: hidden; +} + +.tab:hover .tab-close, +.tab.active .tab-close { + visibility: visible; +} + +.tab .tab-close:hover { + background: var(--surface-3); + color: var(--text); +} + +.tab .tab-close svg { + width: 10px; + height: 10px; + stroke-width: 2; +} + +.tab .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--err); +} + +.tab .tab-kind { + color: var(--text-3); + font-size: 11px; +} + +.count:empty { + display: none; +} + +.count { + min-width: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + font-weight: 700; + text-align: center; +} + +.gutter { + cursor: col-resize; + position: relative; +} + +.gutter::after { + content: ""; + position: absolute; + inset: 30% 2px; + border-radius: 2px; + background: transparent; + transition: background 0.15s; +} + +.gutter:hover::after, +.gutter:focus-visible::after, +.gutter.dragging::after { + background: var(--accent); + outline: none; +} + +.editor-host { + position: relative; + flex: 1; + min-height: 0; +} + +.drop-hint { + position: absolute; + inset: 12px; + z-index: 5; + display: none; + place-items: center; + border: 2px dashed var(--accent); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent-soft) 85%, transparent); + color: var(--accent); + font-weight: 600; + pointer-events: none; +} + +body.dragging-file .drop-hint { + display: grid; +} + +.problems { + flex: none; + max-height: 30%; + overflow: auto; + border-top: 1px solid var(--border); + font-size: 13px; +} + +.problems[data-state="idle"], +.problems:empty { + display: none; +} + +.problem { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 6px 12px; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.problem:hover { + background: var(--surface-2); +} + +.problem .sev { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--err); + transform: translateY(-1px); +} + +.problem.warning .sev { + background: var(--warn); +} + +.problem .where { + flex: none; + color: var(--text-3); + font-family: var(--mono); + font-size: 12px; +} + +.problem .fix { + margin-left: auto; + flex: none; + color: var(--accent); + font-size: 12px; +} + +.envelope { + flex: none; + border-top: 1px solid var(--border); + background: var(--surface-2); + font-size: 13px; +} + +.envelope summary { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 12px; + cursor: pointer; + list-style: none; + font-weight: 600; +} + +.envelope summary::-webkit-details-marker { + display: none; +} + +.envelope summary::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid var(--text-3); + border-bottom: 1.5px solid var(--text-3); + transform: rotate(-45deg); + transition: transform 0.15s; +} + +.envelope[open] summary::before { + transform: rotate(45deg); +} + +.envelope-summary { + overflow: hidden; + color: var(--text-3); + font-family: var(--mono); + font-size: 12px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.envelope-grid { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 6px 10px; + padding: 0 12px 10px; +} + +.envelope-grid label { + color: var(--text-2); + font-family: var(--mono); + font-size: 11px; +} + +input[type="text"], +input[type="number"], +input[type="datetime-local"], +select, +textarea { + width: 100%; + min-width: 0; + height: 30px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface); + font-family: var(--mono); + font-size: 12.5px; +} + +textarea { + height: auto; + min-height: 64px; + padding: 6px 8px; + resize: vertical; + line-height: 1.5; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + outline: 2px solid var(--accent-soft); +} + +.result-body { + position: relative; + flex: 1; + min-height: 0; +} + +.result-view { + position: absolute; + inset: 0; + display: none; + overflow: auto; +} + +.result-view.active { + display: block; +} + +.result-view[data-view="messages"].active { + display: flex; + flex-direction: column; +} + +.empty { + display: grid; + place-items: center; + height: 100%; + padding: 24px; + color: var(--text-3); + text-align: center; +} + +.empty strong { + display: block; + margin-bottom: 4px; + color: var(--text-2); + font-size: 15px; +} + +.empty kbd, +.kbd { + padding: 1px 5px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--surface-2); + font-family: var(--mono); + font-size: 11px; +} + +.outcome { + margin: 12px; + padding: 12px 14px; + border-radius: var(--radius); + background: var(--accent-soft); +} + +.outcome-title { + margin-bottom: 6px; + color: var(--text-2); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.outcome-lines { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 14px; +} + +.outcome-lines b { + font-weight: 600; +} + +.outcome-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.outcome-actions .btn { + height: 28px; + font-size: 13px; +} + +.banner { + display: flex; + gap: 10px; + margin: 12px 12px 0; + padding: 10px 12px; + border-radius: var(--radius); + background: var(--err-soft); + color: var(--err); +} + +.banner.warn { + background: var(--warn-soft); + color: var(--warn); +} + +.banner button { + margin-left: auto; + border: 0; + background: transparent; + color: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} + +.timeline { + position: relative; + margin: 0; + padding: 4px 12px 16px 12px; + list-style: none; +} + +.event { + position: relative; + display: grid; + grid-template-columns: 30px 1fr; + gap: 10px; + padding: 8px 0; +} + +.event::before { + content: ""; + position: absolute; + top: 38px; + bottom: -8px; + left: 14.5px; + width: 1px; + background: var(--border); +} + +.event:last-child::before { + display: none; +} + +.event-icon { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--surface-2); + color: var(--text-2); + z-index: 1; +} + +.event-card { + min-width: 0; + padding: 7px 10px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.event.final .event-card { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.event.muted .event-card { + border-style: dashed; + opacity: 0.85; +} + +.event-summary { + font-weight: 600; + overflow-wrap: anywhere; +} + +.event-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} + +.chip { + display: inline-flex; + gap: 4px; + max-width: 100%; + padding: 1px 7px; + border-radius: 10px; + background: var(--surface-2); + font-size: 12px; + overflow-wrap: anywhere; +} + +.chip .k { + color: var(--text-3); +} + +.chip.tag { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} + +.chip.warn { + background: var(--warn-soft); + color: var(--warn); +} + +.chip-link { + border: 0; + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; + cursor: pointer; +} + +.chip-link:hover { + text-decoration: underline; +} + +.event[data-kind="keep"] .event-icon, +.event[data-kind="fileinto"] .event-icon { + background: var(--ok-soft); + color: var(--ok); +} + +.event[data-kind="discard"] .event-icon, +.event[data-kind="reject"] .event-icon { + background: var(--err-soft); + color: var(--err); +} + +.event[data-kind="redirect"] .event-icon, +.event[data-kind="vacation"] .event-icon, +.event[data-kind="notification"] .event-icon, +.event[data-kind="notify"] .event-icon { + background: var(--accent-soft); + color: var(--accent); +} + +.event[data-kind="created"] .event-icon, +.event[data-kind="envelope"] .event-icon { + background: var(--warn-soft); + color: var(--warn); +} + +.message-list { + display: flex; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex: none; +} + +.message-pill { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 150px; + max-width: 240px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + text-align: left; + cursor: pointer; +} + +.message-pill.active { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.message-pill .role { + color: var(--accent); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.message-pill .subj { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + flex: none; +} + +.segmented { + display: inline-flex; + padding: 2px; + border-radius: 6px; + background: var(--surface-2); +} + +.segmented button { + height: 26px; + padding: 0 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-2); + cursor: pointer; +} + +.segmented button.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.segmented button:disabled { + opacity: 0.45; + cursor: default; +} + +.message-frame { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; +} + +.message-frame.code { + overflow: hidden; +} + +.viewer-host { + position: absolute; + inset: 0; +} + +.preview { + padding: 12px 14px; +} + +.headers-table { + width: 100%; + margin-bottom: 12px; + border-collapse: collapse; + font-size: 13px; +} + +.headers-table th { + width: 1%; + padding: 2px 12px 2px 0; + color: var(--text-3); + font-weight: 500; + text-align: left; + vertical-align: top; + white-space: nowrap; +} + +.headers-table td { + padding: 2px 0; + overflow-wrap: anywhere; +} + +.headers-table tr.changed th, +.headers-table tr.changed td { + color: var(--ok); +} + +.headers-toggle { + margin-bottom: 10px; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + padding: 0; + font-size: 12px; +} + +.body-text { + margin: 0; + padding: 12px; + border-radius: 6px; + background: var(--surface-2); + font-family: var(--mono); + font-size: 12.5px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.body-html { + width: 100%; + min-height: 320px; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; +} + +.attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.attachment { + display: inline-flex; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; +} + +.attachment .muted { + color: var(--text-3); +} + +.vars { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.vars th, +.vars td { + padding: 6px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.vars th { + position: sticky; + top: 0; + background: var(--surface-2); + color: var(--text-2); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.vars td { + font-family: var(--mono); + font-size: 12.5px; + overflow-wrap: anywhere; +} + +.vars-note { + padding: 10px 12px; + color: var(--text-3); + font-size: 12px; +} + +.statusbar { + display: flex; + align-items: center; + gap: 14px; + height: var(--statusbar-h); + padding: 0 12px; + border-top: 1px solid var(--border); + background: var(--surface); + color: var(--text-2); + font-size: 12px; + flex: none; + white-space: nowrap; + overflow: hidden; +} + +.status-item { + display: inline-flex; + align-items: center; + gap: 6px; + color: inherit; + text-decoration: none; +} + +a.status-item:hover { + color: var(--accent); +} + +.status-item[data-state]::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-3); +} + +.status-item[data-state="ready"]::before { + background: var(--ok); +} + +.status-item[data-state="error"]::before { + background: var(--err); +} + +.status-item[data-state="busy"]::before { + background: var(--warn); +} + +.status-link { + padding: 0; + border: 0; + background: transparent; + font-size: inherit; + cursor: pointer; +} + +.status-link:hover { + color: var(--accent); +} + +.status-link svg { + width: 12px; + height: 12px; +} + +.welcome-points { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; + font-size: 13.5px; +} + +.welcome-points li { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.welcome-points svg { + margin-top: 2px; + color: var(--accent); +} + +.status-spacer { + flex: 1; +} + + +.drawer-backdrop { + position: fixed; + inset: 0; + z-index: 80; + background: rgba(10, 12, 20, 0.35); +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 90; + display: flex; + flex-direction: column; + width: min(520px, 100vw); + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.drawer-head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border); +} + +.drawer-head h2 { + margin: 0; + font-size: 17px; +} + +.drawer-sub { + color: var(--text-3); + font-size: 12px; +} + +.drawer-head .icon-btn { + margin-left: auto; +} + +.drawer-body { + flex: 1; + overflow: auto; + padding: 4px 16px 16px; +} + +.drawer-foot { + display: flex; + gap: 6px; + padding: 10px 16px; + border-top: 1px solid var(--border); +} + +.settings-group { + border-bottom: 1px solid var(--border); +} + +.settings-group summary { + padding: 12px 0; + cursor: pointer; + font-weight: 600; +} + +.settings-group summary .item-sub { + margin-left: 6px; + color: var(--text-3); + font-size: 12px; + font-weight: 400; +} + +.settings-fields { + display: grid; + gap: 10px; + padding-bottom: 14px; +} + +.field { + display: grid; + gap: 4px; +} + +.field > span { + color: var(--text-2); + font-size: 12.5px; +} + +.field small { + color: var(--text-3); + font-size: 11.5px; +} + +.field-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; +} + +.caps { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 12px; +} + +.caps .check { + font-family: var(--mono); + font-size: 12px; +} + +.caps-actions { + display: flex; + gap: 6px; +} + +.caps-actions button { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + padding: 0; + font-size: 12px; +} + +.dialog { + width: min(560px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} + +.dialog::backdrop { + background: rgba(10, 12, 20, 0.4); +} + +.dialog form { + display: grid; + gap: 12px; + padding: 18px 20px; +} + +.dialog h2 { + margin: 0; + font-size: 17px; +} + +.dialog-text, +.dialog-note, +.dialog-warning { + margin: 0; + color: var(--text-2); + font-size: 13px; +} + +.dialog-note { + color: var(--text-3); +} + +.dialog-warning { + color: var(--warn); +} + +.share-row { + display: flex; + gap: 6px; +} + +.share-row input { + height: 32px; +} + +.dialog-foot { + display: flex; + gap: 6px; +} + +.toast { + position: fixed; + bottom: calc(var(--statusbar-h) + 14px); + left: 50%; + z-index: 100; + max-width: calc(100vw - 32px); + padding: 9px 14px; + border-radius: var(--radius); + background: var(--text); + color: var(--bg); + box-shadow: var(--shadow); + transform: translateX(-50%); + font-size: 13px; +} + +@media (max-width: 900px) { + .hide-narrow, + .brand-by { + display: none; + } + + .topbar { + padding: 0 8px 0 12px; + } + + .toolbar { + margin-left: 4px; + } + + .menu-label { + max-width: 110px; + } + + .mobile-tabs { + display: flex; + gap: 4px; + padding: 6px 8px 0; + flex: none; + } + + .mobile-tabs button { + flex: 1; + height: 32px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text-2); + cursor: pointer; + } + + .mobile-tabs button.active { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; + } + + .workbench { + display: flex; + padding: 6px 8px 8px; + } + + .gutter { + display: none; + } + + .panel { + display: none; + flex: 1; + } + + .panel.active { + display: flex; + } + + .engine-link { + display: none; + } + + .caps, + .field-row { + grid-template-columns: minmax(0, 1fr); + } + + .menu-panel.wide { + width: min(340px, calc(100vw - 24px)); + } +} + +@media (max-width: 520px) { + #compile-status, + #run-stats { + display: none; + } + + .brand-name, + .chevron { + display: none; + } + + .topbar { + gap: 4px; + } + + .toolbar { + gap: 0; + margin-left: 0; + } + + .menu-trigger, + .toolbar .btn.ghost { + padding: 0 7px; + } + + .menu-label { + max-width: 84px; + } + + #run-button { + padding: 0 10px; + } +} diff --git a/sieve-playground/worker.js b/sieve-playground/worker.js new file mode 100644 index 0000000..b4ced53 --- /dev/null +++ b/sieve-playground/worker.js @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +// inbuxa: the engine is the server's sieve-rs built for wasm32-wasip1 (see +// build.sh), so this worker gives it the few WASI calls it makes. The clock +// is the point: currentdate, and the Date headers of the replies a script +// writes, read the playground's current time from it. + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +let memory; +let engine; +let panicMessage = null; +// Seconds since the epoch the clock reports, or null for the real clock. +let clockSeconds = null; + +const view = () => new DataView(memory.buffer); +const bytes = (ptr, len) => new Uint8Array(memory.buffer, ptr, len); + +const SUCCESS = 0; +const CLOCK_MONOTONIC = 1; + +const wasi = { + clock_time_get(clockId, _precision, timePtr) { + const ms = + clockId === CLOCK_MONOTONIC ? performance.now() : clockSeconds !== null ? clockSeconds * 1000 : Date.now(); + view().setBigUint64(timePtr, BigInt(Math.round(ms)) * 1000000n, true); + return SUCCESS; + }, + random_get(ptr, len) { + for (let offset = 0; offset < len; offset += 65536) { + crypto.getRandomValues(bytes(ptr + offset, Math.min(65536, len - offset))); + } + return SUCCESS; + }, + environ_sizes_get(countPtr, sizePtr) { + view().setUint32(countPtr, 0, true); + view().setUint32(sizePtr, 0, true); + return SUCCESS; + }, + environ_get() { + return SUCCESS; + }, + fd_write(_fd, iovs, iovsLen, writtenPtr) { + const v = view(); + let written = 0; + let text = ""; + for (let i = 0; i < iovsLen; i++) { + const ptr = v.getUint32(iovs + i * 8, true); + const len = v.getUint32(iovs + i * 8 + 4, true); + text += decoder.decode(bytes(ptr, len)); + written += len; + } + v.setUint32(writtenPtr, written, true); + if (text.trim()) console.warn(text.trim()); + return SUCCESS; + }, + proc_exit(code) { + throw new WebAssembly.RuntimeError(`The engine exited with code ${code}`); + }, +}; + +const playground = { + panicked(ptr, len) { + panicMessage = decoder.decode(bytes(ptr, len).slice()); + }, +}; + +async function load() { + const url = new URL("./sieve_playground.wasm", import.meta.url); + const imports = { wasi_snapshot_preview1: wasi, playground }; + let instance; + try { + ({ instance } = await WebAssembly.instantiateStreaming(fetch(url), imports)); + } catch { + // Served without application/wasm: compile from the bytes instead. + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status} loading the engine`); + ({ instance } = await WebAssembly.instantiate(await response.arrayBuffer(), imports)); + } + engine = instance.exports; + memory = engine.memory; + engine.playground_init(); +} + +function put(data) { + const ptr = engine.playground_alloc(data.length); + bytes(ptr, data.length).set(data); + return [ptr, data.length]; +} + +function call(op, payload) { + const [opPtr, opLen] = put(encoder.encode(op)); + const [payloadPtr, payloadLen] = put(encoder.encode(payload === undefined ? "null" : JSON.stringify(payload))); + const packed = BigInt.asUintN(64, engine.playground_call(opPtr, opLen, payloadPtr, payloadLen)); + const ptr = Number(packed >> 32n); + const len = Number(packed & 0xffffffffn); + const text = decoder.decode(bytes(ptr, len).slice()); + engine.playground_free(ptr, len); + const reply = JSON.parse(text); + if ("error" in reply) throw new Error(reply.error); + return reply.ok; +} + +const ops = { + version: () => call("version"), + capabilities: () => call("capabilities"), + defaults: () => call("defaults"), + compile: (request) => call("compile", request), + run: (request) => { + const currentTime = request?.settings?.currentTime; + clockSeconds = Number.isFinite(currentTime) ? currentTime : null; + try { + return call("run", request); + } finally { + clockSeconds = null; + } + }, +}; + +const ready = load().then( + () => { + self.postMessage({ type: "ready" }); + return true; + }, + (err) => { + self.postMessage({ type: "init-error", error: String(err && err.message ? err.message : err) }); + return false; + }, +); + +self.onmessage = async (event) => { + const { id, op, payload } = event.data; + if (!(await ready)) return; + try { + const fn = ops[op]; + if (!fn) throw new Error(`Unknown operation ${op}`); + const result = fn(payload); + self.postMessage({ id, result }); + } catch (err) { + const fatal = err instanceof WebAssembly.RuntimeError; + const message = fatal && panicMessage ? panicMessage : String(err && err.message ? err.message : err); + self.postMessage({ id, error: message, fatal }); + } +}; diff --git a/src/components/forms/SievepadButton.tsx b/src/components/forms/SievepadButton.tsx index dd935da..d83ca49 100644 --- a/src/components/forms/SievepadButton.tsx +++ b/src/components/forms/SievepadButton.tsx @@ -1,97 +1,48 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Bug } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Label } from '@/components/ui/label'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { toast } from '@/hooks/use-toast'; -import { dismissSievepadWarning, isSievepadWarningDismissed, openInSievepad } from '@/lib/sievepad'; +import { openInSievepad } from '@/lib/sievepad'; interface SievepadButtonProps { scriptName: string; source: string; } +// inbuxa: the playground ships with the console, so the script goes nowhere +// else and there is nothing to warn about first. export function SievepadButton({ scriptName, source }: SievepadButtonProps) { const { t } = useTranslation(); - const [warningOpen, setWarningOpen] = useState(false); - const [dontShowAgain, setDontShowAgain] = useState(false); const open = () => { openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source).catch(() => { - toast({ title: t('sievepad.failed', 'Failed to open Sievepad.'), variant: 'destructive' }); + toast({ title: t('sievepad.failed', "Couldn't open the Sieve playground."), variant: 'destructive' }); }); }; - const handleClick = () => { - if (isSievepadWarningDismissed()) { - open(); - } else { - setDontShowAgain(false); - setWarningOpen(true); - } - }; - - const handleContinue = () => { - if (dontShowAgain) dismissSievepadWarning(); - setWarningOpen(false); - open(); - }; - return ( - <> -
- -
- - - - {t('sievepad.warningTitle', 'Debug in Sievepad')} - - {t( - 'sievepad.warningDescription', - 'A new tab will open sievepad.com with a copy of this script. Sievepad compiles and runs the script entirely in your browser: nothing is uploaded to or stored on any server.', - )} - - -
- setDontShowAgain(checked === true)} - /> - -
- - - - -
-
- +
+ +
); } diff --git a/src/i18n/en.json b/src/i18n/en.json index 090a3af..2598671 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -387,11 +387,9 @@ }, "sievepad": { "debug": "Debug", + "debugHint": "Test a copy of this script in the Sieve playground", "defaultName": "Sieve script", - "dontShowAgain": "Don't show this again", - "failed": "Failed to open Sievepad.", - "warningDescription": "A new tab will open sievepad.com with a copy of this script. Sievepad compiles and runs the script entirely in your browser: nothing is uploaded to or stored on any server.", - "warningTitle": "Debug in Sievepad" + "failed": "Couldn't open the Sieve playground." }, "toggleTheme": "Toggle theme", "tracing": { diff --git a/src/lib/sievepad.test.ts b/src/lib/sievepad.test.ts index f95efd8..b2195a9 100644 --- a/src/lib/sievepad.test.ts +++ b/src/lib/sievepad.test.ts @@ -1,26 +1,37 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { inflateRawSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; -import { SIEVEPAD_URL, isSieveScriptField, sievepadLink } from './sievepad'; +import { isSieveScriptField, playgroundUrl, sievepadLink } from './sievepad'; function decode(link: string): unknown { const token = new URLSearchParams(new URL(link).hash.slice(1)).get('w') ?? ''; return JSON.parse(inflateRawSync(Buffer.from(token, 'base64url')).toString('utf8')); } +describe('playgroundUrl', () => { + it('names the bundled playground, by content hash, beside the console', () => { + const url = new URL(playgroundUrl()); + expect(url.origin).toBe(new URL(document.baseURI).origin); + expect(url.pathname).toMatch(/\/sieve-playground\/[0-9a-f]{16}\/index\.html$/); + }); +}); + describe('sievepadLink', () => { it('encodes the script as a single main entry', async () => { const source = 'require "imap4flags";\r\naddflag "\\\\Seen";\r\n'; const link = await sievepadLink('Filters é', source); - expect(link.startsWith(`${SIEVEPAD_URL}#w=`)).toBe(true); - expect(link.slice(`${SIEVEPAD_URL}#w=`.length)).toMatch(/^[A-Za-z0-9_-]+$/); + expect(link.startsWith(`${playgroundUrl()}#w=`)).toBe(true); + expect(link.slice(`${playgroundUrl()}#w=`.length)).toMatch(/^[A-Za-z0-9_-]+$/); expect(decode(link)).toEqual({ v: 1, name: 'Filters é', diff --git a/src/lib/sievepad.ts b/src/lib/sievepad.ts index f277a88..b829cd9 100644 --- a/src/lib/sievepad.ts +++ b/src/lib/sievepad.ts @@ -7,13 +7,17 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -export const SIEVEPAD_URL = 'https://sievepad.com/'; +// inbuxa: the Sieve playground ships with the console (sieve-playground.ts) +// and runs the server's own interpreter, so the script never leaves the +// console's origin. +export function playgroundUrl(): string { + return new URL(__SIEVE_PLAYGROUND__, document.baseURI).href; +} const SIEVEPAD_FORMAT_VERSION = 1; const SIEVEPAD_MAX_NAME_LENGTH = 80; const SIEVEPAD_MAIN_SCRIPT = 'main'; const BASE64_CHUNK_SIZE = 0x8000; -const WARNING_DISMISSED_KEY = 'inbuxa-sievepad-warning-dismissed'; const SIEVE_SCRIPT_FIELDS: Record = { 'x:SieveSystemScript': 'contents', @@ -33,7 +37,7 @@ function toBase64Url(bytes: Uint8Array): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } -export async function sievepadLink(name: string, source: string, base = SIEVEPAD_URL): Promise { +export async function sievepadLink(name: string, source: string, base = playgroundUrl()): Promise { const json = JSON.stringify({ v: SIEVEPAD_FORMAT_VERSION, name: name.slice(0, SIEVEPAD_MAX_NAME_LENGTH), @@ -62,19 +66,3 @@ export async function openInSievepad(name: string, source: string): Promise