Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { newRule, reorderRules, rulesToSieve, sieveToRules, testToSieve, sieveString, upsertRule, type SieveRule } from "../sieve";
|
||||
|
||||
describe("sieve codec", () => {
|
||||
it("escapes strings", () => {
|
||||
expect(sieveString('a "quoted" \\ value')).toBe('"a \\"quoted\\" \\\\ value"');
|
||||
});
|
||||
it("generates tests", () => {
|
||||
expect(testToSieve({ type: "header", header: "subject", op: "contains", value: "hi" })).toBe('header :contains "subject" "hi"');
|
||||
expect(testToSieve({ type: "header", header: "x-foo", op: "notexists", value: "" })).toBe('not exists "x-foo"');
|
||||
expect(testToSieve({ type: "address", header: "from", part: "domain", op: "is", value: "example.com" })).toBe('address :domain :is "from" "example.com"');
|
||||
expect(testToSieve({ type: "size", op: "over", value: 2048 })).toBe("size :over 2048");
|
||||
});
|
||||
it("round-trips rules through a script", () => {
|
||||
const rules = [
|
||||
newRule({ id: "r1", name: "Newsletters", tests: [{ type: "header", header: "list-id", op: "exists", value: "" }], actions: [{ type: "fileinto", mailbox: "Newsletters" }, { type: "markread" }, { type: "stop" }] }),
|
||||
newRule({ id: "r2", name: "Big", enabled: false, join: "anyof", tests: [{ type: "size", op: "over", value: 5_000_000 }], actions: [{ type: "addflag", flag: "big" }] }),
|
||||
];
|
||||
const script = rulesToSieve(rules);
|
||||
expect(script).toContain('require ["fileinto", "imap4flags"];');
|
||||
expect(script).toContain('if exists "list-id"');
|
||||
expect(script).toContain('fileinto "Newsletters";');
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
expect(script).toContain("# (disabled) Big");
|
||||
expect(sieveToRules(script)).toEqual(rules);
|
||||
});
|
||||
it("keeps an edited rule in its place and appends a new one", () => {
|
||||
const rules = ["r1", "r2", "r3"].map((id) => newRule({ id, name: id }));
|
||||
const renamed = { ...rules[1]!, name: "Renamed" };
|
||||
expect(upsertRule(rules, renamed).map((r) => r.id)).toEqual(["r1", "r2", "r3"]);
|
||||
expect(upsertRule(rules, renamed)[1]!.name).toBe("Renamed");
|
||||
expect(upsertRule(rules, newRule({ id: "r4" })).map((r) => r.id)).toEqual(["r1", "r2", "r3", "r4"]);
|
||||
expect(rules.map((r) => r.name)).toEqual(["r1", "r2", "r3"]);
|
||||
});
|
||||
it("reports hand-written scripts as raw", () => {
|
||||
expect(sieveToRules('require ["fileinto"];\nif true { keep; }')).toBeNull();
|
||||
expect(sieveToRules("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reordering rules", () => {
|
||||
const ids = (rs: SieveRule[]) => rs.map((r) => r.id);
|
||||
const list = ["a", "b", "c", "d"].map((id) => newRule({ id }));
|
||||
|
||||
it("drops a rule above or below the card it was dropped on", () => {
|
||||
expect(ids(reorderRules(list, "a", "c", false))).toEqual(["b", "a", "c", "d"]);
|
||||
expect(ids(reorderRules(list, "a", "c", true))).toEqual(["b", "c", "a", "d"]);
|
||||
expect(ids(reorderRules(list, "d", "a", false))).toEqual(["d", "a", "b", "c"]);
|
||||
expect(ids(reorderRules(list, "b", "d", true))).toEqual(["a", "c", "d", "b"]);
|
||||
});
|
||||
it("leaves the list alone when the drop goes nowhere", () => {
|
||||
expect(reorderRules(list, "a", "a", true)).toBe(list);
|
||||
expect(reorderRules(list, "a", "zz", true)).toBe(list);
|
||||
expect(reorderRules(list, "zz", "a", true)).toBe(list);
|
||||
expect(ids(list)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateRule, evaluateTest } from "../sieveApply";
|
||||
import type { Email } from "@/jmap/types";
|
||||
import type { SieveRule } from "../sieve";
|
||||
|
||||
const email = {
|
||||
id: "e1", blobId: "b", threadId: "t", mailboxIds: { inbox: true }, keywords: {}, size: 5000, receivedAt: "2026-01-01T00:00:00Z",
|
||||
from: [{ name: "Ada Lovelace", email: "[email protected]" }], to: [{ name: null, email: "[email protected]" }], subject: "Invoice #42 is ready", preview: "Please find attached",
|
||||
"header:List-Id:asText": "<dev.lists.example.org>",
|
||||
} as unknown as Email;
|
||||
|
||||
describe("sieve client-side evaluation", () => {
|
||||
it("evaluates header/address/size/body tests", () => {
|
||||
expect(evaluateTest(email, { type: "header", header: "from", op: "contains", value: "ada@" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "subject", op: "matches", value: "invoice*ready" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "subject", op: "regex", value: "^Invoice #\\d+" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "list-id", op: "exists", value: "" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "x-none", op: "notexists", value: "" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "address", header: "from", part: "domain", op: "is", value: "example.org" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "address", header: "from", part: "localpart", op: "is", value: "ada" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "size", op: "over", value: 1000 })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "size", op: "under", value: 1000 })).toBe(false);
|
||||
expect(evaluateTest(email, { type: "body", op: "contains", value: "attached" }, "Please find attached the file")).toBe(true);
|
||||
});
|
||||
it("combines with allof/anyof", () => {
|
||||
const base: SieveRule = { id: "r", name: "r", enabled: true, join: "allof", tests: [{ type: "header", header: "from", op: "contains", value: "ada" }, { type: "header", header: "subject", op: "contains", value: "nope" }], actions: [] };
|
||||
expect(evaluateRule(email, base)).toBe(false);
|
||||
expect(evaluateRule(email, { ...base, join: "anyof" })).toBe(true);
|
||||
expect(evaluateRule(email, { ...base, tests: [{ type: "true" }] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retargetRules, detachFolders } from "../sieveFolders";
|
||||
import { newRule, type SieveRule } from "../sieve";
|
||||
|
||||
/**
|
||||
* Rules name their destination folder by path, because that is what Sieve
|
||||
* needs. Rename the folder and the path is a lie: mail stops being filed and
|
||||
* nothing says so. These keep the rules following the folder.
|
||||
*/
|
||||
const fileinto = (mailbox: string, mailboxId?: string, extra: SieveRule["actions"] = []): SieveRule["actions"] =>
|
||||
[{ type: "fileinto", mailbox, ...(mailboxId ? { mailboxId } : {}) }, ...extra];
|
||||
|
||||
const rule = (name: string, actions: SieveRule["actions"]) => newRule({ id: name, name, actions });
|
||||
|
||||
describe("retargetRules", () => {
|
||||
it("follows a folder that was renamed, matching on the id", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1"))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Reading" }]);
|
||||
expect(out.changed).toBe(1);
|
||||
expect(out.rules[0]!.actions[0]).toMatchObject({ mailbox: "Reading", mailboxId: "mb1" });
|
||||
});
|
||||
|
||||
it("follows a folder for older rules that only know the path", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters"))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "newsletters", newPath: "Reading" }]);
|
||||
expect(out.changed).toBe(1);
|
||||
// The id is recorded on the way past, so the next rename needs no guessing.
|
||||
expect(out.rules[0]!.actions[0]).toMatchObject({ mailbox: "Reading", mailboxId: "mb1" });
|
||||
});
|
||||
|
||||
it("follows a child whose parent was renamed", () => {
|
||||
const rules = [rule("inv", fileinto("Work/Invoices", "mb2"))];
|
||||
const out = retargetRules(rules, [
|
||||
{ id: "mb1", path: "Work", newPath: "Clients" },
|
||||
{ id: "mb2", path: "Work/Invoices", newPath: "Clients/Invoices" },
|
||||
]);
|
||||
expect(out.rules[0]!.actions[0]).toMatchObject({ mailbox: "Clients/Invoices" });
|
||||
});
|
||||
|
||||
it("leaves everything alone when nothing actually moved", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1"))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Newsletters" }]);
|
||||
expect(out.changed).toBe(0);
|
||||
expect(out.rules).toBe(rules); // same array, so the caller can skip saving
|
||||
});
|
||||
|
||||
it("does not touch rules aimed somewhere else", () => {
|
||||
const rules = [rule("other", fileinto("Archive", "mb9"))];
|
||||
expect(retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Reading" }]).changed).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the rule's other actions", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1", [{ type: "markread" }, { type: "stop" }]))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Reading" }]);
|
||||
expect(out.rules[0]!.actions.map((a) => a.type)).toEqual(["fileinto", "markread", "stop"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detachFolders", () => {
|
||||
it("removes only the filing action, leaving the rest of the rule doing its job", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1", [{ type: "markread" }, { type: "stop" }]))];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.removed).toEqual([]);
|
||||
expect(out.edited).toHaveLength(1);
|
||||
expect(out.rules[0]!.actions.map((a) => a.type)).toEqual(["markread", "stop"]);
|
||||
});
|
||||
|
||||
it("removes the rule when filing was all it did", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1")), rule("keep", fileinto("Archive", "mb9"))];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.removed.map((r) => r.name)).toEqual(["news"]);
|
||||
expect(out.rules.map((r) => r.name)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
it("handles a deleted folder's children too", () => {
|
||||
const rules = [
|
||||
rule("a", fileinto("Work", "mb1")),
|
||||
rule("b", fileinto("Work/Invoices", "mb2", [{ type: "flag" }])),
|
||||
];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Work" }, { id: "mb2", path: "Work/Invoices" }]);
|
||||
expect(out.removed.map((r) => r.name)).toEqual(["a"]);
|
||||
expect(out.rules.map((r) => r.name)).toEqual(["b"]);
|
||||
expect(out.rules[0]!.actions.map((a) => a.type)).toEqual(["flag"]);
|
||||
});
|
||||
|
||||
it("still finds the rule when only the path matches", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters"))];
|
||||
expect(detachFolders(rules, [{ id: "mb1", path: "NEWSLETTERS" }]).removed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a second filing action aimed somewhere that still exists", () => {
|
||||
const rules = [rule("both", [
|
||||
{ type: "fileinto", mailbox: "Newsletters", mailboxId: "mb1" },
|
||||
{ type: "fileinto", mailbox: "Archive", mailboxId: "mb9", copy: true },
|
||||
])];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.removed).toEqual([]);
|
||||
expect(out.rules[0]!.actions).toEqual([{ type: "fileinto", mailbox: "Archive", mailboxId: "mb9", copy: true }]);
|
||||
});
|
||||
|
||||
it("leaves the list untouched when nothing matches", () => {
|
||||
const rules = [rule("keep", fileinto("Archive", "mb9"))];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.rules).toBe(rules);
|
||||
expect(out.edited).toEqual([]);
|
||||
expect(out.removed).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Visual filter rules <-> Sieve script codec.
|
||||
*
|
||||
* Rules are persisted inside the Sieve script itself as JSON comments
|
||||
* (`# rule:{...}`) so the UI can round-trip them losslessly; the generated
|
||||
* Sieve below each comment is what the server actually runs.
|
||||
*/
|
||||
|
||||
import { formatList } from "../datetime";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
|
||||
|
||||
export type SieveTest =
|
||||
| { type: "header"; header: string; op: HeaderOp; value: string }
|
||||
| { type: "address"; header: string; part: "all" | "localpart" | "domain"; op: HeaderOp; value: string }
|
||||
| { type: "size"; op: "over" | "under"; value: number }
|
||||
| { type: "body"; op: "contains" | "notcontains"; value: string }
|
||||
| { type: "true" };
|
||||
|
||||
export type SieveAction =
|
||||
| { type: "fileinto"; mailbox: string; mailboxId?: string; copy?: boolean }
|
||||
| { type: "redirect"; address: string; copy?: boolean }
|
||||
| { type: "discard" }
|
||||
| { type: "keep" }
|
||||
| { type: "reject"; reason: string }
|
||||
| { type: "addflag"; flag: string }
|
||||
| { type: "setflag"; flag: string }
|
||||
| { type: "removeflag"; flag: string }
|
||||
| { type: "markread" }
|
||||
| { type: "flag" }
|
||||
| { type: "stop" };
|
||||
|
||||
export interface SieveRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
join: "allof" | "anyof";
|
||||
tests: SieveTest[];
|
||||
actions: SieveAction[];
|
||||
}
|
||||
|
||||
export const HEADER_CHOICES = [
|
||||
{ value: "from", label: "From" },
|
||||
{ value: "to", label: "To" },
|
||||
{ value: "cc", label: "Cc" },
|
||||
{ value: "subject", label: "Subject" },
|
||||
{ value: "list-id", label: "List-Id" },
|
||||
{ value: "reply-to", label: "Reply-To" },
|
||||
{ value: "x-spam-status", label: "X-Spam-Status" },
|
||||
{ value: "__custom__", label: "Other header…" },
|
||||
];
|
||||
|
||||
export const HEADER_OPS: Array<{ value: HeaderOp; label: string }> = [
|
||||
{ value: "contains", label: "contains" },
|
||||
{ value: "notcontains", label: "does not contain" },
|
||||
{ value: "is", label: "is" },
|
||||
{ value: "notis", label: "is not" },
|
||||
{ value: "matches", label: "matches (wildcards * ?)" },
|
||||
{ value: "notmatches", label: "does not match" },
|
||||
{ value: "regex", label: "matches regex" },
|
||||
{ value: "notregex", label: "does not match regex" },
|
||||
{ value: "exists", label: "exists" },
|
||||
{ value: "notexists", label: "does not exist" },
|
||||
];
|
||||
|
||||
export function sieveString(s: string): string {
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, " ")}"`;
|
||||
}
|
||||
|
||||
function opToSieve(op: HeaderOp): { neg: boolean; match: string } {
|
||||
const neg = op.startsWith("not");
|
||||
const base = neg ? op.slice(3) : op;
|
||||
return { neg, match: base === "regex" ? ":regex" : base === "matches" ? ":matches" : base === "is" ? ":is" : base === "exists" ? "exists" : ":contains" };
|
||||
}
|
||||
|
||||
export function testToSieve(t: SieveTest): string {
|
||||
switch (t.type) {
|
||||
case "true":
|
||||
return "true";
|
||||
case "header": {
|
||||
const { neg, match } = opToSieve(t.op);
|
||||
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `header ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
|
||||
return neg ? `not ${inner}` : inner;
|
||||
}
|
||||
case "address": {
|
||||
const { neg, match } = opToSieve(t.op);
|
||||
const part = t.part === "all" ? ":all" : t.part === "localpart" ? ":localpart" : ":domain";
|
||||
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `address ${part} ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
|
||||
return neg ? `not ${inner}` : inner;
|
||||
}
|
||||
case "size":
|
||||
return `size :${t.op} ${Math.max(0, Math.round(t.value))}`;
|
||||
case "body": {
|
||||
const inner = `body :text :contains ${sieveString(t.value)}`;
|
||||
return t.op === "notcontains" ? `not ${inner}` : inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function actionToSieve(a: SieveAction): string[] {
|
||||
switch (a.type) {
|
||||
case "fileinto":
|
||||
return [`fileinto${a.copy ? " :copy" : ""} ${sieveString(a.mailbox)};`];
|
||||
case "redirect":
|
||||
return [`redirect${a.copy ? " :copy" : ""} ${sieveString(a.address)};`];
|
||||
case "discard":
|
||||
return ["discard;"];
|
||||
case "keep":
|
||||
return ["keep;"];
|
||||
case "reject":
|
||||
return [`reject ${sieveString(a.reason || "Message rejected")};`];
|
||||
case "addflag":
|
||||
return [`addflag ${sieveString(a.flag)};`];
|
||||
case "setflag":
|
||||
return [`setflag ${sieveString(a.flag)};`];
|
||||
case "removeflag":
|
||||
return [`removeflag ${sieveString(a.flag)};`];
|
||||
case "markread":
|
||||
return ['addflag "\\\\Seen";'];
|
||||
case "flag":
|
||||
return ['addflag "\\\\Flagged";'];
|
||||
case "stop":
|
||||
return ["stop;"];
|
||||
}
|
||||
}
|
||||
|
||||
export function requiredExtensions(rules: SieveRule[]): string[] {
|
||||
const req = new Set<string>();
|
||||
for (const r of rules) {
|
||||
for (const t of r.tests) {
|
||||
if (t.type === "body") req.add("body");
|
||||
if ((t.type === "header" || t.type === "address") && (t.op === "regex" || t.op === "notregex")) req.add("regex");
|
||||
if (t.type === "address") req.add("envelope");
|
||||
}
|
||||
for (const a of r.actions) {
|
||||
if (a.type === "fileinto") {
|
||||
req.add("fileinto");
|
||||
if (a.copy) req.add("copy");
|
||||
}
|
||||
if (a.type === "redirect" && a.copy) req.add("copy");
|
||||
if (a.type === "reject") req.add("reject");
|
||||
if (["addflag", "setflag", "removeflag", "markread", "flag"].includes(a.type)) req.add("imap4flags");
|
||||
}
|
||||
}
|
||||
req.delete("envelope");
|
||||
return [...req].sort();
|
||||
}
|
||||
|
||||
export const SCRIPT_HEADER = "# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments";
|
||||
|
||||
export function rulesToSieve(rules: SieveRule[]): string {
|
||||
const ext = requiredExtensions(rules);
|
||||
const lines: string[] = [SCRIPT_HEADER];
|
||||
if (ext.length) lines.push(`require [${ext.map(sieveString).join(", ")}];`);
|
||||
lines.push("");
|
||||
for (const r of rules) {
|
||||
lines.push(`# rule:${JSON.stringify(r)}`);
|
||||
if (!r.enabled) {
|
||||
lines.push(`# (disabled) ${r.name}`);
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
const tests = r.tests.filter((t) => t.type !== "true");
|
||||
let cond: string;
|
||||
if (!tests.length) cond = "true";
|
||||
else if (tests.length === 1) cond = testToSieve(tests[0]!);
|
||||
else cond = `${r.join} (${tests.map(testToSieve).join(", ")})`;
|
||||
const body = r.actions.flatMap(actionToSieve).map((l) => ` ${l}`);
|
||||
if (!body.length) body.push(" keep;");
|
||||
lines.push(`if ${cond}`);
|
||||
lines.push("{");
|
||||
lines.push(...body);
|
||||
lines.push("}");
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this script must not be rewritten from the rules parsed out of it, or
|
||||
* null when rewriting it is safe.
|
||||
*
|
||||
* Saving replaces the whole script with a fresh serialization of the rules read
|
||||
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
|
||||
* the alarm by itself: it skips what it does not recognize, so a script cut off
|
||||
* partway through parses cleanly into a shorter list and looks exactly like one
|
||||
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
|
||||
* truncated download, a plausible parse, and a save that wrote the short
|
||||
* version back over the real one. The transport fault behind it is fixed in the
|
||||
* blob proxy; this is the check that makes the save path refuse regardless of
|
||||
* how the content came to be short.
|
||||
*
|
||||
* The tests are structural rather than an equality check against
|
||||
* `rulesToSieve(sieveToRules(content))`. A script written by an older version
|
||||
* whose serializer differed in some detail is intact, and refusing to let
|
||||
* anyone edit their rules over a changed byte would be the worse bug.
|
||||
*/
|
||||
export function scriptDamage(content: string): string | null {
|
||||
// Not one of ours: genuinely empty, or hand-written. Both mean something
|
||||
// else and are answered elsewhere.
|
||||
if (!content.includes("# rule:") && !content.includes(SCRIPT_HEADER)) {
|
||||
// Unless it is one of ours cut off inside its own first line, which reads
|
||||
// as a very short hand-written script -- and that reading is the one that
|
||||
// offers to replace it.
|
||||
const head = content.replace(/\n+$/, "");
|
||||
if (head !== "" && SCRIPT_HEADER.startsWith(head)) return "breaks off inside its first line";
|
||||
return null;
|
||||
}
|
||||
// Every generated script ends with a newline, so a body that stops mid-line
|
||||
// stopped early. The rest of the walk covers the cuts that land on one.
|
||||
if (!content.endsWith("\n")) return "stops in the middle of a line";
|
||||
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||
let i = 0;
|
||||
let hadRequire = false;
|
||||
if (lines[0] === SCRIPT_HEADER) {
|
||||
i = 1;
|
||||
hadRequire = lines[i]?.startsWith("require ") ?? false;
|
||||
if (hadRequire) i++;
|
||||
if (lines[i] !== "") return "breaks off in its opening lines";
|
||||
i++;
|
||||
} else {
|
||||
// Header edited away but the rule comments kept. Still ours to walk.
|
||||
i = lines.findIndex((l) => l.startsWith("# rule:"));
|
||||
}
|
||||
|
||||
// Walk the shape rulesToSieve emits, one rule block at a time. Deliberately
|
||||
// structural: the condition and action lines are read only for their
|
||||
// presence, so a serializer that words them differently is still intact.
|
||||
let seen = 0;
|
||||
const cut = (r: SieveRule, what: string) => `has a rule in it (“${r.name}”) ${what}`;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
if (!line.startsWith("# rule:")) return "has a stray line where a rule should start";
|
||||
let rule: SieveRule | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(7)) as SieveRule;
|
||||
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tests) && Array.isArray(parsed.actions)) rule = parsed;
|
||||
} catch {
|
||||
/* reported below */
|
||||
}
|
||||
if (!rule) return "has a rule in it that breaks off unfinished";
|
||||
seen++;
|
||||
i++;
|
||||
// `!r.enabled` is how rulesToSieve chooses the branch, so match it exactly
|
||||
// rather than testing for `=== false`.
|
||||
if (rule.enabled) {
|
||||
if (!lines[i]?.startsWith("if ")) return cut(rule, "with nothing below it");
|
||||
i++;
|
||||
if (lines[i] !== "{") return cut(rule, "whose body never opens");
|
||||
i++;
|
||||
while (i < lines.length && lines[i] !== "}") {
|
||||
if (lines[i] === "") return cut(rule, "whose body breaks off");
|
||||
i++;
|
||||
}
|
||||
if (i >= lines.length) return cut(rule, "whose body never closes");
|
||||
i++;
|
||||
} else {
|
||||
if (!lines[i]?.startsWith("# (disabled) ")) return cut(rule, "with nothing below it");
|
||||
i++;
|
||||
}
|
||||
// Each block is followed by a blank line, the last one included: it is the
|
||||
// empty final element left by the trailing newline.
|
||||
if (lines[i] !== "") return cut(rule, "that runs into what follows it");
|
||||
i++;
|
||||
}
|
||||
// A require line is written only for rules that need it, so one standing over
|
||||
// no rules at all means the rules it was written for are gone.
|
||||
if (hadRequire && seen === 0) return "breaks off before the first rule";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns rules if the script was generated by ihasmail, else null (raw script). */
|
||||
export function sieveToRules(script: string): SieveRule[] | null {
|
||||
if (!script.includes("# rule:")) return script.trim() === "" || script.includes(SCRIPT_HEADER) ? [] : null;
|
||||
const out: SieveRule[] = [];
|
||||
for (const line of script.split(/\r?\n/)) {
|
||||
if (!line.startsWith("# rule:")) continue;
|
||||
try {
|
||||
const r = JSON.parse(line.slice(7)) as SieveRule;
|
||||
if (r && typeof r === "object" && Array.isArray(r.tests) && Array.isArray(r.actions)) out.push(r);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newRule(partial: Partial<SieveRule> = {}): SieveRule {
|
||||
return {
|
||||
id: `r${Math.random().toString(36).slice(2, 9)}`,
|
||||
name: "New filter",
|
||||
enabled: true,
|
||||
join: "allof",
|
||||
tests: [{ type: "header", header: "from", op: "contains", value: "" }],
|
||||
actions: [{ type: "fileinto", mailbox: "INBOX" }],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a rule with the same id in place, or appends it when it is new.
|
||||
* Order is evaluation order in Sieve, so an edited rule has to keep its seat.
|
||||
*/
|
||||
export function upsertRule(rules: SieveRule[], rule: SieveRule): SieveRule[] {
|
||||
return rules.some((x) => x.id === rule.id) ? rules.map((x) => (x.id === rule.id ? rule : x)) : [...rules, rule];
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the rule `fromId` to sit either side of `toId`. `below` says which,
|
||||
* decided by which half of the target card the pointer was over.
|
||||
*/
|
||||
export function reorderRules(rules: SieveRule[], fromId: string, toId: string, below: boolean): SieveRule[] {
|
||||
if (fromId === toId) return rules;
|
||||
const moved = rules.find((r) => r.id === fromId);
|
||||
const rest = rules.filter((r) => r.id !== fromId);
|
||||
const target = rest.findIndex((r) => r.id === toId);
|
||||
if (!moved || target < 0) return rules;
|
||||
const at = below ? target + 1 : target;
|
||||
return [...rest.slice(0, at), moved, ...rest.slice(at)];
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter rule as a sentence, for the rule list.
|
||||
*
|
||||
* Rebuilt as whole sentences with placeholders. The old version concatenated
|
||||
* fragments -- a header name, an operator, a quoted value, joined by " and "
|
||||
* -- which no catalog could fix: German puts the verb last, Japanese does
|
||||
* not separate list items with a word at all, and a translator handed " and "
|
||||
* on its own cannot move anything. Reported by a native speaker reviewing the
|
||||
* German catalog (#247).
|
||||
*
|
||||
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
|
||||
* for an anyof rule, the disjunction the language actually uses.
|
||||
*/
|
||||
export function describeRule(r: SieveRule): string {
|
||||
const headerLabel = (h: string): string => t(HEADER_CHOICES.find((c) => c.value === h)?.label ?? h);
|
||||
const opLabel = (op: string): string => t(HEADER_OPS.find((o) => o.value === op)?.label ?? op);
|
||||
|
||||
const tests = r.tests.map((test) => {
|
||||
switch (test.type) {
|
||||
case "header":
|
||||
return t('{header} {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
|
||||
case "address":
|
||||
return t('{header} address {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
|
||||
case "size":
|
||||
return test.op === "over"
|
||||
? t("size is over {n} KB", { n: Math.round(test.value / 1024) })
|
||||
: t("size is under {n} KB", { n: Math.round(test.value / 1024) });
|
||||
case "body":
|
||||
return test.op === "contains"
|
||||
? t('body contains "{value}"', { value: test.value })
|
||||
: t('body does not contain "{value}"', { value: test.value });
|
||||
case "true":
|
||||
return t("always");
|
||||
}
|
||||
});
|
||||
|
||||
const actions = r.actions.map((a) => {
|
||||
switch (a.type) {
|
||||
case "fileinto": return t("move to {folder}", { folder: a.mailbox });
|
||||
case "redirect": return t("forward to {address}", { address: a.address });
|
||||
case "discard": return t("delete it");
|
||||
case "keep": return t("keep it");
|
||||
case "reject": return t("reject it");
|
||||
case "markread": return t("mark it read");
|
||||
case "flag": return t("star it");
|
||||
case "addflag":
|
||||
case "setflag": return t("add {flag}", { flag: a.flag });
|
||||
case "removeflag": return t("remove {flag}", { flag: a.flag });
|
||||
case "stop": return t("stop");
|
||||
}
|
||||
});
|
||||
|
||||
return t("{tests} → {actions}", {
|
||||
tests: tests.length ? formatList(tests, r.join === "allof" ? "conjunction" : "disjunction") : t("always"),
|
||||
actions: formatList(actions, "conjunction"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Client-side evaluation of a visual Sieve rule against existing messages, so a
|
||||
* newly created filter can be applied retroactively to a folder (the server only
|
||||
* runs Sieve on delivery).
|
||||
*/
|
||||
import { client, chunk } from "@/jmap/client";
|
||||
import type { Email, GetResponse, Id, QueryResponse } from "@/jmap/types";
|
||||
import { LIST_PROPS, useMail } from "@/store/mail";
|
||||
import type { SieveRule, SieveTest } from "./sieve";
|
||||
import { domainOf } from "../address";
|
||||
|
||||
function headerValues(e: Email, header: string): string[] {
|
||||
const h = header.toLowerCase();
|
||||
const addr = (list?: { name: string | null; email: string }[] | null) => (list ?? []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email));
|
||||
switch (h) {
|
||||
case "from":
|
||||
return addr(e.from);
|
||||
case "to":
|
||||
return addr(e.to);
|
||||
case "cc":
|
||||
return addr(e.cc);
|
||||
case "bcc":
|
||||
return addr(e.bcc);
|
||||
case "reply-to":
|
||||
return addr(e.replyTo);
|
||||
case "sender":
|
||||
return addr(e.sender);
|
||||
case "subject":
|
||||
return e.subject ? [e.subject] : [];
|
||||
case "message-id":
|
||||
return e.messageId ?? [];
|
||||
default: {
|
||||
const rec = e as unknown as Record<string, unknown>;
|
||||
const key = Object.keys(rec).find((k) => k.toLowerCase().startsWith(`header:${h}:`));
|
||||
const v = key ? rec[key] : undefined;
|
||||
return typeof v === "string" ? [v] : Array.isArray(v) ? (v as string[]) : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addressValues(e: Email, header: string, part: "all" | "localpart" | "domain"): string[] {
|
||||
const h = header.toLowerCase();
|
||||
const list = h === "from" ? e.from : h === "to" ? e.to : h === "cc" ? e.cc : h === "bcc" ? e.bcc : h === "reply-to" ? e.replyTo : h === "sender" ? e.sender : null;
|
||||
return (list ?? []).map((a) => (part === "domain" ? domainOf(a.email) : part === "localpart" ? a.email.split("@")[0] ?? "" : a.email));
|
||||
}
|
||||
|
||||
function wildcardToRegex(pattern: string): RegExp {
|
||||
const esc = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
||||
return new RegExp(`^${esc}$`, "i");
|
||||
}
|
||||
|
||||
function matchOp(values: string[], op: string, value: string): boolean {
|
||||
const neg = op.startsWith("not");
|
||||
const base = neg ? op.slice(3) : op;
|
||||
const v = value.toLowerCase();
|
||||
let r: boolean;
|
||||
switch (base) {
|
||||
case "exists":
|
||||
r = values.length > 0;
|
||||
break;
|
||||
case "is":
|
||||
r = values.some((x) => x.toLowerCase() === v);
|
||||
break;
|
||||
case "matches":
|
||||
r = values.some((x) => wildcardToRegex(value).test(x));
|
||||
break;
|
||||
case "regex": {
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(value, "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
r = re ? values.some((x) => re!.test(x)) : false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
r = values.some((x) => x.toLowerCase().includes(v));
|
||||
}
|
||||
return neg ? !r : r;
|
||||
}
|
||||
|
||||
export function evaluateTest(e: Email, t: SieveTest, bodyText?: string): boolean {
|
||||
switch (t.type) {
|
||||
case "true":
|
||||
return true;
|
||||
case "header":
|
||||
return matchOp(headerValues(e, t.header), t.op, t.value);
|
||||
case "address":
|
||||
return matchOp(addressValues(e, t.header, t.part), t.op, t.value);
|
||||
case "size":
|
||||
return t.op === "over" ? e.size > t.value : e.size < t.value;
|
||||
case "body": {
|
||||
const has = (bodyText ?? e.preview ?? "").toLowerCase().includes(t.value.toLowerCase());
|
||||
return t.op === "contains" ? has : !has;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateRule(e: Email, rule: SieveRule, bodyText?: string): boolean {
|
||||
const tests = rule.tests.filter((t) => t.type !== "true");
|
||||
if (!tests.length) return true;
|
||||
return rule.join === "anyof" ? tests.some((t) => evaluateTest(e, t, bodyText)) : tests.every((t) => evaluateTest(e, t, bodyText));
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
scanned: number;
|
||||
matched: number;
|
||||
skippedActions: string[];
|
||||
}
|
||||
|
||||
/** Apply a rule's actions to all matching messages currently in `mailboxId`. */
|
||||
export async function applyRuleToMailbox(rule: SieveRule, mailboxId: Id, onProgress?: (scanned: number, total: number) => void): Promise<ApplyResult> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId;
|
||||
if (!accountId) throw new Error("Not signed in");
|
||||
const customHeaders = rule.tests.filter((t): t is Extract<SieveTest, { type: "header" }> => t.type === "header").map((t) => t.header).filter((h) => !["from", "to", "cc", "bcc", "reply-to", "sender", "subject", "message-id"].includes(h.toLowerCase()));
|
||||
const needsBody = rule.tests.some((t) => t.type === "body");
|
||||
const props = [...LIST_PROPS, "sender", "cc", "bcc", "replyTo", "messageId", ...customHeaders.map((h) => `header:${h}:asText`), ...(needsBody ? ["textBody", "bodyValues"] : [])];
|
||||
|
||||
// Gather all ids in the folder
|
||||
const ids: Id[] = [];
|
||||
let position = 0;
|
||||
let total = 0;
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const q = await client.call<QueryResponse>("Email/query", { accountId, filter: { inMailbox: mailboxId }, sort: [{ property: "receivedAt", isAscending: false }], position, limit: 500, calculateTotal: true });
|
||||
ids.push(...q.ids);
|
||||
total = q.total ?? ids.length;
|
||||
position += q.ids.length;
|
||||
if (!q.ids.length || position >= total) break;
|
||||
}
|
||||
|
||||
const matched: Email[] = [];
|
||||
let scanned = 0;
|
||||
for (const part of chunk(ids, 200)) {
|
||||
const res = await client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: props, ...(needsBody ? { fetchTextBodyValues: true, maxBodyValueBytes: 64 * 1024 } : {}) });
|
||||
for (const e of res.list) {
|
||||
const body = needsBody ? (e.textBody?.[0]?.partId ? e.bodyValues?.[e.textBody[0].partId]?.value : undefined) : undefined;
|
||||
if (evaluateRule(e, rule, body)) matched.push(e);
|
||||
}
|
||||
scanned += part.length;
|
||||
onProgress?.(scanned, ids.length);
|
||||
}
|
||||
|
||||
const skippedActions: string[] = [];
|
||||
if (matched.length) {
|
||||
const mids = matched.map((e) => e.id);
|
||||
const byPath = new Map<string, Id>();
|
||||
for (const m of Object.values(mail.mailboxes)) byPath.set(mail.mailboxPath(m.id).toLowerCase(), m.id);
|
||||
const inboxId = mail.roleId("inbox");
|
||||
for (const a of rule.actions) {
|
||||
switch (a.type) {
|
||||
case "fileinto": {
|
||||
const target = (a.mailboxId && mail.mailboxes[a.mailboxId]?.id) || byPath.get(a.mailbox.toLowerCase()) || (a.mailbox.toLowerCase() === "inbox" ? inboxId : null) || Object.values(mail.mailboxes).find((m) => m.name.toLowerCase() === a.mailbox.toLowerCase())?.id;
|
||||
if (!target) {
|
||||
skippedActions.push(`move to “${a.mailbox}” (folder not found)`);
|
||||
break;
|
||||
}
|
||||
if (target === mailboxId) break;
|
||||
if (a.copy) await mail.addToMailbox(mids, target, true);
|
||||
else await mail.move(mids, target, { silent: true });
|
||||
break;
|
||||
}
|
||||
case "markread":
|
||||
await mail.setKeyword(mids, "$seen", true);
|
||||
break;
|
||||
case "flag":
|
||||
await mail.setKeyword(mids, "$flagged", true);
|
||||
break;
|
||||
case "addflag":
|
||||
case "setflag":
|
||||
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), true);
|
||||
break;
|
||||
case "removeflag":
|
||||
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), false);
|
||||
break;
|
||||
case "discard":
|
||||
await mail.trash(mids);
|
||||
break;
|
||||
case "redirect":
|
||||
skippedActions.push(`forward to ${a.address} (cannot resend existing mail)`);
|
||||
break;
|
||||
case "reject":
|
||||
skippedActions.push("reject (cannot bounce existing mail)");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
void mail.refreshList();
|
||||
void mail.loadMailboxes();
|
||||
}
|
||||
return { scanned: ids.length, matched: matched.length, skippedActions };
|
||||
}
|
||||
|
||||
function normalizeFlag(flag: string): string {
|
||||
const f = flag.trim();
|
||||
if (/^\\\\?seen$/i.test(f)) return "$seen";
|
||||
if (/^\\\\?flagged$/i.test(f)) return "$flagged";
|
||||
if (/^\\\\?answered$/i.test(f)) return "$answered";
|
||||
if (/^\\\\?draft$/i.test(f)) return "$draft";
|
||||
return f.replace(/^\\+/, "");
|
||||
}
|
||||
|
||||
/** Seed a rule from a message (used by "Filter messages like this"). */
|
||||
export function ruleFromEmail(e: Email, currentMailboxId: Id | null): SieveRule {
|
||||
const mail = useMail.getState();
|
||||
const from = e.from?.[0]?.email ?? "";
|
||||
const listId = e["header:List-Id:asText"];
|
||||
const tests: SieveTest[] = listId ? [{ type: "header", header: "list-id", op: "contains", value: listId.replace(/^.*<|>.*$/g, "") }] : [{ type: "header", header: "from", op: "contains", value: from }];
|
||||
const target = Object.values(mail.mailboxes).find((m) => !m.role && m.id !== currentMailboxId) ?? Object.values(mail.mailboxes).find((m) => m.role === "archive");
|
||||
const name = listId ? `List: ${listId.replace(/^.*<|>.*$/g, "")}` : `From ${from}`;
|
||||
return {
|
||||
id: `r${Math.random().toString(36).slice(2, 9)}`,
|
||||
name,
|
||||
enabled: true,
|
||||
join: "allof",
|
||||
tests,
|
||||
actions: [{ type: "fileinto", mailbox: target ? mail.mailboxPath(target.id) : "INBOX", mailboxId: target?.id }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SieveRule } from "./sieve";
|
||||
|
||||
/** A folder as it was before it moved, so rules that name it can be found again. */
|
||||
export interface FolderRef {
|
||||
id: string;
|
||||
/** The path the folder had when the rules were written, e.g. "Work/Invoices". */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a rule files mail into this folder.
|
||||
*
|
||||
* Rules record the folder both ways: `mailboxId` since the rule editor started
|
||||
* setting it, and `mailbox` as the path Sieve actually needs. The id is the
|
||||
* reliable half — it survives a rename — but rules written before it existed,
|
||||
* or by hand in the Scripts tab, only have the path.
|
||||
*/
|
||||
function filesInto(rule: SieveRule, ref: FolderRef): boolean {
|
||||
return rule.actions.some(
|
||||
(a) => a.type === "fileinto" && (a.mailboxId === ref.id || a.mailbox.toLowerCase() === ref.path.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the paths of rules filing into folders that have moved or been
|
||||
* renamed. Returns the rules unchanged, and `changed: 0`, when none match, so
|
||||
* callers can skip saving.
|
||||
*/
|
||||
export function retargetRules(rules: SieveRule[], moves: Array<FolderRef & { newPath: string }>): { rules: SieveRule[]; changed: number } {
|
||||
const wanted = moves.filter((m) => m.newPath !== m.path);
|
||||
if (!wanted.length) return { rules, changed: 0 };
|
||||
let changed = 0;
|
||||
const next = rules.map((rule) => {
|
||||
const move = wanted.find((m) => filesInto(rule, m));
|
||||
if (!move) return rule;
|
||||
changed++;
|
||||
return {
|
||||
...rule,
|
||||
actions: rule.actions.map((a) =>
|
||||
a.type === "fileinto" && (a.mailboxId === move.id || a.mailbox.toLowerCase() === move.path.toLowerCase())
|
||||
? { ...a, mailbox: move.newPath, mailboxId: move.id }
|
||||
: a,
|
||||
),
|
||||
};
|
||||
});
|
||||
return changed ? { rules: next, changed } : { rules, changed: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the deleted folders out of the rules that filed into them.
|
||||
*
|
||||
* Only the `fileinto` action goes. A rule that also marks read, flags, or stops
|
||||
* processing keeps doing those things — deleting a folder says nothing about
|
||||
* whether the rest of the rule was still wanted. A rule left with no actions at
|
||||
* all has nothing to do, so that one goes.
|
||||
*/
|
||||
export function detachFolders(rules: SieveRule[], gone: FolderRef[]): { rules: SieveRule[]; edited: SieveRule[]; removed: SieveRule[] } {
|
||||
if (!gone.length) return { rules, edited: [], removed: [] };
|
||||
const targets = (a: SieveRule["actions"][number]) =>
|
||||
a.type === "fileinto" && gone.some((ref) => a.mailboxId === ref.id || a.mailbox.toLowerCase() === ref.path.toLowerCase());
|
||||
|
||||
const edited: SieveRule[] = [];
|
||||
const removed: SieveRule[] = [];
|
||||
const next: SieveRule[] = [];
|
||||
for (const rule of rules) {
|
||||
if (!rule.actions.some(targets)) {
|
||||
next.push(rule);
|
||||
continue;
|
||||
}
|
||||
const actions = rule.actions.filter((a) => !targets(a));
|
||||
if (!actions.length) {
|
||||
removed.push(rule);
|
||||
continue;
|
||||
}
|
||||
const trimmed = { ...rule, actions };
|
||||
edited.push(trimmed);
|
||||
next.push(trimmed);
|
||||
}
|
||||
if (!edited.length && !removed.length) return { rules, edited: [], removed: [] };
|
||||
return { rules: next, edited, removed };
|
||||
}
|
||||
Reference in New Issue
Block a user