Merge pull request #37 from LINUXexpert-org/sieve-follows-folders

Keep filter rules pointing at the folder they were aimed at
This commit is contained in:
LINUXexpert.org
2026-08-25 12:16:22 -07:00
committed by GitHub
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 };
}
+75
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { FolderRef } from "@/lib/sieveFolders";
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client"; import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
import type { import type {
Comparator, Comparator,
@@ -668,18 +669,26 @@ export const useMail = create<MailState>((set, get) => ({
async updateMailbox(id, patch) { async updateMailbox(id, patch) {
const accountId = get().accountId!; const accountId = get().accountId!;
// Paths as the filter rules currently spell them, before the move.
const before = patch.name !== undefined || patch.parentId !== undefined ? folderRefs(get(), id) : [];
const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: { [id]: patch } }); const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id]; const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes(); await get().loadMailboxes();
// Awaited, not fired and forgotten: the folder operation is not really done
// until the rules pointing at it agree, and a page that navigates away
// mid-save would leave the script half-written.
if (before.length) await followFolders(before);
}, },
async destroyMailbox(id, removeEmails = true) { async destroyMailbox(id, removeEmails = true) {
const accountId = get().accountId!; const accountId = get().accountId!;
const before = folderRefs(get(), id);
const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails }); const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails });
const err = res.notDestroyed?.[id]; const err = res.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes(); await get().loadMailboxes();
await followFolders(before);
}, },
async loadIdentities() { async loadIdentities() {
@@ -1010,3 +1019,69 @@ export function mailboxIcon(role: MailboxRole): string {
} }
export const ROLE_ORDER: Record<string, number> = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 }; export const ROLE_ORDER: Record<string, number> = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 };
/**
* A folder and everything under it, with the paths they have right now.
*
* Taken before a rename or a move, because renaming a parent silently rewrites
* the path of every folder beneath it, and the rules filing into those children
* name the old path just as much as the rules filing into the folder itself.
*/
function folderRefs(state: MailState, id: Id): FolderRef[] {
const all = Object.values(state.mailboxes);
const ids = new Set<Id>([id]);
// Walk down as far as the tree goes; depth is small and bounded by the server.
for (let pass = 0; pass < 20; pass++) {
const before = ids.size;
for (const m of all) if (m.parentId && ids.has(m.parentId)) ids.add(m.id);
if (ids.size === before) break;
}
return [...ids].map((i) => ({ id: i, path: state.mailboxPath(i) }));
}
/**
* Keeps the Sieve rules pointing at the folders they were aimed at.
*
* Called after the mailbox list has reloaded: anything in `before` that still
* exists has its rules retargeted to the new path, and anything that has gone
* takes its rules with it. Rules are server-side and invisible from here, so
* both outcomes are reported rather than done quietly.
*
* Deliberately never throws. The folder operation has already succeeded by this
* point, and failing to tidy the rules must not make it look otherwise.
*/
async function followFolders(before: FolderRef[]): Promise<void> {
try {
const { useSieve } = await import("./sieve");
const sieve = useSieve.getState();
if (!sieve.available) return;
if (!sieve.scripts.length) await sieve.load();
// Only the script the rule editor manages can be rewritten safely; a
// hand-written one is nobody's business but its author's.
const { rules } = useSieve.getState().rules();
if (!rules?.length) return;
const state = useMail.getState();
const moves: Array<FolderRef & { newPath: string }> = [];
const gone: FolderRef[] = [];
for (const ref of before) {
if (state.mailboxes[ref.id]) moves.push({ ...ref, newPath: state.mailboxPath(ref.id) });
else gone.push(ref);
}
const { retargetRules, dropRulesForFolders } = await import("@/lib/sieveFolders");
const retargeted = retargetRules(rules, moves);
const dropped = dropRulesForFolders(retargeted.rules, gone);
if (!retargeted.changed && !dropped.removed.length) return;
await useSieve.getState().saveRules(dropped.rules);
const { toast } = await import("@/ui/toast");
const said: string[] = [];
if (retargeted.changed) said.push(`${retargeted.changed} filter rule${retargeted.changed === 1 ? "" : "s"} updated`);
if (dropped.removed.length) said.push(`${dropped.removed.length} filter rule${dropped.removed.length === 1 ? "" : "s"} removed: ${dropped.removed.map((r) => `${r.name}`).join(", ")}`);
toast.show(said.join(" · "), { duration: 8000 });
} catch (err) {
const { toast } = await import("@/ui/toast");
toast.error(`Folder changed, but its filter rules could not be updated: ${(err as Error).message}`);
}
}