Files
ihasmail/web/src/lib/sieveFolders.ts
T
jcoffey-dev 9e2987f43c Take the filing action, not the whole rule
Deleting a folder removed every rule that filed into it, along with
whatever else those rules did. A rule that filed into Work, marked read
and stopped processing lost the marking and the stopping too, and
deleting a folder says nothing about whether those were still wanted.

Only the fileinto action goes now. A rule left with nothing to do is
still removed, because it has nothing to do; a rule filing into two
folders keeps the one that still exists. The toast says which happened.

Verified against the running app with two rules aimed at the same
folder, one filing only and one filing and marking read: the first was
removed, the second kept its markread, and the script stored on the
server agrees.
2026-08-25 12:23:29 -07:00

82 lines
3.0 KiB
TypeScript

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 };
}
/**
* Takes the deleted folders out of the rules that filed into them.
*
* Only the `fileinto` action goes. A rule that also marks read, flags, or stops
* processing keeps doing those things — deleting a folder says nothing about
* whether the rest of the rule was still wanted. A rule left with no actions at
* all has nothing to do, so that one goes.
*/
export function detachFolders(rules: SieveRule[], gone: FolderRef[]): { rules: SieveRule[]; edited: SieveRule[]; removed: SieveRule[] } {
if (!gone.length) return { rules, edited: [], removed: [] };
const targets = (a: SieveRule["actions"][number]) =>
a.type === "fileinto" && gone.some((ref) => a.mailboxId === ref.id || a.mailbox.toLowerCase() === ref.path.toLowerCase());
const edited: SieveRule[] = [];
const removed: SieveRule[] = [];
const next: SieveRule[] = [];
for (const rule of rules) {
if (!rule.actions.some(targets)) {
next.push(rule);
continue;
}
const actions = rule.actions.filter((a) => !targets(a));
if (!actions.length) {
removed.push(rule);
continue;
}
const trimmed = { ...rule, actions };
edited.push(trimmed);
next.push(trimmed);
}
if (!edited.length && !removed.length) return { rules, edited: [], removed: [] };
return { rules: next, edited, removed };
}