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 { 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user