1026 lines
51 KiB
JavaScript
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"); }
|
|
}
|
|
|