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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { FolderRef } from "@/lib/sieveFolders";
|
||||
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
||||
import type {
|
||||
Comparator,
|
||||
@@ -668,18 +669,26 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
|
||||
async updateMailbox(id, patch) {
|
||||
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 err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
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) {
|
||||
const accountId = get().accountId!;
|
||||
const before = folderRefs(get(), id);
|
||||
const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
await get().loadMailboxes();
|
||||
await followFolders(before);
|
||||
},
|
||||
|
||||
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 };
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user