Keep filter rules pointing at the folder they were aimed at

A rule files mail into a folder by path, because that is what Sieve
needs. Rename the folder and the path becomes a lie: the rule keeps
matching and stops filing, and nothing anywhere says so. Delete the
folder and the rule is aimed at nothing at all.

Renaming or moving a folder now rewrites the rules that file into it,
and deleting one takes its rules with it. Both are reported in a toast,
because rules live on the server and are otherwise invisible from the
folder list.

The reconciliation runs off one hook. Before a mailbox is changed the
folder and everything beneath it are noted with the paths they have then
-- renaming a parent rewrites the path of every child, and rules naming
those children are just as stale. Afterwards, whatever still exists is
retargeted and whatever has gone takes its rules with it.

Rules record the folder twice, as a mailboxId and as the path. The id is
the reliable half and is preferred; the path is the fallback for rules
written before the id was recorded, or by hand in the Scripts tab, and a
rule matched that way has its id filled in on the way past. Only the
script the rule editor manages is touched; a hand-written one is left
alone.

Awaited rather than fired and forgotten, so a folder operation is not
reported complete while the rules still disagree with it.
This commit is contained in:
2026-08-25 12:12:30 -07:00
parent bb71a2d063
commit 9da1ef3ead
3 changed files with 222 additions and 0 deletions
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { retargetRules, dropRulesForFolders } 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("dropRulesForFolders", () => {
it("removes a rule whose destination is gone", () => {
const rules = [rule("news", fileinto("Newsletters", "mb1")), rule("keep", fileinto("Archive", "mb9"))];
const out = dropRulesForFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
expect(out.removed.map((r) => r.name)).toEqual(["news"]);
expect(out.rules.map((r) => r.name)).toEqual(["keep"]);
});
it("removes rules for a deleted folder's children too", () => {
const rules = [rule("a", fileinto("Work", "mb1")), rule("b", fileinto("Work/Invoices", "mb2"))];
const out = dropRulesForFolders(rules, [{ id: "mb1", path: "Work" }, { id: "mb2", path: "Work/Invoices" }]);
expect(out.rules).toEqual([]);
expect(out.removed).toHaveLength(2);
});
it("still finds the rule when only the path matches", () => {
const rules = [rule("news", fileinto("Newsletters"))];
expect(dropRulesForFolders(rules, [{ id: "mb1", path: "NEWSLETTERS" }]).removed).toHaveLength(1);
});
it("leaves the list untouched when nothing matches", () => {
const rules = [rule("keep", fileinto("Archive", "mb9"))];
const out = dropRulesForFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
expect(out.rules).toBe(rules);
expect(out.removed).toEqual([]);
});
});