Files
RustBox/src/web/js/90-sieve.js
T
Mateusz Gruszczyński a643e7e3ab
RustBox CI / rustbox (push) Canceled after 0s
v0.0.5
2026-10-30 12:06:44 +01:00

1026 lines
51 KiB
JavaScript

async function loadSieve() {
const status = $("#sieve-status");
status.textContent = "Connecting…";
try {
const [info, scripts] = await Promise.all([request("/sieve/status", { method: "GET" }), request("/sieve/scripts", { method: "GET" })]);
status.textContent = `${info.host}:${info.port} · ${info.tlsMode} · ${info.implementation || "ManageSieve"}`;
state.sieveInfo = info;
state.sieveScripts = scripts.scripts || [];
renderSieveScripts();
const selectedName = $("#sieve-name")?.value.trim();
const target = state.sieveScripts.find((script) => script.name === selectedName)
|| state.sieveScripts.find((script) => script.active)
|| state.sieveScripts[0];
if (target) await openSieveScript(target.name);
else {
$("#sieve-editor").value = "";
clearSieveRuleBuilder();
updateSieveBuilder();
}
} catch (error) {
status.textContent = error.message;
toast(error.message, "error");
}
}
function sieveQuote(value) {
return String(value ?? "").replaceAll("\\", "\\\\").replaceAll('"', '\\"').replace(/[\r\n]+/g, " ");
}
function sieveBuilderRequirements(condition, action) {
const requirements = new Set();
if (["move", "copy"].includes(action)) requirements.add("fileinto");
if (["copy", "redirect-copy"].includes(action)) requirements.add("copy");
if (["mark-read", "add-flag", "set-flags"].includes(action)) requirements.add("imap4flags");
return [...requirements];
}
function mergeSieveRequirements(script, requirements) {
const found = new Set(requirements);
const without = String(script || "").replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => {
const raw = single ? [`"${single}"`] : (list || "").split(",");
for (const item of raw) {
const match = item.match(/"([^"]+)"/);
if (match) found.add(match[1]);
}
return "";
}).trim();
const header = found.size ? `require [${[...found].sort().map((item) => `"${sieveQuote(item)}"`).join(",")}];` : "";
return [header, without].filter(Boolean).join("\n\n").trim() + "\n";
}
function sieveMatchLabel(match) {
return match === "is" ? "is exactly" : match === "matches" ? "matches pattern" : "contains";
}
function sieveConditionLabel(condition, header = "") {
if (condition === "from") return "From address";
if (condition === "from-domain") return "From domain";
if (condition === "to") return "To / Cc address";
if (condition === "header-from") return "From header";
if (condition === "header-to") return "To header";
if (condition === "subject") return "Subject";
if (condition === "list-id") return "List-ID";
return header || "Header";
}
function buildSieveCondition(suffix = "", required = true) {
const condition = $(`#sieve-builder-condition${suffix}`).value;
const value = $(`#sieve-builder-value${suffix}`).value.trim();
if (!condition || !value) {
if (required) throw new Error("Rule value is required");
return null;
}
const match = $(`#sieve-builder-match${suffix}`).value;
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
let test;
let header = "";
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
else {
header = $(`#sieve-builder-header${suffix}`).value.trim();
if (!/^[A-Za-z0-9-]+$/.test(header)) {
const label = suffix === "-3" ? "Third custom" : suffix === "-2" ? "Second custom" : "Custom";
throw new Error(`${label} header name is invalid`);
}
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
}
const negate = $(`#sieve-builder-negate${suffix}`).checked;
if (negate) test = `not ${test}`;
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”`;
return { test, condition, match, value, header, negate, summary };
}
function sieveFlagValues(target) {
return String(target || "").split(",").map((value) => value.trim()).filter(Boolean);
}
function buildSieveActionFromValues(action, target, label = "Action") {
let command;
let summary;
if (action === "move") {
if (!target) throw new Error(`${label} target folder is required`);
command = `fileinto "${sieveQuote(target)}";`;
summary = `Move to “${target}”`;
} else if (action === "copy") {
if (!target) throw new Error(`${label} target folder is required`);
command = `fileinto :copy "${sieveQuote(target)}";`;
summary = `Copy to “${target}”`;
} else if (action === "redirect" || action === "redirect-copy") {
if (!target || !target.includes("@")) throw new Error(`${label} redirect address is required`);
command = `redirect${action === "redirect-copy" ? " :copy" : ""} "${sieveQuote(target)}";`;
summary = action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}`;
} else if (action === "discard") {
command = "discard;";
summary = "Discard the message";
} else if (action === "mark-read") {
command = 'addflag "\\Seen";';
summary = "Mark as read";
} else if (action === "add-flag") {
if (!target) throw new Error(`${label} IMAP flag is required`);
command = `addflag "${sieveQuote(target)}";`;
summary = `Add IMAP flag ${target}`;
} else if (action === "set-flags") {
const flags = sieveFlagValues(target);
if (!flags.length) throw new Error(`${label} IMAP flags are required`);
command = `setflag [${flags.map((flag) => `"${sieveQuote(flag)}"`).join(",")}];`;
summary = `Set IMAP flags ${flags.join(", ")}`;
} else {
command = "keep;";
summary = "Keep in the inbox";
}
return { action, target, command, summary, requirements: sieveBuilderRequirements("", action) };
}
function buildSieveAction(suffix = "", required = true) {
const action = $(`#sieve-builder-action${suffix}`).value;
const target = $(`#sieve-builder-target${suffix}`)?.value.trim() || "";
if (!action) {
if (required) throw new Error("Action is required");
return null;
}
return buildSieveActionFromValues(action, target, suffix ? "Second action" : "Action");
}
function newSieveVisualRuleId() {
const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `rule-${random.replace(/[^A-Za-z0-9_-]/g, "")}`;
}
function sieveUnescape(value) {
return String(value || "").replace(/\\([\\"])/g, "$1");
}
function sieveQuotedValues(value) {
const values = [];
const pattern = /"((?:\\.|[^"\\])*)"/g;
let match;
while ((match = pattern.exec(String(value || "")))) values.push(sieveUnescape(match[1]));
return values;
}
function splitSieveTopLevel(value) {
const result = [];
let start = 0;
let depth = 0;
let bracket = 0;
let quoted = false;
let escaped = false;
const text = String(value || "");
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
continue;
}
if (char === '"') { quoted = true; continue; }
if (char === "(") depth += 1;
else if (char === ")") depth = Math.max(0, depth - 1);
else if (char === "[") bracket += 1;
else if (char === "]") bracket = Math.max(0, bracket - 1);
else if (char === "," && depth === 0 && bracket === 0) {
result.push(text.slice(start, index).trim());
start = index + 1;
}
}
result.push(text.slice(start).trim());
return result.filter(Boolean);
}
function splitSieveStatements(value) {
const result = [];
let start = 0;
let quoted = false;
let escaped = false;
const text = String(value || "");
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
continue;
}
if (char === '"') { quoted = true; continue; }
if (char === "#") {
while (index < text.length && text[index] !== "\n") index += 1;
continue;
}
if (char === ";") {
const statement = text.slice(start, index + 1).trim();
if (statement) result.push(statement);
start = index + 1;
}
}
return result;
}
function findSieveClosingBrace(source, openIndex) {
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = openIndex; index < source.length; index += 1) {
const char = source[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
continue;
}
if (char === '"') { quoted = true; continue; }
if (char === "#") {
while (index < source.length && source[index] !== "\n") index += 1;
continue;
}
if (char === "{") depth += 1;
else if (char === "}") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function parseSieveConditionToken(value) {
let raw = String(value || "").trim();
const negate = /^not\s+/i.test(raw);
if (negate) raw = raw.replace(/^not\s+/i, "").trim();
const match = raw.match(/^(header|address)\s+(?:(:domain)\s+)?:(contains|is|matches)\s+([\s\S]+)$/i);
if (!match) return { raw: value, supported: false, summary: String(value || "").trim() || "Unsupported condition" };
const values = sieveQuotedValues(match[4]);
if (values.length < 2) return { raw: value, supported: false, summary: String(value || "").trim() };
const targetValue = values.at(-1);
const fields = values.slice(0, -1).map((item) => item.toLowerCase());
const primaryField = fields[0] || "";
let kind = "header";
let header = primaryField;
const testType = match[1].toLowerCase();
if (testType === "address" && primaryField === "from") kind = match[2] ? "from-domain" : "from";
else if (testType === "address" && (fields.includes("to") || fields.includes("cc"))) kind = "to";
else if (testType === "header" && primaryField === "from") kind = "header-from";
else if (testType === "header" && primaryField === "to" && fields.length === 1) kind = "header-to";
else if (primaryField === "subject") kind = "subject";
else if (primaryField === "list-id") kind = "list-id";
const comparator = match[3].toLowerCase();
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(kind, header)} ${sieveMatchLabel(comparator)} “${targetValue}”`;
return { raw: value, supported: true, kind, header, match: comparator, value: targetValue, negate, summary };
}
function parseSieveConditionExpression(value) {
const raw = String(value || "").trim();
const group = raw.match(/^(allof|anyof)\s*\(([\s\S]*)\)$/i);
if (!group) {
const condition = parseSieveConditionToken(raw);
return { logic: "allof", conditions: [condition], supported: condition.supported, mixed: false, summary: condition.summary };
}
const logic = group[1].toLowerCase();
const parts = splitSieveTopLevel(group[2]);
const conditions = [];
let supported = true;
let mixed = false;
const summaries = [];
for (const part of parts) {
const parsed = parseSieveConditionExpression(part);
if (parsed.conditions.length > 1 && parsed.logic !== logic) mixed = true;
conditions.push(...parsed.conditions);
supported = supported && parsed.supported;
mixed = mixed || parsed.mixed;
summaries.push(parsed.summary);
}
return { logic, conditions, supported, mixed, summary: summaries.join(logic === "anyof" ? " OR " : " AND ") };
}
function parseSieveActionStatement(statement) {
const raw = String(statement || "").trim().replace(/;\s*$/, "");
let match = raw.match(/^fileinto\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
if (match) {
const target = sieveUnescape(match[2]);
const action = match[1] ? "copy" : "move";
return { action, target, supported: true, summary: action === "copy" ? `Copy to “${target}”` : `Move to “${target}”` };
}
match = raw.match(/^redirect\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
if (match) {
const target = sieveUnescape(match[2]);
const action = match[1] ? "redirect-copy" : "redirect";
return { action, target, supported: true, summary: action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}` };
}
match = raw.match(/^addflag\s+([\s\S]+)$/i);
if (match) {
const flags = sieveQuotedValues(match[1]);
const target = flags.join(", ");
const markRead = flags.length === 1 && flags[0] === "\\Seen";
return { action: markRead ? "mark-read" : "add-flag", target: markRead ? "" : target, supported: Boolean(flags.length), summary: markRead ? "Mark as read" : `Add IMAP flag ${target}` };
}
match = raw.match(/^setflag\s+([\s\S]+)$/i);
if (match) {
const flags = sieveQuotedValues(match[1]);
const target = flags.join(", ");
return { action: "set-flags", target, supported: Boolean(flags.length), summary: `Set IMAP flags ${target}` };
}
if (/^discard$/i.test(raw)) return { action: "discard", target: "", supported: true, summary: "Discard the message" };
if (/^keep$/i.test(raw)) return { action: "keep", target: "", supported: true, summary: "Keep in the inbox" };
if (/^stop$/i.test(raw)) return { action: "stop", target: "", supported: true, stop: true, summary: "Stop processing more rules" };
return { action: "unsupported", target: "", supported: false, summary: raw || "Unsupported action", raw };
}
function parseSieveRules(script = $("#sieve-editor")?.value || "") {
const source = String(script || "");
const markerPattern = /^\s*#\s*(?:rule:\[([^\]\r\n]+)\]|RUSTBOX-RULE-BEGIN\s+([A-Za-z0-9_-]+))\s*$/gim;
const markers = [];
let marker;
while ((marker = markerPattern.exec(source))) markers.push({ index: marker.index, end: markerPattern.lastIndex, name: marker[1]?.trim() || "", rustboxId: marker[2] || "" });
const rules = [];
const nameCounts = new Map();
for (let markerIndex = 0; markerIndex < markers.length; markerIndex += 1) {
const info = markers[markerIndex];
const boundary = markers[markerIndex + 1]?.index ?? source.length;
const segment = source.slice(info.end, boundary);
const ifMatch = /\bif\s+([\s\S]*?)\{/i.exec(segment);
if (!ifMatch) continue;
const openBrace = info.end + ifMatch.index + ifMatch[0].lastIndexOf("{");
const closeBrace = findSieveClosingBrace(source, openBrace);
if (closeBrace < 0 || closeBrace >= boundary) continue;
const conditionOffset = ifMatch[0].indexOf(ifMatch[1]);
const conditionStart = info.end + ifMatch.index + conditionOffset;
const conditionEnd = conditionStart + ifMatch[1].length;
const rawCondition = ifMatch[1].trim();
const disabledMatch = rawCondition.match(/^false\s*#\s*([\s\S]+)$/i);
const enabled = !disabledMatch;
const actualCondition = (disabledMatch ? disabledMatch[1] : rawCondition).trim();
const condition = parseSieveConditionExpression(actualCondition);
const body = source.slice(openBrace + 1, closeBrace);
const parsedActions = splitSieveStatements(body).map(parseSieveActionStatement);
const stop = parsedActions.some((action) => action.stop);
const actions = parsedActions.filter((action) => !action.stop);
let end = closeBrace + 1;
if (info.rustboxId) {
const tail = source.slice(end, boundary);
const endMarker = new RegExp(`^\\s*#\\s*RUSTBOX-RULE-END\\s*$`, "im").exec(tail);
if (endMarker) end += endMarker.index + endMarker[0].length;
}
const fallbackName = info.rustboxId ? `RustBox ${info.rustboxId.replace(/^rule-/, "").slice(0, 12)}` : `Rule ${rules.length + 1}`;
const name = info.name || fallbackName;
const count = (nameCounts.get(name) || 0) + 1;
nameCounts.set(name, count);
const id = `${info.rustboxId ? "rustbox" : "named"}:${name}:${count}`;
const editable = condition.supported && !condition.mixed && actions.every((action) => action.supported);
rules.push({
id, name, enabled, start: info.index, end, full: source.slice(info.index, end), rawCondition, actualCondition,
conditionStart, conditionEnd, condition, actions, stop, editable, rustboxId: info.rustboxId,
});
}
return rules;
}
function setSieveConditionControls(suffix, condition) {
const kind = condition?.kind || "";
$(`#sieve-builder-condition${suffix}`).value = kind;
$(`#sieve-builder-match${suffix}`).value = condition?.match || "contains";
$(`#sieve-builder-value${suffix}`).value = condition?.value || "";
$(`#sieve-builder-negate${suffix}`).checked = Boolean(condition?.negate);
const header = $(`#sieve-builder-header${suffix}`);
if (header) header.value = condition?.header || "";
}
function setSieveDynamicCondition(row, condition, logic) {
row.querySelector(".sieve-dynamic-logic").value = logic === "anyof" ? "anyof" : "allof";
row.querySelector(".sieve-dynamic-kind").value = condition.kind || "from";
row.querySelector(".sieve-dynamic-match").value = condition.match || "contains";
row.querySelector(".sieve-dynamic-header").value = condition.header || "";
row.querySelector(".sieve-dynamic-value").value = condition.value || "";
row.querySelector(".sieve-dynamic-negate").checked = Boolean(condition.negate);
}
function setSieveActionControls(suffix, action) {
$(`#sieve-builder-action${suffix}`).value = action?.action || (suffix ? "" : "move");
const target = $(`#sieve-builder-target${suffix}`);
if (target) target.value = action?.target || "";
}
function setSieveDynamicAction(row, action) {
row.querySelector(".sieve-dynamic-action").value = action.action || "keep";
row.querySelector(".sieve-dynamic-target").value = action.target || "";
configureSieveDynamicAction(row);
}
function showSieveRuleDialog(mode = "new", ruleName = "") {
const dialog = $("#sieve-rule-dialog");
if (!dialog) return;
$("#sieve-rule-dialog-title").textContent = mode === "edit" ? `Edit filter · ${ruleName}` : "Create filter";
$("#sieve-rule-dialog-subtitle").textContent = mode === "edit"
? "Change the rule visually; the live preview shows exactly what will be written to Sieve."
: "Build the rule visually and verify the preview before applying it.";
if (!dialog.open) dialog.showModal();
}
function openNewSieveRuleDialog() {
clearSieveRuleBuilder();
showSieveRuleDialog("new");
setTimeout(() => $("#sieve-builder-name")?.focus(), 20);
}
function loadSieveRuleIntoBuilder(rule) {
if (!rule?.editable) return toast("This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.", "warning");
state.sieveEditingRuleId = rule.id;
$("#sieve-builder-name").value = rule.name;
$("#sieve-builder-enabled").checked = rule.enabled;
$("#sieve-builder-stop").checked = rule.stop;
$("#sieve-builder-extra-conditions").replaceChildren();
$("#sieve-builder-extra-actions").replaceChildren();
const conditions = rule.condition.conditions;
setSieveConditionControls("", conditions[0]);
setSieveConditionControls("-2", conditions[1] || null);
setSieveConditionControls("-3", conditions[2] || null);
$("#sieve-builder-logic").value = rule.condition.logic === "anyof" ? "anyof" : "allof";
$("#sieve-builder-logic-3").value = rule.condition.logic === "anyof" ? "anyof" : "allof";
for (const condition of conditions.slice(3)) {
const row = createSieveDynamicCondition();
setSieveDynamicCondition(row, condition, rule.condition.logic);
$("#sieve-builder-extra-conditions").append(row);
}
setSieveActionControls("", rule.actions[0] || { action: "keep", target: "" });
setSieveActionControls("-2", rule.actions[1] || null);
for (const action of rule.actions.slice(2)) {
const row = createSieveDynamicAction();
setSieveDynamicAction(row, action);
$("#sieve-builder-extra-actions").append(row);
}
$("#sieve-builder-replace").disabled = false;
updateSieveBuilder();
renderManagedSieveRules();
showSieveRuleDialog("edit", rule.name);
setTimeout(() => $("#sieve-builder-name")?.focus(), 20);
}
function clearSieveRuleBuilder() {
state.sieveEditingRuleId = null;
$("#sieve-builder-name").value = "";
$("#sieve-builder-enabled").checked = true;
$("#sieve-builder-stop").checked = true;
$("#sieve-builder-condition").value = "from";
$("#sieve-builder-match").value = "contains";
$("#sieve-builder-value").value = "";
$("#sieve-builder-header").value = "";
$("#sieve-builder-negate").checked = false;
for (const suffix of ["-2", "-3"]) {
$(`#sieve-builder-condition${suffix}`).value = "";
$(`#sieve-builder-match${suffix}`).value = "contains";
$(`#sieve-builder-value${suffix}`).value = "";
$(`#sieve-builder-header${suffix}`).value = "";
$(`#sieve-builder-negate${suffix}`).checked = false;
}
$("#sieve-builder-logic").value = "allof";
$("#sieve-builder-logic-3").value = "allof";
$("#sieve-builder-extra-conditions").replaceChildren();
$("#sieve-builder-action").value = "move";
$("#sieve-builder-target").value = "";
$("#sieve-builder-action-2").value = "";
$("#sieve-builder-target-2").value = "";
$("#sieve-builder-extra-actions").replaceChildren();
$("#sieve-builder-replace").disabled = true;
updateSieveBuilder();
renderManagedSieveRules();
if ($("#sieve-rule-dialog")?.open) {
$("#sieve-rule-dialog-title").textContent = "Create filter";
$("#sieve-rule-dialog-subtitle").textContent = "Build the rule visually and verify the preview before applying it.";
}
}
function moveParsedSieveRule(ruleId, delta) {
const editor = $("#sieve-editor");
const rules = parseSieveRules(editor.value);
const index = rules.findIndex((rule) => rule.id === ruleId);
const target = index + delta;
if (index < 0 || target < 0 || target >= rules.length) return;
const first = delta > 0 ? rules[index] : rules[target];
const second = delta > 0 ? rules[target] : rules[index];
const separator = editor.value.slice(first.end, second.start);
const replacement = second.full + separator + first.full;
editor.value = editor.value.slice(0, first.start) + replacement + editor.value.slice(second.end);
state.sieveEditingRuleId = null;
renderManagedSieveRules();
$("#sieve-result").textContent = "Rule order changed — save to apply";
}
function toggleSieveRuleEnabled(rule) {
const editor = $("#sieve-editor");
const source = editor.value;
const replacement = rule.enabled ? `false # ${rule.actualCondition}` : rule.actualCondition;
editor.value = source.slice(0, rule.conditionStart) + replacement + source.slice(rule.conditionEnd);
state.sieveEditingRuleId = null;
renderManagedSieveRules();
$("#sieve-result").textContent = `${rule.name} ${rule.enabled ? "disabled" : "enabled"} — save to apply`;
}
async function deleteSieveRule(rule) {
if (!await appConfirm(`Delete Sieve rule “${rule.name}”?`, { title: "Delete filter rule", confirmLabel: "Delete", danger: true })) return;
const editor = $("#sieve-editor");
editor.value = `${editor.value.slice(0, rule.start).trimEnd()}\n\n${editor.value.slice(rule.end).trimStart()}`.trim() + "\n";
state.sieveEditingRuleId = null;
clearSieveRuleBuilder();
renderManagedSieveRules();
$("#sieve-result").textContent = `Rule “${rule.name}” removed — save to apply`;
}
function renderManagedSieveRules() {
const list = $("#sieve-managed-rules");
if (!list) return;
const rules = parseSieveRules();
const count = $("#sieve-rule-count");
if (count) count.textContent = String(rules.length);
if (state.sieveEditingRuleId && !rules.some((rule) => rule.id === state.sieveEditingRuleId)) {
state.sieveEditingRuleId = null;
const update = $("#sieve-builder-replace");
if (update) update.disabled = true;
}
list.replaceChildren();
if (!rules.length) {
list.append(el("div", "muted", "No named rules found. Rules using # rule:[name] are parsed automatically."));
return;
}
rules.forEach((rule, index) => {
const item = el("article", `sieve-managed-rule${rule.enabled ? "" : " disabled"}${rule.editable ? "" : " unsupported"}${state.sieveEditingRuleId === rule.id ? " selected" : ""}`);
const main = el("div", "sieve-managed-rule-main");
const title = el("div", "sieve-managed-rule-title");
title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, rule.enabled ? "ON" : "OFF"));
const summary = el("div", "sieve-managed-rule-summary");
const when = el("div"); when.innerHTML = `<b>When:</b> ${escapeHtml(rule.condition.summary || rule.actualCondition)}`;
const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : "No action parsed";
const then = el("div"); then.innerHTML = `<b>Then:</b> ${escapeHtml(actionText)}${rule.stop ? "; stop processing more rules" : ""}`;
summary.append(when, then);
main.append(title, summary);
if (!rule.editable) main.append(el("div", "sieve-rule-warning", "Uses syntax not fully supported by the visual editor; source is preserved unchanged."));
const actions = el("div", "sieve-managed-rule-actions");
const edit = el("button", "button quiet small", "Edit"); edit.type = "button"; edit.disabled = !rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
const toggle = el("button", "button quiet small", rule.enabled ? "Disable" : "Enable"); toggle.type = "button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
const up = el("button", "button quiet small", "↑"); up.type = "button"; up.title = "Move rule up"; up.disabled = index === 0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1));
const down = el("button", "button quiet small", "↓"); down.type = "button"; down.title = "Move rule down"; down.disabled = index === rules.length - 1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1));
const remove = el("button", "button quiet small danger", "Delete"); remove.type = "button"; remove.addEventListener("click", () => deleteSieveRule(rule));
actions.append(edit, toggle, up, down, remove);
item.append(main, actions);
list.append(item);
});
}
function createSieveDynamicCondition() {
const row = el("div", "sieve-dynamic-row sieve-dynamic-condition");
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind"><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
const refresh = () => {
const kind = row.querySelector(".sieve-dynamic-kind").value;
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", kind !== "header");
updateSieveBuilder();
};
row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh);
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
return row;
}
function buildSieveDynamicCondition(row) {
const condition = row.querySelector(".sieve-dynamic-kind").value;
const value = row.querySelector(".sieve-dynamic-value").value.trim();
if (!condition || !value) throw new Error("Every additional condition needs a value");
const match = row.querySelector(".sieve-dynamic-match").value;
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
let test;
let header = "";
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
else {
header = row.querySelector(".sieve-dynamic-header").value.trim();
if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error("Additional custom header name is invalid");
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
}
const negate = row.querySelector(".sieve-dynamic-negate").checked;
if (negate) test = `not ${test}`;
return { test, condition, match, value, header, negate, logic: row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof", summary: `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”` };
}
function createSieveDynamicAction() {
const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row");
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action"><option value="move">Move to folder</option><option value="copy">Copy to folder</option><option value="redirect">Redirect</option><option value="redirect-copy">Redirect and keep copy</option><option value="discard">Discard</option><option value="mark-read">Mark read</option><option value="add-flag">Add IMAP flag</option><option value="set-flags">Set IMAP flags</option><option value="keep">Keep</option></select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); };
row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh);
row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder);
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
configureSieveDynamicAction(row);
return row;
}
function configureSieveDynamicAction(row) {
const action = row.querySelector(".sieve-dynamic-action").value;
const wrap = row.querySelector(".sieve-dynamic-target-wrap");
const label = row.querySelector(".sieve-dynamic-target-label");
const target = row.querySelector(".sieve-dynamic-target");
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
wrap.classList.toggle("hidden", !needsTarget);
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
target.removeAttribute("list");
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
}
function buildSieveDynamicAction(row) {
return buildSieveActionFromValues(row.querySelector(".sieve-dynamic-action").value, row.querySelector(".sieve-dynamic-target").value.trim(), "Additional action");
}
function cleanSieveRuleName(value) {
const name = String(value || "").trim();
if (!name) throw new Error("Rule name is required");
if (/[\]\r\n]/.test(name)) throw new Error("Rule name cannot contain ] or a line break");
return name;
}
function buildSieveVisualRule() {
const name = cleanSieveRuleName($("#sieve-builder-name").value);
const enabled = $("#sieve-builder-enabled").checked;
const primary = buildSieveCondition("", true);
const secondary = buildSieveCondition("-2", false);
const tertiary = buildSieveCondition("-3", false);
let test = primary.test;
let summary = primary.summary;
if (secondary) {
const logic = $("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof";
test = `${logic} (${test}, ${secondary.test})`;
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${secondary.summary}`;
}
if (tertiary) {
const logic = $("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof";
test = `${logic} (${test}, ${tertiary.test})`;
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${tertiary.summary}`;
}
for (const row of $$("#sieve-builder-extra-conditions .sieve-dynamic-condition")) {
const extra = buildSieveDynamicCondition(row);
test = `${extra.logic} (${test}, ${extra.test})`;
summary = `${summary} ${extra.logic === "anyof" ? "OR" : "AND"} ${extra.summary}`;
}
const actions = [buildSieveAction("", true), buildSieveAction("-2", false), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").map(buildSieveDynamicAction)].filter(Boolean);
const commands = actions.map((item) => item.command);
const stop = $("#sieve-builder-stop").checked;
if (stop) commands.push("stop;");
const requirements = new Set();
for (const item of actions) for (const requirement of item.requirements) requirements.add(requirement);
const conditionCode = enabled ? test : `false # ${test}`;
return {
name, enabled, stop, requirements: [...requirements], conditionSummary: summary,
actionSummary: actions.map((item) => item.summary).join("; ") + (stop ? "; stop processing more rules" : ""),
rule: `# rule:[${name}]\nif ${conditionCode}\n{\n\t${commands.join("\n\t")}\n}`,
};
}
function configureSieveActionFields(suffix = "") {
const action = $(`#sieve-builder-action${suffix}`).value;
const wrap = $(`#sieve-builder-target-wrap${suffix}`);
const label = $(`#sieve-builder-target-label${suffix}`);
const target = $(`#sieve-builder-target${suffix}`);
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
wrap?.classList.toggle("hidden", !needsTarget);
if (!target || !label) return;
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
target.removeAttribute("list");
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
}
function applySieveCapabilityMatrix(extensions) {
const supports = (name) => extensions.has(name.toLowerCase());
const actionAllowed = (action) => {
if (["move", "copy"].includes(action) && !supports("fileinto")) return false;
if (["copy", "redirect-copy"].includes(action) && !supports("copy")) return false;
if (["mark-read", "add-flag", "set-flags"].includes(action) && !supports("imap4flags")) return false;
return true;
};
for (const select of [$("#sieve-builder-action"), $("#sieve-builder-action-2"), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action")]) {
if (!select) continue;
for (const option of select.options) option.disabled = Boolean(option.value) && !actionAllowed(option.value);
if (select.selectedOptions[0]?.disabled) select.value = select.id === "sieve-builder-action-2" ? "" : "keep";
}
$("#sieve-vacation-append").disabled = Boolean(state.sieveInfo) && !supports("vacation");
}
function renderSieveBuilderPreview() {
const preview = $("#sieve-builder-preview-text");
const code = $("#sieve-builder-preview-code");
const statePill = $("#sieve-builder-preview-state");
if (!preview || !code || !statePill) return;
try {
const built = buildSieveVisualRule();
preview.innerHTML = `<div><strong>When:</strong> ${escapeHtml(built.conditionSummary)}</div><div><strong>Then:</strong> ${escapeHtml(built.actionSummary || "No action")}</div>`;
code.textContent = built.rule;
statePill.textContent = built.enabled ? "Enabled" : "Disabled";
statePill.className = `sieve-status-pill ${built.enabled ? "ok" : ""}`;
} catch (error) {
preview.textContent = error.message;
code.textContent = "";
statePill.textContent = "Incomplete";
statePill.className = "sieve-status-pill error";
}
}
function updateSieveBuilder() {
const condition = $("#sieve-builder-condition").value;
$("#sieve-builder-header-wrap").classList.toggle("hidden", condition !== "header");
for (const suffix of ["-2", "-3"]) {
const nextCondition = $(`#sieve-builder-condition${suffix}`).value;
const enabled = Boolean(nextCondition);
$(`#sieve-builder-header-wrap${suffix}`).classList.toggle("hidden", nextCondition !== "header");
$(`#sieve-builder-match${suffix}`).disabled = !enabled;
$(`#sieve-builder-value${suffix}`).disabled = !enabled;
$(`#sieve-builder-negate${suffix}`).disabled = !enabled;
const customHeader = $(`#sieve-builder-header${suffix}`);
if (customHeader) customHeader.disabled = !enabled || nextCondition !== "header";
}
$("#sieve-builder-logic").disabled = !Boolean($("#sieve-builder-condition-2").value);
$("#sieve-builder-logic-3").disabled = !Boolean($("#sieve-builder-condition-3").value);
configureSieveActionFields("");
configureSieveActionFields("-2");
$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").forEach(configureSieveDynamicAction);
$$("#sieve-builder-extra-conditions .sieve-dynamic-condition").forEach((row) => {
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", row.querySelector(".sieve-dynamic-kind").value !== "header");
});
const list = $("#sieve-folder-options");
list.replaceChildren(...state.folders.map((folder) => {
const option = document.createElement("option");
option.value = folder.name;
return option;
}));
const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase()));
applySieveCapabilityMatrix(extensions);
const actions = ["", "-2"].map((suffix) => $(`#sieve-builder-action${suffix}`)?.value).filter(Boolean)
.concat($$("#sieve-builder-extra-actions .sieve-dynamic-action").map((node) => node.value).filter(Boolean));
const requirements = new Set();
for (const actionName of actions) for (const requirement of sieveBuilderRequirements("", actionName)) requirements.add(requirement);
const missing = [...requirements].filter((requirement) => state.sieveInfo && !extensions.has(requirement.toLowerCase()));
$("#sieve-builder-capability").textContent = missing.length
? `Server does not advertise: ${missing.join(", ")}`
: requirements.size ? `Requires: ${[...requirements].join(", ")}` : "No optional extension required";
const vacationSupported = extensions.has("vacation");
const vacationScheduled = Boolean($("#sieve-vacation-start")?.value || $("#sieve-vacation-end")?.value);
const scheduleSupported = extensions.has("date") && extensions.has("relational");
$("#sieve-vacation-capability").textContent = state.sieveInfo
? !vacationSupported
? "Server does not advertise vacation"
: vacationScheduled && !scheduleSupported
? "Scheduled dates require date + relational"
: vacationScheduled
? "Server supports vacation scheduling"
: "Server supports vacation"
: "";
$("#sieve-builder-replace").disabled = !state.sieveEditingRuleId;
renderSieveBuilderPreview();
}
function buildSieveVacationRule() {
const subject = $("#sieve-vacation-subject").value.trim() || "Out of office";
const message = $("#sieve-vacation-message").value.trim();
const days = Math.max(1, Math.min(30, Number.parseInt($("#sieve-vacation-days").value, 10) || 7));
const start = $("#sieve-vacation-start").value.trim();
const end = $("#sieve-vacation-end").value.trim();
if (!message) throw new Error("Autoresponder message is required");
if (start && !/^\d{4}-\d{2}-\d{2}$/.test(start)) throw new Error("Vacation start date is invalid");
if (end && !/^\d{4}-\d{2}-\d{2}$/.test(end)) throw new Error("Vacation end date is invalid");
if (start && end && end < start) throw new Error("Vacation end date must not be before the start date");
const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase()));
const requirements = new Set(["vacation"]);
const tests = [];
if (start || end) {
if (state.sieveInfo && (!extensions.has("date") || !extensions.has("relational"))) {
throw new Error("This server does not advertise the Sieve date + relational extensions required for scheduled vacation");
}
requirements.add("date");
requirements.add("relational");
if (start) tests.push(`currentdate :value "ge" "date" "${sieveQuote(start)}"`);
if (end) tests.push(`currentdate :value "le" "date" "${sieveQuote(end)}"`);
}
if ($("#sieve-vacation-skip-lists").checked) tests.push('not exists ["list-id"]');
if ($("#sieve-vacation-skip-auto").checked) tests.push('not header :matches "auto-submitted" ["auto-replied", "auto-generated"]');
const command = `vacation :days ${days} :subject "${sieveQuote(subject)}" "${sieveQuote(message)}";`;
const rule = tests.length
? `# RustBox autoresponder\nif allof (${tests.join(", ")}) {\n ${command}\n}`
: `# RustBox autoresponder\n${command}`;
return { requirements: [...requirements], rule };
}
async function appendSieveVacation() {
try {
const built = buildSieveVacationRule();
const editor = $("#sieve-editor");
editor.value = mergeSieveRequirements([editor.value.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
$("#sieve-result").textContent = "Autoresponder appended";
await validateSieve();
} catch (error) { toast(error.message, "error"); }
}
async function applySieveVisualRule(updateSelected) {
try {
const built = buildSieveVisualRule();
const editor = $("#sieve-editor");
let body = editor.value;
if (updateSelected) {
if (!state.sieveEditingRuleId) throw new Error("Select a parsed rule to update first");
const selected = parseSieveRules(body).find((rule) => rule.id === state.sieveEditingRuleId);
if (!selected) throw new Error("The selected rule no longer exists in the source");
body = `${body.slice(0, selected.start)}${built.rule}${body.slice(selected.end)}`;
} else {
body = [body.trim(), built.rule].filter(Boolean).join("\n\n");
}
editor.value = mergeSieveRequirements(body, built.requirements);
const parsed = parseSieveRules(editor.value);
const matching = parsed.find((rule) => rule.name === built.name);
state.sieveEditingRuleId = matching?.id || null;
renderManagedSieveRules();
updateSieveBuilder();
$("#sieve-result").textContent = updateSelected ? `Rule "${built.name}" updated - save to apply` : `Rule "${built.name}" added - save to apply`;
await validateSieve();
if ($("#sieve-rule-dialog")?.open) $("#sieve-rule-dialog").close();
} catch (error) {
toast(error.message, "error");
}
}
function renderSieveScripts() {
const list = $("#sieve-scripts");
list.replaceChildren();
if (!state.sieveScripts.length) list.append(el("div", "empty-list", "No scripts"));
for (const script of state.sieveScripts) {
const button = el("button", `sieve-script ${script.active ? "active" : ""}`);
button.type = "button";
button.innerHTML = `<span>${escapeHtml(script.name)}</span><span>${script.active ? "ACTIVE" : ""}</span>`;
button.addEventListener("click", () => openSieveScript(script.name));
list.append(button);
}
}
async function openSieveScript(name) {
try {
const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "GET" });
$("#sieve-name").value = data.name;
$("#sieve-editor").value = data.content;
state.sieveEditingRuleId = null;
clearSieveRuleBuilder();
renderManagedSieveRules();
$("#sieve-result").textContent = "Loaded";
} catch (error) { toast(error.message, "error"); }
}
function newSieveScript() {
$("#sieve-name").value = "rustbox";
$("#sieve-editor").value = `require ["fileinto"];\n`;
state.sieveEditingRuleId = null;
clearSieveRuleBuilder();
renderManagedSieveRules();
$("#sieve-result").textContent = "New script";
$("#sieve-name").focus();
}
async function validateSieve() {
try {
const data = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: $("#sieve-editor").value }) });
$("#sieve-result").textContent = data.valid ? (data.warning || "Valid script") : (data.error || "Invalid script");
return data.valid;
} catch (error) {
$("#sieve-result").textContent = error.message;
toast(error.message, "error");
return false;
}
}
async function backupSieveScripts() {
try {
if (!state.sieveScripts.length) await loadSieve();
if (!state.sieveScripts.length) return toast("No Sieve scripts to back up", "info");
const scripts = [];
for (const script of state.sieveScripts) {
const data = await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method: "GET" });
scripts.push({ name: script.name, active: Boolean(script.active), content: data.content || "" });
}
const payload = {
format: "rustbox-sieve-backup",
version: 1,
exported_at: new Date().toISOString(),
scripts,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `rustbox-sieve-${new Date().toISOString().slice(0, 10)}.json`;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast(`Backed up ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function restoreSieveBackup(file) {
if (!file) return;
try {
if (file.size > 2 * 1024 * 1024) throw new Error("Sieve backup is too large");
const payload = JSON.parse(await file.text());
if (payload?.format !== "rustbox-sieve-backup" || payload?.version !== 1 || !Array.isArray(payload.scripts)) {
throw new Error("This is not a supported RustBox Sieve backup");
}
const scripts = payload.scripts.slice(0, 100).map((script) => ({
name: String(script?.name || "").trim(),
active: Boolean(script?.active),
content: String(script?.content || ""),
}));
if (!scripts.length || scripts.some((script) => !script.name || script.name.length > 128 || /[\r\n]/.test(script.name))) {
throw new Error("Backup contains an invalid script name");
}
if (payload.scripts.length > 100) throw new Error("Backup contains too many scripts");
const activeCount = scripts.filter((script) => script.active).length;
if (activeCount > 1) throw new Error("Backup marks more than one Sieve script as active");
const existing = new Set(state.sieveScripts.map((script) => script.name));
const overwriteCount = scripts.filter((script) => existing.has(script.name)).length;
const confirmed = await appConfirm(
`Restore ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}${overwriteCount ? ` and overwrite ${overwriteCount} existing script${overwriteCount === 1 ? "" : "s"}` : ""}? Existing scripts not present in the backup will be kept.`,
{ title: "Restore Sieve backup", confirmLabel: "Restore", danger: overwriteCount > 0 }
);
if (!confirmed) return;
for (const script of scripts) {
const result = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: script.content }) });
if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`);
}
for (const script of scripts) {
await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method: "PUT", body: JSON.stringify({ content: script.content }) });
}
const active = scripts.find((script) => script.active);
if (active) await request(`/sieve/scripts/${encodeURIComponent(active.name)}/activate`, { method: "POST", body: "{}" });
await loadSieve();
toast(`Restored ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success");
} catch (error) { toast(error.message, "error"); }
finally { $("#sieve-restore-file").value = ""; }
}
async function saveSieve() {
const name = $("#sieve-name").value.trim();
if (!name) return toast("Script name is required", "error");
const existing = state.sieveScripts.find((script) => script.name === name);
if (existing?.active) {
const confirmed = await appConfirm(`“${name}” is the active Sieve script. Validate and overwrite it?`, { title: "Overwrite active filter script", confirmLabel: "Overwrite", danger: true });
if (!confirmed) return;
}
if (!await validateSieve()) return;
try {
const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify({ content: $("#sieve-editor").value }) });
$("#sieve-result").textContent = data.warning || "Saved";
await loadSieve();
toast(`Sieve script “${name}” saved`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function activateSieve() {
const name = $("#sieve-name").value.trim();
if (!name) return toast("Select a script first", "error");
try {
await request(`/sieve/scripts/${encodeURIComponent(name)}/activate`, { method: "POST", body: "{}" });
await loadSieve();
toast(`Sieve script “${name}” activated`, "success");
} catch (error) { toast(error.message, "error"); }
}
async function deactivateSieve() {
try {
await request("/sieve/deactivate", { method: "POST", body: "{}" });
await loadSieve();
toast("Sieve script deactivated", "success");
} catch (error) { toast(error.message, "error"); }
}
async function deleteSieve() {
const name = $("#sieve-name").value.trim();
if (!name || !await appConfirm(`Delete Sieve script “${name}”?`, { title: "Delete Sieve script", confirmLabel: "Delete", danger: true })) return;
try {
await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "DELETE" });
$("#sieve-name").value = "";
$("#sieve-editor").value = "";
await loadSieve();
toast(`Sieve script “${name}” deleted`, "success");
} catch (error) { toast(error.message, "error"); }
}