Files
ihasmail-inbuxa/web/src/lib/sieve.ts
T
jcoffey-dev 696b3713ed Let a filter rule be dragged into place
Twenty-five rules and two buttons that move one place at a time meant a
rule pushed to the wrong end cost ten clicks to bring back. It can now
be dragged.

A grip on the left of each card arms the drag, so the switch, the name
and the buttons still take a plain click, and the up and down buttons
stay for the keyboard. The card being dragged fades; the one under the
pointer draws a line on the edge the rule would land on, top half or
bottom.

The guard against dropping a rule onto itself reads a ref rather than
state: dragstart and the first dragover can arrive in the same frame,
and a stale read there drew a drop line on the card being dragged. Found
by driving the real thing in a browser, and covered by a test that fires
the two events back to back.
2026-08-25 08:51:28 -07:00

273 lines
9.5 KiB
TypeScript

/**
* Visual filter rules <-> Sieve script codec.
*
* Rules are persisted inside the Sieve script itself as JSON comments
* (`# rule:{...}`) so the UI can round-trip them losslessly; the generated
* Sieve below each comment is what the server actually runs.
*/
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
export type SieveTest =
| { type: "header"; header: string; op: HeaderOp; value: string }
| { type: "address"; header: string; part: "all" | "localpart" | "domain"; op: HeaderOp; value: string }
| { type: "size"; op: "over" | "under"; value: number }
| { type: "body"; op: "contains" | "notcontains"; value: string }
| { type: "true" };
export type SieveAction =
| { type: "fileinto"; mailbox: string; mailboxId?: string; copy?: boolean }
| { type: "redirect"; address: string; copy?: boolean }
| { type: "discard" }
| { type: "keep" }
| { type: "reject"; reason: string }
| { type: "addflag"; flag: string }
| { type: "setflag"; flag: string }
| { type: "removeflag"; flag: string }
| { type: "markread" }
| { type: "flag" }
| { type: "stop" };
export interface SieveRule {
id: string;
name: string;
enabled: boolean;
join: "allof" | "anyof";
tests: SieveTest[];
actions: SieveAction[];
}
export const HEADER_CHOICES = [
{ value: "from", label: "From" },
{ value: "to", label: "To" },
{ value: "cc", label: "Cc" },
{ value: "subject", label: "Subject" },
{ value: "list-id", label: "List-Id" },
{ value: "reply-to", label: "Reply-To" },
{ value: "x-spam-status", label: "X-Spam-Status" },
{ value: "__custom__", label: "Other header…" },
];
export const HEADER_OPS: Array<{ value: HeaderOp; label: string }> = [
{ value: "contains", label: "contains" },
{ value: "notcontains", label: "does not contain" },
{ value: "is", label: "is" },
{ value: "notis", label: "is not" },
{ value: "matches", label: "matches (wildcards * ?)" },
{ value: "notmatches", label: "does not match" },
{ value: "regex", label: "matches regex" },
{ value: "notregex", label: "does not match regex" },
{ value: "exists", label: "exists" },
{ value: "notexists", label: "does not exist" },
];
export function sieveString(s: string): string {
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, " ")}"`;
}
function opToSieve(op: HeaderOp): { neg: boolean; match: string } {
const neg = op.startsWith("not");
const base = neg ? op.slice(3) : op;
return { neg, match: base === "regex" ? ":regex" : base === "matches" ? ":matches" : base === "is" ? ":is" : base === "exists" ? "exists" : ":contains" };
}
export function testToSieve(t: SieveTest): string {
switch (t.type) {
case "true":
return "true";
case "header": {
const { neg, match } = opToSieve(t.op);
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `header ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
return neg ? `not ${inner}` : inner;
}
case "address": {
const { neg, match } = opToSieve(t.op);
const part = t.part === "all" ? ":all" : t.part === "localpart" ? ":localpart" : ":domain";
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `address ${part} ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
return neg ? `not ${inner}` : inner;
}
case "size":
return `size :${t.op} ${Math.max(0, Math.round(t.value))}`;
case "body": {
const inner = `body :text :contains ${sieveString(t.value)}`;
return t.op === "notcontains" ? `not ${inner}` : inner;
}
}
}
export function actionToSieve(a: SieveAction): string[] {
switch (a.type) {
case "fileinto":
return [`fileinto${a.copy ? " :copy" : ""} ${sieveString(a.mailbox)};`];
case "redirect":
return [`redirect${a.copy ? " :copy" : ""} ${sieveString(a.address)};`];
case "discard":
return ["discard;"];
case "keep":
return ["keep;"];
case "reject":
return [`reject ${sieveString(a.reason || "Message rejected")};`];
case "addflag":
return [`addflag ${sieveString(a.flag)};`];
case "setflag":
return [`setflag ${sieveString(a.flag)};`];
case "removeflag":
return [`removeflag ${sieveString(a.flag)};`];
case "markread":
return ['addflag "\\\\Seen";'];
case "flag":
return ['addflag "\\\\Flagged";'];
case "stop":
return ["stop;"];
}
}
export function requiredExtensions(rules: SieveRule[]): string[] {
const req = new Set<string>();
for (const r of rules) {
for (const t of r.tests) {
if (t.type === "body") req.add("body");
if ((t.type === "header" || t.type === "address") && (t.op === "regex" || t.op === "notregex")) req.add("regex");
if (t.type === "address") req.add("envelope");
}
for (const a of r.actions) {
if (a.type === "fileinto") {
req.add("fileinto");
if (a.copy) req.add("copy");
}
if (a.type === "redirect" && a.copy) req.add("copy");
if (a.type === "reject") req.add("reject");
if (["addflag", "setflag", "removeflag", "markread", "flag"].includes(a.type)) req.add("imap4flags");
}
}
req.delete("envelope");
return [...req].sort();
}
export const SCRIPT_HEADER = "# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments";
export function rulesToSieve(rules: SieveRule[]): string {
const ext = requiredExtensions(rules);
const lines: string[] = [SCRIPT_HEADER];
if (ext.length) lines.push(`require [${ext.map(sieveString).join(", ")}];`);
lines.push("");
for (const r of rules) {
lines.push(`# rule:${JSON.stringify(r)}`);
if (!r.enabled) {
lines.push(`# (disabled) ${r.name}`);
lines.push("");
continue;
}
const tests = r.tests.filter((t) => t.type !== "true");
let cond: string;
if (!tests.length) cond = "true";
else if (tests.length === 1) cond = testToSieve(tests[0]!);
else cond = `${r.join} (${tests.map(testToSieve).join(", ")})`;
const body = r.actions.flatMap(actionToSieve).map((l) => ` ${l}`);
if (!body.length) body.push(" keep;");
lines.push(`if ${cond}`);
lines.push("{");
lines.push(...body);
lines.push("}");
lines.push("");
}
return lines.join("\n");
}
/** Returns rules if the script was generated by ihasmail, else null (raw script). */
export function sieveToRules(script: string): SieveRule[] | null {
if (!script.includes("# rule:")) return script.trim() === "" || script.includes(SCRIPT_HEADER) ? [] : null;
const out: SieveRule[] = [];
for (const line of script.split(/\r?\n/)) {
if (!line.startsWith("# rule:")) continue;
try {
const r = JSON.parse(line.slice(7)) as SieveRule;
if (r && typeof r === "object" && Array.isArray(r.tests) && Array.isArray(r.actions)) out.push(r);
} catch {
/* skip */
}
}
return out;
}
export function newRule(partial: Partial<SieveRule> = {}): SieveRule {
return {
id: `r${Math.random().toString(36).slice(2, 9)}`,
name: "New filter",
enabled: true,
join: "allof",
tests: [{ type: "header", header: "from", op: "contains", value: "" }],
actions: [{ type: "fileinto", mailbox: "INBOX" }],
...partial,
};
}
/**
* Replaces a rule with the same id in place, or appends it when it is new.
* Order is evaluation order in Sieve, so an edited rule has to keep its seat.
*/
export function upsertRule(rules: SieveRule[], rule: SieveRule): SieveRule[] {
return rules.some((x) => x.id === rule.id) ? rules.map((x) => (x.id === rule.id ? rule : x)) : [...rules, rule];
}
/**
* Moves the rule `fromId` to sit either side of `toId`. `below` says which,
* decided by which half of the target card the pointer was over.
*/
export function reorderRules(rules: SieveRule[], fromId: string, toId: string, below: boolean): SieveRule[] {
if (fromId === toId) return rules;
const moved = rules.find((r) => r.id === fromId);
const rest = rules.filter((r) => r.id !== fromId);
const target = rest.findIndex((r) => r.id === toId);
if (!moved || target < 0) return rules;
const at = below ? target + 1 : target;
return [...rest.slice(0, at), moved, ...rest.slice(at)];
}
export function describeRule(r: SieveRule): string {
const tests = r.tests
.map((t) => {
switch (t.type) {
case "header":
return `${t.header} ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "address":
return `${t.header} address ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "size":
return `size ${t.op} ${Math.round(t.value / 1024)} KB`;
case "body":
return `body ${t.op === "contains" ? "contains" : "does not contain"} "${t.value}"`;
case "true":
return "always";
}
})
.join(r.join === "allof" ? " and " : " or ");
const actions = r.actions
.map((a) => {
switch (a.type) {
case "fileinto":
return `move to ${a.mailbox}`;
case "redirect":
return `forward to ${a.address}`;
case "discard":
return "delete";
case "keep":
return "keep";
case "reject":
return "reject";
case "markread":
return "mark read";
case "flag":
return "star";
case "addflag":
case "setflag":
return `add ${a.flag}`;
case "removeflag":
return `remove ${a.flag}`;
case "stop":
return "stop";
}
})
.join(", ");
return `${tests || "always"}${actions}`;
}