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 a9231a335c
commit 56e1f4f1be
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([]);
});
});
+62
View File
@@ -0,0 +1,62 @@
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 };
}
/**
* Drops rules that file into folders which no longer exist.
*
* The whole rule goes, not just its fileinto action: a rule whose destination
* has been deleted has no destination, and leaving it behind to match mail and
* do nothing is worse than removing it. Rules that merely mention the folder in
* some other action are left alone.
*/
export function dropRulesForFolders(rules: SieveRule[], gone: FolderRef[]): { rules: SieveRule[]; removed: SieveRule[] } {
if (!gone.length) return { rules, removed: [] };
const removed = rules.filter((r) => gone.some((ref) => filesInto(r, ref)));
if (!removed.length) return { rules, removed: [] };
return { rules: rules.filter((r) => !removed.includes(r)), removed };
}