Debug Sieve scripts in a playground that ships with the console #97
No files matched your search
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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" }));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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" }]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
.
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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--
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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)"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 : {},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
});
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -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
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -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
@@ -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
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user