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.
338 lines
15 KiB
JavaScript
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)"),
|
|
];
|
|
}
|
|
}
|