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

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

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

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

338 lines
15 KiB
JavaScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import { 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)"),
];
}
}