Debug Sieve scripts in a playground that ships with the console #97

Merged
jcoffey-dev merged 1 commits from sieve-playground into main 2026-10-09 08:34:02 +00:00
41 changed files with 5386 additions and 97 deletions

No files matched your search

+8
View File
@@ -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",
+1
View File
@@ -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",
+95
View File
@@ -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/<hash>/`, 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<string, string> = {
'.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<string, string>) {
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<string, string> {
const files = new Map<string, string>()
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) })
}
},
}
}
+10
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+113
View File
@@ -0,0 +1,113 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
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" },
});
}
+144
View File
@@ -0,0 +1,144 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
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);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4f5bd5"/><path d="M7 9h18l-6.5 7.5v6L13.5 25v-8.5z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 175 B

+260
View File
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<!--
SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
SPDX-FileCopyrightText: 2026 Coffey Labs LLC
SPDX-License-Identifier: AGPL-3.0-only
Modified by Coffey Labs in 2026 for INBUXA.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sieve playground · inbuxa Admin</title>
<meta name="description"
content="Write, test and share Sieve email filters in your browser, with the Sieve interpreter of the inbuxa server." />
<meta name="theme-color" content="#1b1d24" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" data-name="vs/editor/editor.main" href="./vendor/monaco-0.52.2/vs/editor/editor.main.css" />
</head>
<body>
<header class="topbar">
<a class="brand" href="./" aria-label="Sieve playground">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M5 7h22l-8 9v7l-6 3V16z" fill="currentColor" />
</svg>
<span class="brand-name">Sieve playground</span>
</a>
<span class="brand-by">inbuxa Admin</span>
<div class="toolbar">
<div class="menu" id="workspace-menu">
<button class="btn ghost menu-trigger" type="button" aria-haspopup="true" aria-expanded="false">
<span class="menu-label" id="workspace-name">Workspace</span>
<svg class="chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div class="menu-panel" role="menu" hidden>
<div class="menu-section">Workspaces</div>
<div class="menu-list" id="workspace-list"></div>
<div class="menu-sep"></div>
<button type="button" role="menuitem" data-action="new-workspace">New workspace</button>
<button type="button" role="menuitem" data-action="duplicate-workspace">Duplicate</button>
<button type="button" role="menuitem" data-action="rename-workspace">Rename…</button>
<button type="button" role="menuitem" data-action="delete-workspace">Delete…</button>
<div class="menu-sep"></div>
<button type="button" role="menuitem" data-action="export-workspace">Export to file</button>
<button type="button" role="menuitem" data-action="import-workspace">Import from file…</button>
</div>
</div>
<div class="menu" id="examples-menu">
<button class="btn ghost menu-trigger" type="button" aria-haspopup="true" aria-expanded="false">
Examples
<svg class="chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div class="menu-panel wide" role="menu" hidden>
<div class="menu-section">Open an example as a new workspace</div>
<div id="examples-list"></div>
</div>
</div>
<span class="toolbar-gap"></span>
<button class="btn ghost" type="button" id="share-button" title="Share this workspace">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 9l4-2M6 7l4 2M4.5 10a2 2 0 100-4 2 2 0 000 4zM11.5 6a2 2 0 100-4 2 2 0 000 4zM11.5 14a2 2 0 100-4 2 2 0 000 4z" /></svg>
<span class="hide-narrow">Share</span>
</button>
<button class="btn ghost" type="button" id="settings-button" title="Settings">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 10a2 2 0 100-4 2 2 0 000 4zM13 8a5 5 0 00-.1-1l1.4-1.1-1.4-2.4-1.7.6a5 5 0 00-1.7-1L9.2 1.3H6.8l-.3 1.8a5 5 0 00-1.7 1l-1.7-.6L1.7 5.9 3.1 7a5 5 0 000 2l-1.4 1.1 1.4 2.4 1.7-.6a5 5 0 001.7 1l.3 1.8h2.4l.3-1.8a5 5 0 001.7-1l1.7.6 1.4-2.4L12.9 9a5 5 0 00.1-1z" /></svg>
<span class="hide-narrow">Settings</span>
</button>
<button class="btn primary" type="button" id="run-button" title="Run (Ctrl+Enter)">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 3l8 5-8 5z" /></svg>
Run
</button>
</div>
</header>
<nav class="mobile-tabs" aria-label="Panels">
<button type="button" data-panel="script" class="active">Script</button>
<button type="button" data-panel="message">Message</button>
<button type="button" data-panel="result">Result</button>
</nav>
<main class="workbench" id="workbench">
<section class="panel active" data-panel="script" aria-label="Sieve script">
<div class="panel-head">
<span class="panel-title"><span class="step">1</span>Script</span>
<div class="tabs" id="script-tabs" role="tablist"></div>
<button class="icon-btn" type="button" id="add-script" title="Add an include script">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
</button>
</div>
<div class="editor-host" id="script-editor"></div>
<div class="problems" id="problems" data-state="idle"></div>
</section>
<div class="gutter" data-gutter="0" role="separator" aria-orientation="vertical" tabindex="0"></div>
<section class="panel" data-panel="message" aria-label="Test message">
<div class="panel-head">
<span class="panel-title"><span class="step">2</span>Message</span>
<div class="tabs" id="message-tabs" role="tablist"></div>
<button class="icon-btn" type="button" id="add-message" title="Add a test message">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
</button>
</div>
<div class="editor-host drop-target" id="message-editor">
<div class="drop-hint">Drop an .eml or .sieve file</div>
</div>
<details class="envelope" id="envelope">
<summary>
<span>Envelope</span>
<span class="envelope-summary" id="envelope-summary"></span>
</summary>
<div class="envelope-grid">
<label for="envelope-from">MAIL FROM</label>
<input id="envelope-from" type="text" spellcheck="false" autocomplete="off" />
<label for="envelope-to">RCPT TO</label>
<input id="envelope-to" type="text" spellcheck="false" autocomplete="off" placeholder="comma separated" />
</div>
</details>
</section>
<div class="gutter" data-gutter="1" role="separator" aria-orientation="vertical" tabindex="0"></div>
<section class="panel" data-panel="result" aria-label="Result">
<div class="panel-head">
<span class="panel-title"><span class="step">3</span>Result</span>
<div class="tabs" role="tablist" id="result-tabs">
<button type="button" role="tab" class="tab active" data-view="actions">Actions</button>
<button type="button" role="tab" class="tab" data-view="messages">Messages <span class="count" id="messages-count"></span></button>
<button type="button" role="tab" class="tab" data-view="variables">Variables</button>
</div>
</div>
<div class="result-body" id="result-body">
<div class="result-view active" data-view="actions" id="actions-view"></div>
<div class="result-view" data-view="messages" id="messages-view"></div>
<div class="result-view" data-view="variables" id="variables-view"></div>
</div>
</section>
</main>
<footer class="statusbar">
<span class="status-item" id="engine-status" data-state="loading">Loading engine…</span>
<span class="status-item" id="compile-status"></span>
<span class="status-item" id="run-stats"></span>
<button class="status-item status-link" type="button" id="privacy-link" title="How the playground handles your data">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 7V5a3.5 3.5 0 017 0v2M3.5 7h9v6.5h-9z" /></svg>
<span class="hide-narrow">Runs in your browser</span>
</button>
<span class="status-spacer"></span>
<a class="status-item engine-link" href="https://git.coffeylabs.org/inbuxa/inbuxa-server/src/branch/main/tools/sieve-playground" target="_blank" rel="noopener" title="The server's Sieve interpreter, and this playground's source">sieve-rs <span id="engine-version"></span></a>
</footer>
<div class="drawer-backdrop" id="settings-backdrop" hidden></div>
<aside class="drawer" id="settings-drawer" aria-label="Settings" hidden>
<div class="drawer-head">
<h2>Settings</h2>
<span class="drawer-sub">Saved with this workspace</span>
<button class="icon-btn" type="button" data-close title="Close">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
</div>
<div class="drawer-body" id="settings-body"></div>
<div class="drawer-foot">
<button class="btn ghost" type="button" id="settings-reset">Reset to defaults</button>
<button class="btn ghost" type="button" id="settings-json">Edit as JSON</button>
<span class="toolbar-gap"></span>
<button class="btn primary" type="button" data-close>Done</button>
</div>
</aside>
<dialog class="dialog" id="welcome-dialog" aria-labelledby="welcome-title">
<form method="dialog">
<h2 id="welcome-title">Sieve playground</h2>
<p class="dialog-text">
Write and test Sieve email filters against real messages, with the same Sieve interpreter your
inbuxa server runs.
</p>
<ul class="welcome-points">
<li>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 7V5a3.5 3.5 0 017 0v2M3.5 7h9v6.5h-9z" /></svg>
<span><b>Everything runs in your browser.</b> Scripts are compiled and executed locally with WebAssembly.</span>
</li>
<li>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 4.5h11v8h-11zM5 4.5V3h6v1.5" /></svg>
<span><b>Nothing is stored on a server.</b> Your scripts, messages and settings are saved only in this browser.</span>
</li>
<li>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 9l4-2M6 7l4 2M4.5 10a2 2 0 100-4 2 2 0 000 4zM11.5 6a2 2 0 100-4 2 2 0 000 4zM11.5 14a2 2 0 100-4 2 2 0 000 4z" /></svg>
<span><b>Sharing is up to you.</b> A share link carries the workspace inside the link itself; nothing is uploaded.</span>
</li>
</ul>
<div class="dialog-foot">
<span class="toolbar-gap"></span>
<button class="btn primary" value="close" autofocus>Start filtering</button>
</div>
</form>
</dialog>
<dialog class="dialog" id="share-dialog">
<form method="dialog">
<h2>Share workspace</h2>
<p class="dialog-text">
The workspace is compressed into the link itself. Nothing is uploaded: whoever opens the link gets their own copy.
</p>
<label class="check">
<input type="checkbox" id="share-messages" checked />
Include test messages
</label>
<p class="dialog-warning" id="share-privacy">Messages may contain personal information. Check them before sharing.</p>
<div class="share-row">
<input id="share-url" type="text" readonly spellcheck="false" />
<button class="btn primary" type="button" id="share-copy">Copy link</button>
</div>
<p class="dialog-note" id="share-size"></p>
<div class="dialog-foot">
<button class="btn ghost" type="button" data-action="export-workspace">Export to file instead</button>
<span class="toolbar-gap"></span>
<button class="btn" value="close">Close</button>
</div>
</form>
</dialog>
<dialog class="dialog" id="prompt-dialog">
<form method="dialog">
<h2 id="prompt-title"></h2>
<p class="dialog-text" id="prompt-text"></p>
<input id="prompt-input" type="text" spellcheck="false" autocomplete="off" />
<div class="dialog-foot">
<span class="toolbar-gap"></span>
<button class="btn" value="cancel" formnovalidate>Cancel</button>
<button class="btn primary" value="ok" id="prompt-ok">OK</button>
</div>
</form>
</dialog>
<input type="file" id="file-input" accept=".json,.eml,.sieve,.siv,.txt,message/rfc822" hidden />
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script>
self.MonacoEnvironment = {
getWorkerUrl: () => new URL("./vendor/monaco-0.52.2/vs/base/worker/workerMain.js", location.href).href,
};
</script>
<script src="./vendor/monaco-0.52.2/vs/loader.js"></script>
<script>
window.monacoReady = new Promise((resolve, reject) => {
require.config({ paths: { vs: "./vendor/monaco-0.52.2/vs" } });
require(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
});
</script>
<script type="module" src="./app.js" onerror="try { if (!sessionStorage.getItem('inbuxa-sieve.reloaded')) { sessionStorage.setItem('inbuxa-sieve.reloaded', '1'); location.reload(); } } catch (_) {}"></script>
</body>
</html>
+434
View File
@@ -0,0 +1,434 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import { 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: '<path d="M2 9l2-6h8l2 6v4H2zM2 9h4l1 2h2l1-2h4"/>',
fileinto: '<path d="M2 4h4l1.5 1.5H14V13H2z"/>',
discard: '<path d="M3 4h10M6 4V2.5h4V4M4.5 4l.7 9.5h5.6l.7-9.5"/>',
reject: '<circle cx="8" cy="8" r="5.5"/><path d="M4.2 11.8l7.6-7.6"/>',
redirect: '<path d="M2 11c1-4 4-6 9-6M9 2.5L11.5 5 9 7.5"/>',
vacation: '<path d="M8 2v12M3 7a5 5 0 0110 0zM8 14h-3"/>',
notification: '<path d="M4 11V7a4 4 0 018 0v4l1 1.5H3zM6.5 14h3"/>',
notify: '<path d="M4 11V7a4 4 0 018 0v4l1 1.5H3zM6.5 14h3"/>',
created: '<path d="M4 2h5l3 3v9H4zM9 2v3h3M6.5 9.5h3M8 8v3"/>',
envelope: '<path d="M2 4h12v8H2zM2 4l6 5 6-5"/>',
error: '<circle cx="8" cy="8" r="5.5"/><path d="M8 5v3.5M8 11h0"/>',
};
function icon(kind) {
const span = h("span", { class: "event-icon", "aria-hidden": "true" });
span.innerHTML = `<svg viewBox="0 0 16 16">${ICONS[kind] || ICONS.created}</svg>`;
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 = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; img-src data:; style-src \'unsafe-inline\'">';
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" }));
}
}
+9
View File
@@ -0,0 +1,9 @@
From: Team Lead <[email protected]>
To: [email protected], [email protected], [email protected]
Cc: [email protected]
Subject: MEETING MOVED TO FRIDAY
Date: Sat, 12 Sep 2026 09:00:00 +0000
Message-ID: <[email protected]>
Content-Type: text/plain; charset="utf-8"
The weekly meeting moves to Friday 10:00.
@@ -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";
}
+14
View File
@@ -0,0 +1,14 @@
From: Billing <[email protected]>
To: [email protected]
Subject: Urgent: invoice 2026-0915 overdue
Importance: high
Date: Thu, 10 Sep 2026 08:00:00 +0000
Message-ID: <[email protected]>
Content-Type: text/plain; charset="utf-8"
Dear customer,
Invoice 2026-0915 is now overdue. Please arrange payment at your
earliest convenience.
Supplier Accounts
+29
View File
@@ -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" "[email protected]";
}
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:[email protected]?subject=Urgent%20mail";
}
if header :matches "Subject" "*" {
deleteheader "Subject";
addheader "Subject" "[Finance] ${1}";
}
fileinto :copy :create "Finance";
@@ -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";
}
@@ -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}";
}
+8
View File
@@ -0,0 +1,8 @@
From: Ops Team <[email protected]>
To: [email protected]
Subject: [Apollo] Outage in the staging cluster
Date: Fri, 11 Sep 2026 14:30:00 +0000
Message-ID: <[email protected]>
Content-Type: text/plain; charset="utf-8"
The staging cluster is down since 14:10 UTC. Investigating.
+55
View File
@@ -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" }]
}
]
+16
View File
@@ -0,0 +1,16 @@
From: Ferris <[email protected]>
To: [email protected]
Subject: [ANN] Release 1.99 is out
Date: Mon, 7 Sep 2026 17:02:11 +0000
Message-ID: <[email protected]>
In-Reply-To: <[email protected]>
List-Id: Rust users <rust-users.lists.rust-lang.example>
List-Unsubscribe: <mailto:[email protected]>
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
+34
View File
@@ -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 [email protected] goes to "shopping".
if envelope :detail :matches "to" "*" {
if string :is "${1}" "" {
keep;
} else {
set :lower "folder" "${1}";
fileinto :create "${folder}";
}
}
+8
View File
@@ -0,0 +1,8 @@
From: "Prize Department" <[email protected]>
To: [email protected]
Subject: You have WON 1,000,000 dollars!!!
Date: Sun, 6 Sep 2026 03:12:45 +0000
Message-ID: <[email protected]>
Content-Type: text/html; charset="utf-8"
<html><body><h1>Congratulations!</h1><p>Click <a href="http://lottery.example/claim">here</a> to claim your prize.</p></body></html>
+28
View File
@@ -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";
}
+10
View File
@@ -0,0 +1,10 @@
From: Bob Smith <[email protected]>
To: Jane Doe <[email protected]>
Subject: Lunch next week?
Date: Wed, 9 Sep 2026 11:20:00 +0000
Message-ID: <[email protected]>
Content-Type: text/plain; charset="utf-8"
Hi Jane, are you free for lunch next Tuesday?
Bob
+37
View File
@@ -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 <[email protected]>"
:addresses ["[email protected]", "[email protected]"]
: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 [email protected].
Best regards,
Jane
.
;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
Return-Path: <[email protected]>
Received: from mail.example.com (mail.example.com [192.0.2.25])
by mx.example.org with ESMTPS id 4F2A1B3C
for <[email protected]>; Tue, 8 Sep 2026 09:41:07 +0000
From: Alice Martin <[email protected]>
To: Jane Doe <[email protected]>
Subject: Q3 report and team offsite
Date: Tue, 8 Sep 2026 09:41:02 +0000
Message-ID: <[email protected]>
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--
+105
View File
@@ -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}";
}
+337
View File
@@ -0,0 +1,337 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import { 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)"),
];
}
}
+94
View File
@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
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 : {},
};
}
+173
View File
@@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
const RFC = (n, section) => `https://www.rfc-editor.org/rfc/rfc${n}${section ? `#section-${section}` : ""}`;
export const CONTROL = {
require: { sig: "require <capabilities: string-list>", 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 <test> <block>", doc: "Runs the block when the test is true. Can be followed by `elsif` and `else`.", rfc: RFC(5228, "3.1") },
elsif: { sig: "elsif <test> <block>", 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 <block>", 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] <name: string>', 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 <names: string-list>", doc: "Shares variables between the including and included scripts.", rfc: RFC(6609, "3.4"), capability: "include" },
foreverypart: { sig: "foreverypart [:name <name: string>] <block>", 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 <name: string>]", doc: "Leaves the innermost (or the named) `foreverypart` or `while` loop.", rfc: RFC(5703, "3"), capability: "foreverypart" },
while: { sig: "while <expression: string> <block>", 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 <flags: string-list>]", 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 <flags>] [:create] [:specialuse <use>] [:mailboxid <id>] <mailbox: string>", 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 <items>] [:ret <full|hdrs>] [:bytimerelative <n> / :bytimeabsolute <date>] <address: string>", 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 <reason: string>", doc: "Refuses the message and sends the reason back to the sender.", rfc: RFC(5429, "2.2"), capability: "reject" },
ereject: { sig: "ereject <reason: string>", doc: "Refuses the message at the SMTP level when possible.", rfc: RFC(5429, "2.1"), capability: "ereject" },
vacation: { sig: "vacation [:days <n> / :seconds <n>] [:subject <s>] [:from <addr>] [:addresses <list>] [:mime] [:handle <s>] <reason: string>", 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 <s>] [:importance <1|2|3>] [:options <list>] [:message <s>] <method: string>", 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] <name: string> <value: string>", doc: "Stores a value in a variable. Use `${name}` to read it back inside strings.", rfc: RFC(5229, "4"), capability: "variables" },
setflag: { sig: "setflag [<variable>] <flags: string-list>", doc: "Replaces the flags that will be stored with the message.", rfc: RFC(5232, "3.1"), capability: "imap4flags" },
addflag: { sig: "addflag [<variable>] <flags: string-list>", 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 [<variable>] <flags: string-list>", doc: "Removes flags from the set stored with the message.", rfc: RFC(5232, "3.3"), capability: "imap4flags" },
addheader: { sig: "addheader [:last] <field: string> <value: string>", doc: "Adds a header field, at the top unless `:last` is given.", rfc: RFC(5293, "4"), capability: "editheader" },
deleteheader: { sig: 'deleteheader [:index <n> [:last]] [<match-type>] [<comparator>] <field: string> [<values: string-list>]', doc: "Removes header fields, optionally only those matching the values.", rfc: RFC(5293, "5"), capability: "editheader" },
replace: { sig: "replace [:mime] [:subject <s>] [:from <s>] <replacement: string>", doc: "Replaces the message body, or the current part inside `foreverypart`.", rfc: RFC(5703, "5"), capability: "replace" },
enclose: { sig: "enclose [:subject <s>] [:headers <list>] <text: string>", doc: "Wraps the original message as an attachment of a new message.", rfc: RFC(5703, "6"), capability: "enclose" },
extracttext: { sig: "extracttext [:first <n>] [<modifiers>] <variable: string>", 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 <from-type> <to-type> <parameters: string-list>", doc: "Converts MIME parts from one media type to another.", rfc: RFC(6558, "3"), capability: "convert" },
error: { sig: "error <message: string>", doc: "Aborts the script with an error. The implicit keep applies.", rfc: RFC(5463, "5"), capability: "ihave" },
let: { sig: "let <variable: string> <expression: string>", 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 [<address-part>] [<comparator>] [<match-type>] [:index <n> [:last]] <headers> <keys>", 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 (<tests>)", doc: "True when every test in the list is true.", rfc: RFC(5228, "5.2") },
anyof: { sig: "anyof (<tests>)", doc: "True when at least one test in the list is true.", rfc: RFC(5228, "5.3") },
envelope: { sig: "envelope [<address-part>] [<comparator>] [<match-type>] <parts: from|to> <keys>", doc: "Compares the SMTP envelope. Set the envelope below the message.", rfc: RFC(5228, "5.4"), capability: "envelope" },
exists: { sig: "exists <headers: string-list>", 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] [<comparator>] [<match-type>] [:index <n> [:last]] <headers> <keys>", doc: "Compares header field values. With `:mime` it looks at MIME part headers.", rfc: RFC(5228, "5.7") },
not: { sig: "not <test>", doc: "Inverts a test.", rfc: RFC(5228, "5.8") },
size: { sig: "size <:over / :under> <limit: number>", 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 [<comparator>] [<match-type>] [:raw / :content <types> / :text] <keys>", doc: "Compares the message body.", rfc: RFC(5173, "4"), capability: "body" },
currentdate: { sig: "currentdate [:zone <zone>] [<comparator>] [<match-type>] <date-part> <keys>", 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 <zone> / :originalzone] [<comparator>] [<match-type>] <header> <date-part> <keys>", doc: "Compares a date taken from a header field.", rfc: RFC(5260, "4"), capability: "date" },
duplicate: { sig: "duplicate [:handle <s>] [:header <s> / :uniqueid <s>] [:seconds <n>] [: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 [<comparator>] [<match-type>] <name: string> <keys>", 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 [<comparator>] [<match-type>] [<variables>] <flags>", doc: "Tests the flags set by the script so far.", rfc: RFC(5232, "4"), capability: "imap4flags" },
ihave: { sig: "ihave <capabilities: string-list>", doc: "True when the listed extensions are available.", rfc: RFC(5463, "4"), capability: "ihave" },
mailboxexists: { sig: "mailboxexists <mailboxes: string-list>", doc: "True when every mailbox exists. Mailboxes are listed in Settings.", rfc: RFC(5490, "3.1"), capability: "mailbox" },
mailboxidexists: { sig: "mailboxidexists <ids: string-list>", doc: "True when every mailbox id exists.", rfc: RFC(9042, "4.2"), capability: "mailboxid" },
metadata: { sig: "metadata [<comparator>] [<match-type>] <mailbox> <annotation> <keys>", doc: "Compares a mailbox annotation.", rfc: RFC(5490, "3.2"), capability: "mboxmetadata" },
metadataexists: { sig: "metadataexists <mailbox> <annotations>", doc: "True when the mailbox has the annotations.", rfc: RFC(5490, "3.3"), capability: "mboxmetadata" },
servermetadata: { sig: "servermetadata [<comparator>] [<match-type>] <annotation> <keys>", doc: "Compares a server annotation. Configure annotations in Settings.", rfc: RFC(5490, "3.4"), capability: "servermetadata" },
servermetadataexists: { sig: "servermetadataexists <annotations>", doc: "True when the server has the annotations.", rfc: RFC(5490, "3.5"), capability: "servermetadata" },
spamtest: { sig: "spamtest [:percent] [<comparator>] [<match-type>] <value: string>", 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 [<comparator>] [<match-type>] <value: string>", 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 [<mailbox>] <special-use-attrs>", doc: "True when a mailbox with the special-use attributes exists.", rfc: RFC(8579, "3"), capability: "special-use" },
string: { sig: "string [<comparator>] [<match-type>] <source: string-list> <keys>", doc: "Compares arbitrary strings, usually containing variables.", rfc: RFC(5229, "5"), capability: "variables" },
valid_notify_method: { sig: "valid_notify_method <uris: string-list>", 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 [<comparator>] [<match-type>] <uri> <capability> <keys>", doc: "Queries a capability of a notification method.", rfc: RFC(5435, "6"), capability: "enotify" },
valid_ext_list: { sig: "valid_ext_list <lists: string-list>", doc: "True when the external lists exist. Lists are configured in Settings.", rfc: RFC(6134, "2.6"), capability: "extlists" },
eval: { sig: "eval <expression: string>", 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;
}
+384
View File
@@ -0,0 +1,384 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import { 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:[email protected]}\";",
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",
};
});
}
Binary file not shown.
+147
View File
@@ -0,0 +1,147 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
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)}`;
}
File diff suppressed because it is too large. Load diff
+152
View File
@@ -0,0 +1,152 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
// 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 });
}
};
+20 -69
View File
@@ -1,97 +1,48 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 (
<>
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={handleClick} disabled={!source.trim()}>
<Bug className="h-4 w-4" />
{t('sievepad.debug', 'Debug')}
</Button>
</div>
<Dialog open={warningOpen} onOpenChange={setWarningOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sievepad.warningTitle', 'Debug in Sievepad')}</DialogTitle>
<DialogDescription>
{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.',
)}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Checkbox
id="sievepad-dont-show-again"
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
/>
<Label htmlFor="sievepad-dont-show-again" className="text-sm font-normal">
{t('sievepad.dontShowAgain', "Don't show this again")}
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setWarningOpen(false)}>
{t('common.cancel')}
</Button>
<Button type="button" onClick={handleContinue}>
{t('common.continue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={open}
disabled={!source.trim()}
title={t('sievepad.debugHint', 'Test a copy of this script in the Sieve playground')}
>
<Bug className="h-4 w-4" />
{t('sievepad.debug', 'Debug')}
</Button>
</div>
);
}
+2 -4
View File
@@ -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": {
+14 -3
View File
@@ -1,26 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 é',
+7 -19
View File
@@ -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<string, string> = {
'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<string> {
export async function sievepadLink(name: string, source: string, base = playgroundUrl()): Promise<string> {
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<void
window.open(link, '_blank', 'noopener,noreferrer');
}
}
export function isSievepadWarningDismissed(): boolean {
try {
return localStorage.getItem(WARNING_DISMISSED_KEY) === 'true';
} catch {
return false;
}
}
export function dismissSievepadWarning(): void {
try {
localStorage.setItem(WARNING_DISMISSED_KEY, 'true');
} catch {
return;
}
}
+3
View File
@@ -22,3 +22,6 @@ interface ImportMeta {
}
declare const __APP_VERSION__: string;
// inbuxa: the Sieve playground's page, relative to the console
// (sieve-playground.ts)
declare const __SIEVE_PLAYGROUND__: string;
+1 -1
View File
@@ -21,5 +21,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts", "source-archive.ts"]
"include": ["vite.config.ts", "source-archive.ts", "sieve-playground.ts"]
}
+2 -1
View File
@@ -9,13 +9,14 @@ import { version } from './package.json' with { type: 'json' }
// INBUXA's own dated version lives apart from package.json, whose version
// follows upstream WebUI so its bumps merge without conflicts.
import inbuxa from './inbuxa-version.json' with { type: 'json' }
import { sievePlayground } from './sieve-playground.ts'
export default defineConfig({
base: './',
define: {
__APP_VERSION__: JSON.stringify(`${inbuxa.version} (base ${version})`),
},
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), sievePlayground()],
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, './src'),