Both describeRule functions assembled their output by concatenation, which no catalogue could fix. A translator handed " and " or " on " in isolation cannot move it: German puts the verb last, Japanese does not separate list items with a word at all, and the fragments arrive in an order the English sentence chose. Reported by a native speaker reviewing the German catalogue (#247), whose "the summaries" item is the Sieve one. Every branch is now one whole sentence with placeholders, so a translator rewrites the sentence including its word order. Joining is Intl.ListFormat, which gives "A, B und C" for an allof rule and the language's own disjunction for anyof, rather than a hardcoded " and " that would be wrong twice over. The recurrence tail no longer appends: ", 5 times" and ", until 2026-05-03" wrap the sentence they qualify, so a language that puts the limit first can. Ordinals become words. The old suffix table -- st, nd, rd, th, picked by arithmetic -- is English spelling rules in code, and no catalogue can reach a suffix chosen that way. German writes "1.", Japanese "第1". nthOfPeriod is 1-5 or -1 in practice, so five words and "last" cover it. WEEKDAYS is gone. Its long names could have been catalogue entries but its short ones never could: "T" is Tuesday and Thursday, "S" is Saturday and Sunday, and a catalogue cannot hold two translations under one key. That was bad data rather than missing translation, and Intl has every name in every locale in three widths. lib/datetime.ts gains weekdayName, weekdayNames and formatList; recurrence.ts keeps WEEKDAY_KEYS for the ordering, which is not a language question. Adds the first tests either function has had. Neither had any, and no test would have caught what was wrong with them, since the English output was correct -- so these pin the two properties that actually matter: fragments go through the catalogue, and the joining is Intl's. 32 strings and 9 plural forms are new and land with each language. Verified: typecheck clean, 1009 tests pass.
381 lines
15 KiB
TypeScript
381 lines
15 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.
|
|
*/
|
|
|
|
import { formatList } from "./datetime";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
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");
|
|
}
|
|
|
|
/**
|
|
* Why this script must not be rewritten from the rules parsed out of it, or
|
|
* null when rewriting it is safe.
|
|
*
|
|
* Saving replaces the whole script with a fresh serialization of the rules read
|
|
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
|
|
* the alarm by itself: it skips what it does not recognise, so a script cut off
|
|
* partway through parses cleanly into a shorter list and looks exactly like one
|
|
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
|
|
* truncated download, a plausible parse, and a save that wrote the short
|
|
* version back over the real one. The transport fault behind it is fixed in the
|
|
* blob proxy; this is the check that makes the save path refuse regardless of
|
|
* how the content came to be short.
|
|
*
|
|
* The tests are structural rather than an equality check against
|
|
* `rulesToSieve(sieveToRules(content))`. A script written by an older version
|
|
* whose serializer differed in some detail is intact, and refusing to let
|
|
* anyone edit their rules over a changed byte would be the worse bug.
|
|
*/
|
|
export function scriptDamage(content: string): string | null {
|
|
// Not one of ours: genuinely empty, or hand-written. Both mean something
|
|
// else and are answered elsewhere.
|
|
if (!content.includes("# rule:") && !content.includes(SCRIPT_HEADER)) {
|
|
// Unless it is one of ours cut off inside its own first line, which reads
|
|
// as a very short hand-written script -- and that reading is the one that
|
|
// offers to replace it.
|
|
const head = content.replace(/\n+$/, "");
|
|
if (head !== "" && SCRIPT_HEADER.startsWith(head)) return "breaks off inside its first line";
|
|
return null;
|
|
}
|
|
// Every generated script ends with a newline, so a body that stops mid-line
|
|
// stopped early. The rest of the walk covers the cuts that land on one.
|
|
if (!content.endsWith("\n")) return "stops in the middle of a line";
|
|
|
|
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
let i = 0;
|
|
let hadRequire = false;
|
|
if (lines[0] === SCRIPT_HEADER) {
|
|
i = 1;
|
|
hadRequire = lines[i]?.startsWith("require ") ?? false;
|
|
if (hadRequire) i++;
|
|
if (lines[i] !== "") return "breaks off in its opening lines";
|
|
i++;
|
|
} else {
|
|
// Header edited away but the rule comments kept. Still ours to walk.
|
|
i = lines.findIndex((l) => l.startsWith("# rule:"));
|
|
}
|
|
|
|
// Walk the shape rulesToSieve emits, one rule block at a time. Deliberately
|
|
// structural: the condition and action lines are read only for their
|
|
// presence, so a serializer that words them differently is still intact.
|
|
let seen = 0;
|
|
const cut = (r: SieveRule, what: string) => `has a rule in it (“${r.name}”) ${what}`;
|
|
while (i < lines.length) {
|
|
const line = lines[i]!;
|
|
if (!line.startsWith("# rule:")) return "has a stray line where a rule should start";
|
|
let rule: SieveRule | null = null;
|
|
try {
|
|
const parsed = JSON.parse(line.slice(7)) as SieveRule;
|
|
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tests) && Array.isArray(parsed.actions)) rule = parsed;
|
|
} catch {
|
|
/* reported below */
|
|
}
|
|
if (!rule) return "has a rule in it that breaks off unfinished";
|
|
seen++;
|
|
i++;
|
|
// `!r.enabled` is how rulesToSieve chooses the branch, so match it exactly
|
|
// rather than testing for `=== false`.
|
|
if (rule.enabled) {
|
|
if (!lines[i]?.startsWith("if ")) return cut(rule, "with nothing below it");
|
|
i++;
|
|
if (lines[i] !== "{") return cut(rule, "whose body never opens");
|
|
i++;
|
|
while (i < lines.length && lines[i] !== "}") {
|
|
if (lines[i] === "") return cut(rule, "whose body breaks off");
|
|
i++;
|
|
}
|
|
if (i >= lines.length) return cut(rule, "whose body never closes");
|
|
i++;
|
|
} else {
|
|
if (!lines[i]?.startsWith("# (disabled) ")) return cut(rule, "with nothing below it");
|
|
i++;
|
|
}
|
|
// Each block is followed by a blank line, the last one included: it is the
|
|
// empty final element left by the trailing newline.
|
|
if (lines[i] !== "") return cut(rule, "that runs into what follows it");
|
|
i++;
|
|
}
|
|
// A require line is written only for rules that need it, so one standing over
|
|
// no rules at all means the rules it was written for are gone.
|
|
if (hadRequire && seen === 0) return "breaks off before the first rule";
|
|
return null;
|
|
}
|
|
|
|
/** 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)];
|
|
}
|
|
|
|
/**
|
|
* A filter rule as a sentence, for the rule list.
|
|
*
|
|
* Rebuilt as whole sentences with placeholders. The old version concatenated
|
|
* fragments -- a header name, an operator, a quoted value, joined by " and "
|
|
* -- which no catalogue could fix: German puts the verb last, Japanese does
|
|
* not separate list items with a word at all, and a translator handed " and "
|
|
* on its own cannot move anything. Reported by a native speaker reviewing the
|
|
* German catalogue (#247).
|
|
*
|
|
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
|
|
* for an anyof rule, the disjunction the language actually uses.
|
|
*/
|
|
export function describeRule(r: SieveRule): string {
|
|
const headerLabel = (h: string): string => t(HEADER_CHOICES.find((c) => c.value === h)?.label ?? h);
|
|
const opLabel = (op: string): string => t(HEADER_OPS.find((o) => o.value === op)?.label ?? op);
|
|
|
|
const tests = r.tests.map((test) => {
|
|
switch (test.type) {
|
|
case "header":
|
|
return t('{header} {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
|
|
case "address":
|
|
return t('{header} address {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
|
|
case "size":
|
|
return test.op === "over"
|
|
? t("size is over {n} KB", { n: Math.round(test.value / 1024) })
|
|
: t("size is under {n} KB", { n: Math.round(test.value / 1024) });
|
|
case "body":
|
|
return test.op === "contains"
|
|
? t('body contains "{value}"', { value: test.value })
|
|
: t('body does not contain "{value}"', { value: test.value });
|
|
case "true":
|
|
return t("always");
|
|
}
|
|
});
|
|
|
|
const actions = r.actions.map((a) => {
|
|
switch (a.type) {
|
|
case "fileinto": return t("move to {folder}", { folder: a.mailbox });
|
|
case "redirect": return t("forward to {address}", { address: a.address });
|
|
case "discard": return t("delete it");
|
|
case "keep": return t("keep it");
|
|
case "reject": return t("reject it");
|
|
case "markread": return t("mark it read");
|
|
case "flag": return t("star it");
|
|
case "addflag":
|
|
case "setflag": return t("add {flag}", { flag: a.flag });
|
|
case "removeflag": return t("remove {flag}", { flag: a.flag });
|
|
case "stop": return t("stop");
|
|
}
|
|
});
|
|
|
|
return t("{tests} → {actions}", {
|
|
tests: tests.length ? formatList(tests, r.join === "allof" ? "conjunction" : "disjunction") : t("always"),
|
|
actions: formatList(actions, "conjunction"),
|
|
});
|
|
}
|