diff --git a/web/src/lib/__tests__/sieveFolders.test.ts b/web/src/lib/__tests__/sieveFolders.test.ts new file mode 100644 index 0000000..d2aa7fa --- /dev/null +++ b/web/src/lib/__tests__/sieveFolders.test.ts @@ -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([]); + }); +}); diff --git a/web/src/lib/sieveFolders.ts b/web/src/lib/sieveFolders.ts new file mode 100644 index 0000000..5bf20a0 --- /dev/null +++ b/web/src/lib/sieveFolders.ts @@ -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): { 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 }; +} diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts index ee4fa81..1a1e523 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail.ts @@ -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((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("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("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 = { 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]); + // 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 { + 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 = []; + 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}`); + } +}