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 = `When: ${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 = `Then: ${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 = ``; 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 = ``; 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 = `
When: ${escapeHtml(built.conditionSummary)}
Then: ${escapeHtml(built.actionSummary || "No action")}
`; 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 = `${escapeHtml(script.name)}${script.active ? "ACTIVE" : ""}`; 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"); } }