feat: Enhance external rule handling in Sieve parser and store #201
This commit is contained in:
@@ -23,8 +23,13 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
PalmtreeIcon,
|
PalmtreeIcon,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function isReadonlyRule(r: FilterRule): boolean {
|
||||||
|
return r.origin === "external" || r.origin === "opaque";
|
||||||
|
}
|
||||||
|
|
||||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
|
|
||||||
@@ -429,90 +434,137 @@ export function FilterSettings() {
|
|||||||
|
|
||||||
{!isOpaque && rules.length > 0 && (
|
{!isOpaque && rules.length > 0 && (
|
||||||
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
|
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
|
||||||
{rules.map((rule, index) => (
|
{rules.map((rule, index) => {
|
||||||
<div
|
const readonly = isReadonlyRule(rule);
|
||||||
key={rule.id}
|
|
||||||
role="listitem"
|
if (readonly) {
|
||||||
draggable
|
const label = rule.originLabel || t("origin_external");
|
||||||
onDragStart={(e) => handleDragStart(e, index)}
|
const tooltip = t("managed_by_tooltip", { source: label });
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
const hasStructuredSummary =
|
||||||
onDrop={(e) => handleDrop(e, index)}
|
rule.origin === "external" &&
|
||||||
onDragEnd={handleDragEnd}
|
rule.conditions.length > 0 &&
|
||||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
rule.actions.length > 0;
|
||||||
dragOverIndex === index
|
return (
|
||||||
? "border-primary bg-primary/5"
|
<div
|
||||||
: "border-border hover:bg-muted/50"
|
key={rule.id}
|
||||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
role="listitem"
|
||||||
>
|
className="flex items-start gap-3 p-3 rounded-md border border-border"
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{rule.name}
|
||||||
|
</p>
|
||||||
|
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[10px]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasStructuredSummary ? (
|
||||||
|
expandedFilterView ? (
|
||||||
|
<VisualRuleSummary rule={rule} />
|
||||||
|
) : (
|
||||||
|
<RuleSummary rule={rule} />
|
||||||
|
)
|
||||||
|
) : rule.rawBlock ? (
|
||||||
|
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
|
||||||
|
{rule.rawBlock.trim()}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
key={rule.id}
|
||||||
aria-label={t("drag_to_reorder")}
|
role="listitem"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||||
|
dragOverIndex === index
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted/50"
|
||||||
|
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4" />
|
<div
|
||||||
</div>
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||||
|
aria-label={t("drag_to_reorder")}
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-0.5">
|
<div className="pt-0.5">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={rule.enabled}
|
checked={rule.enabled}
|
||||||
onChange={() => handleToggle(rule.id)}
|
onChange={() => handleToggle(rule.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingRule(rule);
|
|
||||||
setShowRuleModal(true);
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setEditingRule(rule);
|
setEditingRule(rule);
|
||||||
setShowRuleModal(true);
|
setShowRuleModal(true);
|
||||||
}
|
}}
|
||||||
}}
|
role="button"
|
||||||
>
|
tabIndex={0}
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
onKeyDown={(e) => {
|
||||||
{rule.name}
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
</p>
|
e.preventDefault();
|
||||||
{expandedFilterView ? (
|
setEditingRule(rule);
|
||||||
<VisualRuleSummary rule={rule} />
|
setShowRuleModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{rule.name}
|
||||||
|
</p>
|
||||||
|
{expandedFilterView ? (
|
||||||
|
<VisualRuleSummary rule={rule} />
|
||||||
|
) : (
|
||||||
|
<RuleSummary rule={rule} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteConfirmId === rule.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
>
|
||||||
|
{t("confirm_delete")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RuleSummary rule={rule} />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteConfirmId(rule.id)}
|
||||||
|
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
|
aria-label={t("delete_rule")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{deleteConfirmId === rule.id ? (
|
})}
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(rule.id)}
|
|
||||||
>
|
|
||||||
{t("confirm_delete")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDeleteConfirmId(rule.id)}
|
|
||||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
|
||||||
aria-label={t("delete_rule")}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
@@ -196,10 +196,15 @@ describe('sieve generator', () => {
|
|||||||
expect(result.vacation?.isEnabled).toBe(true);
|
expect(result.vacation?.isEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should mark as opaque when real filter rules exist alongside vacation', () => {
|
it('parses filter rules alongside vacation as external when no metadata is present', () => {
|
||||||
const script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
|
const script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
|
||||||
const result = parseScript(script);
|
const result = parseScript(script);
|
||||||
expect(result.isOpaque).toBe(true);
|
// New behavior: preserve both the vacation statement (as opaque) and
|
||||||
|
// the if-block (as a structured external rule) instead of dropping them.
|
||||||
|
expect(result.isOpaque).toBe(false);
|
||||||
|
const ifRule = result.rules.find(r => r.origin === 'external');
|
||||||
|
expect(ifRule?.conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'boss@example.com' });
|
||||||
|
expect(ifRule?.actions[0]).toEqual({ type: 'move', value: 'Important' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle Stalwart :mime format vacation script', () => {
|
it('should handle Stalwart :mime format vacation script', () => {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface FilterAction {
|
|||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
|
||||||
|
|
||||||
export interface FilterRule {
|
export interface FilterRule {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -47,6 +49,9 @@ export interface FilterRule {
|
|||||||
conditions: FilterCondition[];
|
conditions: FilterCondition[];
|
||||||
actions: FilterAction[];
|
actions: FilterAction[];
|
||||||
stopProcessing: boolean;
|
stopProcessing: boolean;
|
||||||
|
origin?: FilterOrigin;
|
||||||
|
originLabel?: string;
|
||||||
|
rawBlock?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VacationSieveConfig {
|
export interface VacationSieveConfig {
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { parseScript } from '../parser';
|
||||||
|
import { generateScript } from '../generator';
|
||||||
|
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||||
|
|
||||||
|
function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||||||
|
return {
|
||||||
|
id: 'bw-1',
|
||||||
|
name: 'Bulwark Rule',
|
||||||
|
enabled: true,
|
||||||
|
matchType: 'all',
|
||||||
|
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||||
|
actions: [{ type: 'move', value: 'Archive' }],
|
||||||
|
stopProcessing: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('external rule preservation (issue #201)', () => {
|
||||||
|
describe('parser — external rule recognition', () => {
|
||||||
|
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
|
||||||
|
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
|
||||||
|
const result = parseScript(script);
|
||||||
|
|
||||||
|
expect(result.isOpaque).toBe(false);
|
||||||
|
expect(result.rules).toHaveLength(1);
|
||||||
|
const rule = result.rules[0];
|
||||||
|
expect(rule.origin).toBe('external');
|
||||||
|
expect(rule.originLabel).toBe('Roundcube');
|
||||||
|
expect(rule.name).toBe('Archive Newsletters');
|
||||||
|
expect(rule.conditions[0]).toMatchObject({
|
||||||
|
field: 'header',
|
||||||
|
comparator: 'contains',
|
||||||
|
value: 'news',
|
||||||
|
headerName: 'List-Id',
|
||||||
|
});
|
||||||
|
expect(rule.actions[0]).toEqual({ type: 'move', value: 'Newsletters' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels rules near a Nextcloud marker comment', () => {
|
||||||
|
const script = `require ["fileinto"];\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
||||||
|
const result = parseScript(script);
|
||||||
|
expect(result.rules[0].originLabel).toBe('Nextcloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "External" label when no known marker is present', () => {
|
||||||
|
const script = `require ["fileinto"];\n\nif header :contains "From" "boss@corp.com" {\n fileinto "Important";\n}\n`;
|
||||||
|
const result = parseScript(script);
|
||||||
|
expect(result.rules[0].originLabel).toBe('External');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses anyof/allof conditions in external rules', () => {
|
||||||
|
const script = `require ["fileinto"];\n\nif anyof(header :contains "From" "a@x.com", header :contains "From" "b@x.com") {\n fileinto "VIP";\n}\n`;
|
||||||
|
const result = parseScript(script);
|
||||||
|
expect(result.rules[0].matchType).toBe('any');
|
||||||
|
expect(result.rules[0].conditions).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses negated conditions (not header :is)', () => {
|
||||||
|
const script = `if not header :is "From" "spam@x.com" {\n keep;\n}\n`;
|
||||||
|
const result = parseScript(script);
|
||||||
|
expect(result.rules[0].conditions[0]).toMatchObject({
|
||||||
|
field: 'from',
|
||||||
|
comparator: 'not_is',
|
||||||
|
value: 'spam@x.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks unrecognized blocks as opaque but preserves their raw text', () => {
|
||||||
|
const script = `require ["relational"];\n\nif header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {\n keep;\n}\n`;
|
||||||
|
const result = parseScript(script);
|
||||||
|
expect(result.isOpaque).toBe(false);
|
||||||
|
const rule = result.rules[0];
|
||||||
|
expect(rule.origin).toBe('opaque');
|
||||||
|
expect(rule.rawBlock).toContain('if header :value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects all external require tokens', () => {
|
||||||
|
const script = `require ["fileinto", "imap4flags", "body"];\n\nif header :is "Subject" "hi" { fileinto "A"; }\n`;
|
||||||
|
const result = parseScript(script);
|
||||||
|
expect(result.externalRequires).toEqual(expect.arrayContaining(['fileinto', 'imap4flags', 'body']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parser — mixed Bulwark + external', () => {
|
||||||
|
it('returns Bulwark rules from metadata and external rules from the rest', () => {
|
||||||
|
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
|
||||||
|
const bulwarkScript = generateScript(bulwark);
|
||||||
|
const mixedScript = `${bulwarkScript}\n# External appended by Nextcloud\nif header :contains "List-Id" "devs" {\n fileinto "Dev";\n}\n`;
|
||||||
|
|
||||||
|
const result = parseScript(mixedScript);
|
||||||
|
expect(result.isOpaque).toBe(false);
|
||||||
|
expect(result.rules.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const bulwarkParsed = result.rules.filter(r => !r.origin || r.origin === 'bulwark');
|
||||||
|
const externalParsed = result.rules.filter(r => r.origin === 'external');
|
||||||
|
expect(bulwarkParsed).toHaveLength(1);
|
||||||
|
expect(bulwarkParsed[0].name).toBe('Bulwark A');
|
||||||
|
expect(externalParsed).toHaveLength(1);
|
||||||
|
expect(externalParsed[0].originLabel).toBe('Nextcloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return Bulwark-emitted if-blocks as external duplicates', () => {
|
||||||
|
const bulwark = [makeBulwarkRule({ name: 'My Bulwark Rule' })];
|
||||||
|
const script = generateScript(bulwark);
|
||||||
|
const result = parseScript(script);
|
||||||
|
|
||||||
|
// Only the metadata-sourced rule, no duplicate "external" entry.
|
||||||
|
expect(result.rules).toHaveLength(1);
|
||||||
|
expect(result.rules[0].origin).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generator — external splice', () => {
|
||||||
|
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
|
||||||
|
const externalRule: FilterRule = {
|
||||||
|
id: 'ext-0',
|
||||||
|
name: 'External',
|
||||||
|
enabled: true,
|
||||||
|
matchType: 'all',
|
||||||
|
conditions: [{ field: 'header', comparator: 'contains', value: 'x', headerName: 'List-Id' }],
|
||||||
|
actions: [{ type: 'move', value: 'Lists' }],
|
||||||
|
stopProcessing: false,
|
||||||
|
origin: 'external',
|
||||||
|
originLabel: 'Nextcloud',
|
||||||
|
rawBlock: '# Nextcloud Mail\nif header :contains "List-Id" "x" {\n fileinto "Lists";\n}\n',
|
||||||
|
};
|
||||||
|
const rules: FilterRule[] = [makeBulwarkRule(), externalRule];
|
||||||
|
const script = generateScript(rules);
|
||||||
|
|
||||||
|
expect(script).toContain('# Rule: Bulwark Rule');
|
||||||
|
expect(script).toContain('# Nextcloud Mail');
|
||||||
|
expect(script).toContain('# --- External rules (managed outside Bulwark) ---');
|
||||||
|
const bulwarkIdx = script.indexOf('# Rule: Bulwark Rule');
|
||||||
|
const externalIdx = script.indexOf('# Nextcloud Mail');
|
||||||
|
expect(bulwarkIdx).toBeLessThan(externalIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unions external requires into the top-level require line', () => {
|
||||||
|
const script = generateScript([makeBulwarkRule()], undefined, {
|
||||||
|
externalRequires: ['fileinto', 'imap4flags', 'body'],
|
||||||
|
});
|
||||||
|
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
||||||
|
expect(requireLine).toContain('"fileinto"');
|
||||||
|
expect(requireLine).toContain('"imap4flags"');
|
||||||
|
expect(requireLine).toContain('"body"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips origin/rawBlock/originLabel from Bulwark rules when writing metadata', () => {
|
||||||
|
const bulwarkWithJunk: FilterRule = {
|
||||||
|
...makeBulwarkRule(),
|
||||||
|
origin: 'bulwark',
|
||||||
|
originLabel: 'shouldnotbehere',
|
||||||
|
rawBlock: 'shouldnotbehere',
|
||||||
|
};
|
||||||
|
const script = generateScript([bulwarkWithJunk]);
|
||||||
|
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||||
|
const metadata = JSON.parse(match![1]);
|
||||||
|
expect(metadata.rules[0]).not.toHaveProperty('origin');
|
||||||
|
expect(metadata.rules[0]).not.toHaveProperty('originLabel');
|
||||||
|
expect(metadata.rules[0]).not.toHaveProperty('rawBlock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes external rules into metadata', () => {
|
||||||
|
const ext: FilterRule = {
|
||||||
|
id: 'ext-0',
|
||||||
|
name: 'Ext',
|
||||||
|
enabled: true,
|
||||||
|
matchType: 'all',
|
||||||
|
conditions: [{ field: 'from', comparator: 'is', value: 'x@y' }],
|
||||||
|
actions: [{ type: 'keep' }],
|
||||||
|
stopProcessing: false,
|
||||||
|
origin: 'external',
|
||||||
|
rawBlock: '# ext\nif header :is "From" "x@y" { keep; }',
|
||||||
|
};
|
||||||
|
const script = generateScript([makeBulwarkRule(), ext]);
|
||||||
|
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||||
|
const metadata = JSON.parse(match![1]);
|
||||||
|
expect(metadata.rules).toHaveLength(1);
|
||||||
|
expect(metadata.rules[0].name).toBe('Bulwark Rule');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fixture: mixed-origins.sieve', () => {
|
||||||
|
const fixture = readFileSync(
|
||||||
|
join(__dirname, 'fixtures', 'mixed-origins.sieve'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('identifies Bulwark, Roundcube, Nextcloud, External, and opaque rules', () => {
|
||||||
|
const result = parseScript(fixture);
|
||||||
|
|
||||||
|
expect(result.isOpaque).toBe(false);
|
||||||
|
expect(result.vacation).toBeUndefined();
|
||||||
|
|
||||||
|
const byOrigin = {
|
||||||
|
bulwark: result.rules.filter(r => !r.origin || r.origin === 'bulwark'),
|
||||||
|
external: result.rules.filter(r => r.origin === 'external'),
|
||||||
|
opaque: result.rules.filter(r => r.origin === 'opaque'),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(byOrigin.bulwark).toHaveLength(2);
|
||||||
|
expect(byOrigin.external.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(byOrigin.opaque).toHaveLength(1);
|
||||||
|
|
||||||
|
const labels = byOrigin.external.map(r => r.originLabel);
|
||||||
|
expect(labels).toContain('Roundcube');
|
||||||
|
expect(labels).toContain('Nextcloud');
|
||||||
|
expect(labels).toContain('External');
|
||||||
|
|
||||||
|
const opaqueRule = byOrigin.opaque[0];
|
||||||
|
expect(opaqueRule.rawBlock).toContain(':comparator "i;ascii-numeric"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves unknown-Sieve content through save round-trip', () => {
|
||||||
|
const parsed = parseScript(fixture);
|
||||||
|
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
||||||
|
externalRequires: parsed.externalRequires,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The unparseable construct must appear verbatim in the regenerated script.
|
||||||
|
expect(regenerated).toContain(':comparator "i;ascii-numeric"');
|
||||||
|
// Require tokens from the external content are preserved.
|
||||||
|
expect(regenerated).toContain('"relational"');
|
||||||
|
// Bulwark rules are still present.
|
||||||
|
expect(regenerated).toContain('# Rule: Archive newsletters');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trip', () => {
|
||||||
|
it('preserves external rules through parse → generate → parse', () => {
|
||||||
|
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
||||||
|
|
||||||
|
const firstParse = parseScript(initial);
|
||||||
|
expect(firstParse.rules).toHaveLength(2);
|
||||||
|
|
||||||
|
const regenerated = generateScript(firstParse.rules, firstParse.vacation, {
|
||||||
|
externalRequires: firstParse.externalRequires,
|
||||||
|
});
|
||||||
|
const secondParse = parseScript(regenerated);
|
||||||
|
|
||||||
|
expect(secondParse.rules).toHaveLength(2);
|
||||||
|
const names = secondParse.rules.map(r => r.name).sort();
|
||||||
|
expect(names).toContain('VIP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not destroy external rules when Bulwark regenerates after an edit', () => {
|
||||||
|
const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n# rule:[Untouchable]\nif header :is "X-Spam" "yes" {\n discard;\n}\n`;
|
||||||
|
|
||||||
|
const parsed = parseScript(initial);
|
||||||
|
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
|
||||||
|
expect(externalBefore).toHaveLength(1);
|
||||||
|
|
||||||
|
// Simulate a user edit — update the Bulwark rule name
|
||||||
|
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
|
||||||
|
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
|
||||||
|
const reparsed = parseScript(regenerated);
|
||||||
|
|
||||||
|
const externalAfter = reparsed.rules.filter(r => r.origin === 'external');
|
||||||
|
expect(externalAfter).toHaveLength(1);
|
||||||
|
expect(externalAfter[0].name).toBe('Untouchable');
|
||||||
|
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* @metadata:begin
|
||||||
|
{"version":1,"rules":[{"id":"bw-news","name":"Archive newsletters","enabled":true,"matchType":"any","conditions":[{"field":"header","comparator":"contains","value":"unsubscribe","headerName":"List-Unsubscribe"},{"field":"from","comparator":"contains","value":"newsletter@"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"bw-vip","name":"Flag VIP senders","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"is","value":"ceo@company.com"},{"field":"from","comparator":"is","value":"board@company.com"}],"actions":[{"type":"star"},{"type":"mark_read"}],"stopProcessing":false}]}
|
||||||
|
@metadata:end */
|
||||||
|
|
||||||
|
require ["body", "copy", "fileinto", "imap4flags", "relational"];
|
||||||
|
|
||||||
|
# Rule: Archive newsletters
|
||||||
|
if anyof(header :contains "List-Unsubscribe" "unsubscribe", header :contains "From" "newsletter@") {
|
||||||
|
fileinto "Newsletters";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rule: Flag VIP senders
|
||||||
|
if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.com") {
|
||||||
|
addflag "\\Flagged";
|
||||||
|
addflag "\\Seen";
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- External rules (managed outside Bulwark) ---
|
||||||
|
|
||||||
|
# rule:[Finance — auto-file invoices]
|
||||||
|
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
|
||||||
|
fileinto :copy "Finance/Invoices";
|
||||||
|
keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nextcloud Mail - begin
|
||||||
|
# Filter installed by Nextcloud Mail app
|
||||||
|
if header :contains "Subject" "[Support]" {
|
||||||
|
fileinto "Support";
|
||||||
|
}
|
||||||
|
# Nextcloud Mail - end
|
||||||
|
|
||||||
|
# A handwritten rule without a tool-specific marker.
|
||||||
|
# Bulwark should recognize this as generic "External" and preserve it.
|
||||||
|
if not header :is "X-Spam-Status" "No" {
|
||||||
|
fileinto "Junk";
|
||||||
|
}
|
||||||
|
|
||||||
|
# A rule using a Sieve construct Bulwark's visual editor does not understand.
|
||||||
|
# It must survive round-trips verbatim, shown to the user as read-only.
|
||||||
|
if header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {
|
||||||
|
fileinto "LowPriority";
|
||||||
|
stop;
|
||||||
|
}
|
||||||
@@ -25,10 +25,14 @@ describe('parseScript', () => {
|
|||||||
expect(result.rules).toEqual(rules);
|
expect(result.rules).toEqual(rules);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns isOpaque for missing metadata', () => {
|
it('parses external rules when no Bulwark metadata is present', () => {
|
||||||
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
|
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
|
||||||
expect(result.isOpaque).toBe(true);
|
expect(result.isOpaque).toBe(false);
|
||||||
expect(result.rules).toEqual([]);
|
expect(result.rules).toHaveLength(1);
|
||||||
|
expect(result.rules[0].origin).toBe('external');
|
||||||
|
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
|
||||||
|
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
|
||||||
|
expect(result.externalRequires).toContain('fileinto');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns isOpaque for corrupted JSON', () => {
|
it('returns isOpaque for corrupted JSON', () => {
|
||||||
@@ -90,9 +94,10 @@ describe('parseScript', () => {
|
|||||||
expect(result.isOpaque).toBe(true);
|
expect(result.isOpaque).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns isOpaque for empty string', () => {
|
it('treats an empty string as an empty, editable script (not opaque)', () => {
|
||||||
const result = parseScript('');
|
const result = parseScript('');
|
||||||
expect(result.isOpaque).toBe(true);
|
expect(result.isOpaque).toBe(false);
|
||||||
|
expect(result.rules).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('round-trip', () => {
|
describe('round-trip', () => {
|
||||||
|
|||||||
+58
-8
@@ -111,11 +111,47 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...extensions].sort();
|
return [...extensions];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateScript(rules: FilterRule[], vacation?: VacationSieveConfig): string {
|
function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
|
||||||
const metadata: FilterMetadata = { version: 1, rules };
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
enabled: r.enabled,
|
||||||
|
matchType: r.matchType,
|
||||||
|
conditions: r.conditions,
|
||||||
|
actions: r.actions,
|
||||||
|
stopProcessing: r.stopProcessing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateOptions {
|
||||||
|
/**
|
||||||
|
* Require extensions used by external (non-Bulwark) rules that we must
|
||||||
|
* preserve in the top-level `require` directive. Duplicates with Bulwark's
|
||||||
|
* own requires are deduplicated.
|
||||||
|
*/
|
||||||
|
externalRequires?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateScript(
|
||||||
|
rules: FilterRule[],
|
||||||
|
vacation?: VacationSieveConfig,
|
||||||
|
options: GenerateOptions = {},
|
||||||
|
): string {
|
||||||
|
// Partition rules by origin. Treat missing origin as 'bulwark' for back-compat.
|
||||||
|
const bulwarkRules: FilterRule[] = [];
|
||||||
|
const externalRules: FilterRule[] = [];
|
||||||
|
for (const r of rules) {
|
||||||
|
if (r.origin && r.origin !== 'bulwark') externalRules.push(r);
|
||||||
|
else bulwarkRules.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: FilterMetadata = {
|
||||||
|
version: 1,
|
||||||
|
rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[],
|
||||||
|
};
|
||||||
if (vacation?.isEnabled) {
|
if (vacation?.isEnabled) {
|
||||||
metadata.vacation = vacation;
|
metadata.vacation = vacation;
|
||||||
}
|
}
|
||||||
@@ -127,9 +163,12 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
|
|||||||
lines.push('@metadata:end */');
|
lines.push('@metadata:end */');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
const requires = computeRequires(rules, vacation);
|
const bulwarkRequires = computeRequires(bulwarkRules, vacation);
|
||||||
if (requires.length > 0) {
|
const externalRequires = options.externalRequires ?? [];
|
||||||
lines.push(`require [${requires.map(r => `"${r}"`).join(', ')}];`);
|
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
|
||||||
|
|
||||||
|
if (allRequires.length > 0) {
|
||||||
|
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vacation?.isEnabled) {
|
if (vacation?.isEnabled) {
|
||||||
@@ -143,9 +182,9 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
|
|||||||
lines.push(`vacation ${vacationParts.join(' ')};`);
|
lines.push(`vacation ${vacationParts.join(' ')};`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledRules = rules.filter(r => r.enabled);
|
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
|
||||||
|
|
||||||
for (const rule of enabledRules) {
|
for (const rule of enabledBulwarkRules) {
|
||||||
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
||||||
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
|
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
|
||||||
continue;
|
continue;
|
||||||
@@ -182,6 +221,17 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
|
|||||||
lines.push('}');
|
lines.push('}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append preserved external rules verbatim. Each rawBlock already carries its
|
||||||
|
// own leading comments and trailing whitespace from the source script.
|
||||||
|
if (externalRules.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('# --- External rules (managed outside Bulwark) ---');
|
||||||
|
for (const ext of externalRules) {
|
||||||
|
if (!ext.rawBlock) continue;
|
||||||
|
lines.push(ext.rawBlock.replace(/\s+$/, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
+527
-38
@@ -1,17 +1,33 @@
|
|||||||
import type { FilterRule, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types';
|
import type {
|
||||||
|
FilterAction,
|
||||||
|
FilterCondition,
|
||||||
|
FilterComparator,
|
||||||
|
FilterConditionField,
|
||||||
|
FilterMetadata,
|
||||||
|
FilterRule,
|
||||||
|
VacationSieveConfig,
|
||||||
|
} from '@/lib/jmap/sieve-types';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
export interface ParseResult {
|
export interface ParseResult {
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
isOpaque: boolean;
|
isOpaque: boolean;
|
||||||
vacation?: VacationSieveConfig;
|
vacation?: VacationSieveConfig;
|
||||||
|
externalRequires: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPAQUE: ParseResult = { rules: [], isOpaque: true };
|
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
|
||||||
|
|
||||||
const METADATA_BEGIN = '/* @metadata:begin';
|
const METADATA_BEGIN = '/* @metadata:begin';
|
||||||
const METADATA_END = '@metadata:end */';
|
const METADATA_END = '@metadata:end */';
|
||||||
|
|
||||||
|
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
||||||
|
from: 'from',
|
||||||
|
to: 'to',
|
||||||
|
cc: 'cc',
|
||||||
|
subject: 'subject',
|
||||||
|
};
|
||||||
|
|
||||||
function isValidCondition(c: unknown): boolean {
|
function isValidCondition(c: unknown): boolean {
|
||||||
if (!c || typeof c !== 'object') return false;
|
if (!c || typeof c !== 'object') return false;
|
||||||
const cond = c as Record<string, unknown>;
|
const cond = c as Record<string, unknown>;
|
||||||
@@ -42,83 +58,556 @@ function isValidRule(rule: unknown): rule is FilterRule {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect Stalwart-generated vacation-only scripts (no metadata).
|
* Detect Stalwart-generated vacation-only scripts (no metadata).
|
||||||
* These contain `vacation` command but no other filter logic we need to preserve.
|
|
||||||
*/
|
*/
|
||||||
function detectVacationOnlyScript(content: string): ParseResult | null {
|
function detectVacationOnlyScript(content: string): ParseResult | null {
|
||||||
// Must contain a vacation command
|
|
||||||
if (!/\bvacation\b/.test(content)) return null;
|
if (!/\bvacation\b/.test(content)) return null;
|
||||||
|
|
||||||
// Strip requires, comments, and whitespace to see if only vacation remains
|
|
||||||
const stripped = content
|
const stripped = content
|
||||||
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
|
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
|
||||||
.replace(/#[^\n]*/g, '')
|
.replace(/#[^\n]*/g, '')
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Strip quoted string *contents* before checking for structural keywords so that
|
|
||||||
// message body text like "if you need urgent help..." doesn't cause false rejection.
|
|
||||||
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
||||||
|
|
||||||
// Check there are no if/elsif/else filter blocks
|
|
||||||
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
|
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
|
||||||
|
|
||||||
// Must still have a vacation command after stripping boilerplate
|
|
||||||
if (!/\bvacation\b/.test(structural)) return null;
|
if (!/\bvacation\b/.test(structural)) return null;
|
||||||
|
|
||||||
// Extract subject if present (:subject "...")
|
|
||||||
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
|
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
|
||||||
const subject = subjectMatch ? subjectMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : '';
|
const subject = subjectMatch ? unescapeSieveString(subjectMatch[1]) : '';
|
||||||
|
|
||||||
// Extract the body text. Stalwart uses :mime format where the body is a full MIME
|
|
||||||
// message. Extract the plain text after the Content-Transfer-Encoding header.
|
|
||||||
// Handle both LF and CRLF line endings.
|
|
||||||
let textBody = '';
|
let textBody = '';
|
||||||
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
|
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
|
||||||
if (mimeBodyMatch) {
|
if (mimeBodyMatch) {
|
||||||
textBody = mimeBodyMatch[1].trim();
|
textBody = mimeBodyMatch[1].trim();
|
||||||
} else {
|
} else {
|
||||||
// Plain format: last quoted string argument in the vacation statement
|
|
||||||
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
|
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
|
||||||
const last = allQuoted[allQuoted.length - 1];
|
const last = allQuoted[allQuoted.length - 1];
|
||||||
if (last) {
|
if (last) textBody = unescapeSieveString(last[1]);
|
||||||
textBody = last[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: [],
|
rules: [],
|
||||||
isOpaque: false,
|
isOpaque: false,
|
||||||
vacation: { isEnabled: true, subject, textBody },
|
vacation: { isEnabled: true, subject, textBody },
|
||||||
|
externalRequires: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unescapeSieveString(s: string): string {
|
||||||
|
return s.replace(/\\(.)/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipStringLit(s: string, i: number): number {
|
||||||
|
i++;
|
||||||
|
while (i < s.length) {
|
||||||
|
if (s[i] === '\\') { i += 2; continue; }
|
||||||
|
if (s[i] === '"') return i + 1;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipHashComment(s: string, i: number): number {
|
||||||
|
while (i < s.length && s[i] !== '\n') i++;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipBlockComment(s: string, i: number): number {
|
||||||
|
const end = s.indexOf('*/', i + 2);
|
||||||
|
return end === -1 ? s.length : end + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipStatement(s: string, i: number): number {
|
||||||
|
while (i < s.length) {
|
||||||
|
const c = s[i];
|
||||||
|
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||||
|
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||||
|
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||||
|
if (c === ';') return i + 1;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipBalanced(s: string, i: number, open: string, close: string): number {
|
||||||
|
let depth = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
const c = s[i];
|
||||||
|
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||||
|
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||||
|
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||||
|
if (c === open) { depth++; i++; continue; }
|
||||||
|
if (c === close) {
|
||||||
|
depth--;
|
||||||
|
i++;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIfStatement(s: string, i: number): number {
|
||||||
|
// positioned after 'if' keyword; skip through condition expression and body braces
|
||||||
|
while (i < s.length && s[i] !== '{') {
|
||||||
|
const c = s[i];
|
||||||
|
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||||
|
if (c === '(') { i = skipBalanced(s, i, '(', ')'); continue; }
|
||||||
|
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||||
|
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i >= s.length) return i;
|
||||||
|
return skipBalanced(s, i, '{', '}');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopBlock {
|
||||||
|
kind: 'require' | 'if' | 'vacation' | 'other';
|
||||||
|
raw: string; // from start-of-leading-text to end of statement
|
||||||
|
statement: string; // the statement itself (no leading comments/whitespace)
|
||||||
|
startIdx: number;
|
||||||
|
endIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanTopLevel(content: string): TopBlock[] {
|
||||||
|
const blocks: TopBlock[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let segmentStart = 0;
|
||||||
|
|
||||||
|
const consume = (kind: TopBlock['kind'], stmtStart: number, stmtEnd: number) => {
|
||||||
|
blocks.push({
|
||||||
|
kind,
|
||||||
|
raw: content.slice(segmentStart, stmtEnd),
|
||||||
|
statement: content.slice(stmtStart, stmtEnd),
|
||||||
|
startIdx: segmentStart,
|
||||||
|
endIdx: stmtEnd,
|
||||||
|
});
|
||||||
|
segmentStart = stmtEnd;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < content.length) {
|
||||||
|
// Skip whitespace
|
||||||
|
while (i < content.length && /\s/.test(content[i])) i++;
|
||||||
|
if (i >= content.length) break;
|
||||||
|
|
||||||
|
const c = content[i];
|
||||||
|
|
||||||
|
// Comments (stay attached to next block as leading text)
|
||||||
|
if (c === '#') { i = skipHashComment(content, i); continue; }
|
||||||
|
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
|
||||||
|
|
||||||
|
// Identifier
|
||||||
|
const m = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(content.slice(i));
|
||||||
|
if (!m) { i++; continue; }
|
||||||
|
|
||||||
|
const ident = m[0];
|
||||||
|
const stmtStart = i;
|
||||||
|
i += ident.length;
|
||||||
|
|
||||||
|
if (ident === 'require') {
|
||||||
|
i = skipStatement(content, i);
|
||||||
|
consume('require', stmtStart, i);
|
||||||
|
} else if (ident === 'if') {
|
||||||
|
i = skipIfStatement(content, i);
|
||||||
|
consume('if', stmtStart, i);
|
||||||
|
} else if (ident === 'vacation') {
|
||||||
|
i = skipStatement(content, i);
|
||||||
|
consume('vacation', stmtStart, i);
|
||||||
|
} else {
|
||||||
|
i = skipStatement(content, i);
|
||||||
|
consume('other', stmtStart, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRequireTokens(stmt: string): string[] {
|
||||||
|
const mList = /require\s+\[([\s\S]*?)\]\s*;/.exec(stmt);
|
||||||
|
if (mList) return [...mList[1].matchAll(/"([^"]+)"/g)].map(x => x[1]);
|
||||||
|
const mSingle = /require\s+"([^"]+)"\s*;/.exec(stmt);
|
||||||
|
return mSingle ? [mSingle[1]] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the last contiguous block of comments immediately preceding a
|
||||||
|
* statement — comments separated from the statement by a blank line are not
|
||||||
|
* considered its leading commentary (they likely belong to the previous
|
||||||
|
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
|
||||||
|
*/
|
||||||
|
function lastCommentChunk(leading: string): string {
|
||||||
|
const parts = leading.split(/\r?\n\s*\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||||
|
return parts.length ? parts[parts.length - 1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOriginLabel(leading: string): string {
|
||||||
|
const chunk = lastCommentChunk(leading);
|
||||||
|
const lower = chunk.toLowerCase();
|
||||||
|
if (/rule:\s*\[/i.test(chunk) || /roundcube|managesieve/.test(lower)) return 'Roundcube';
|
||||||
|
if (/nextcloud/.test(lower)) return 'Nextcloud';
|
||||||
|
if (/horde|ingo/.test(lower)) return 'Horde';
|
||||||
|
if (/kolab/.test(lower)) return 'Kolab';
|
||||||
|
if (/dovecot/.test(lower)) return 'Dovecot';
|
||||||
|
if (/thunderbird/.test(lower)) return 'Thunderbird';
|
||||||
|
return 'External';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractName(leading: string, fallback: string): string {
|
||||||
|
// Roundcube: "# rule:[Name]"
|
||||||
|
const rc = leading.match(/#\s*rule:\s*\[([^\]]+)\]/i);
|
||||||
|
if (rc) return rc[1].trim();
|
||||||
|
|
||||||
|
// "# Rule: Name"
|
||||||
|
const rr = leading.match(/#\s*Rule:\s*(.+?)\s*$/mi);
|
||||||
|
if (rr) return rr[1].trim();
|
||||||
|
|
||||||
|
// Last non-empty trimmed comment line
|
||||||
|
const lines = leading.split('\n').map(l => l.replace(/^\s*#\s*/, '').trim()).filter(Boolean);
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
if (last && last.length <= 80 && !/^\/\*|\*\/$/.test(last)) return last;
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevelComma(s: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let start = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
const c = s[i];
|
||||||
|
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||||
|
if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
|
||||||
|
if (c === ')' || c === ']' || c === '}') { depth--; i++; continue; }
|
||||||
|
if (c === ',' && depth === 0) {
|
||||||
|
parts.push(s.slice(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
parts.push(s.slice(start));
|
||||||
|
return parts.map(p => p.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitStatements(body: string): string[] {
|
||||||
|
const stmts: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < body.length) {
|
||||||
|
const c = body[i];
|
||||||
|
if (c === '"') { i = skipStringLit(body, i); continue; }
|
||||||
|
if (c === '#') { i = skipHashComment(body, i); continue; }
|
||||||
|
if (c === '/' && body[i + 1] === '*') { i = skipBlockComment(body, i); continue; }
|
||||||
|
if (c === ';') {
|
||||||
|
stmts.push(body.slice(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const tail = body.slice(start).trim();
|
||||||
|
if (tail) stmts.push(tail);
|
||||||
|
return stmts.map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderName(name: string): { field: FilterConditionField; headerName?: string } {
|
||||||
|
const lc = name.toLowerCase();
|
||||||
|
if (FIELD_FROM_HEADER[lc]) return { field: FIELD_FROM_HEADER[lc] };
|
||||||
|
return { field: 'header', headerName: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAtom(raw: string): FilterCondition | null {
|
||||||
|
let s = raw.trim();
|
||||||
|
let negated = false;
|
||||||
|
|
||||||
|
if (/^not\b/.test(s)) {
|
||||||
|
negated = true;
|
||||||
|
s = s.replace(/^not\s*/, '').trim();
|
||||||
|
if (s.startsWith('(') && s.endsWith(')')) {
|
||||||
|
s = s.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||||
|
if (m) {
|
||||||
|
const [, tag, headerName, rawValue] = m;
|
||||||
|
const value = unescapeSieveString(rawValue);
|
||||||
|
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||||
|
|
||||||
|
let comparator: FilterComparator;
|
||||||
|
if (tag === 'contains') {
|
||||||
|
comparator = negated ? 'not_contains' : 'contains';
|
||||||
|
} else if (tag === 'is') {
|
||||||
|
comparator = negated ? 'not_is' : 'is';
|
||||||
|
} else {
|
||||||
|
// :matches — distinguish starts_with / ends_with / matches
|
||||||
|
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
||||||
|
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
||||||
|
comparator = 'starts_with';
|
||||||
|
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
|
||||||
|
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||||
|
return cond;
|
||||||
|
}
|
||||||
|
if (starPositions.length === 1 && starPositions[0] === 0) {
|
||||||
|
comparator = 'ends_with';
|
||||||
|
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
|
||||||
|
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||||
|
return cond;
|
||||||
|
}
|
||||||
|
comparator = 'matches';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cond: FilterCondition = { field, comparator, value };
|
||||||
|
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||||
|
return cond;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||||
|
if (m) {
|
||||||
|
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||||
|
if (m) {
|
||||||
|
return { field: 'size', comparator: m[1] === 'over' ? 'greater_than' : 'less_than', value: m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCondition(raw: string): { matchType: 'all' | 'any'; conditions: FilterCondition[] } | null {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
|
||||||
|
const allMatch = /^allof\s*\(([\s\S]*)\)$/.exec(s);
|
||||||
|
const anyMatch = /^anyof\s*\(([\s\S]*)\)$/.exec(s);
|
||||||
|
let matchType: 'all' | 'any' = 'all';
|
||||||
|
let inner: string;
|
||||||
|
if (allMatch) { matchType = 'all'; inner = allMatch[1]; }
|
||||||
|
else if (anyMatch) { matchType = 'any'; inner = anyMatch[1]; }
|
||||||
|
else inner = s;
|
||||||
|
|
||||||
|
const parts = splitTopLevelComma(inner);
|
||||||
|
const conditions: FilterCondition[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const atom = parseAtom(part);
|
||||||
|
if (!atom) return null;
|
||||||
|
conditions.push(atom);
|
||||||
|
}
|
||||||
|
return { matchType, conditions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAction(raw: string): FilterAction | null {
|
||||||
|
const s = raw.trim();
|
||||||
|
|
||||||
|
let m = /^fileinto\s+:copy\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||||
|
if (m) return { type: 'copy', value: unescapeSieveString(m[1]) };
|
||||||
|
|
||||||
|
m = /^fileinto\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||||
|
if (m) return { type: 'move', value: unescapeSieveString(m[1]) };
|
||||||
|
|
||||||
|
m = /^redirect\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||||
|
if (m) return { type: 'forward', value: unescapeSieveString(m[1]) };
|
||||||
|
|
||||||
|
m = /^addflag\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||||
|
if (m) {
|
||||||
|
const flag = unescapeSieveString(m[1]);
|
||||||
|
if (flag === '\\Seen') return { type: 'mark_read' };
|
||||||
|
if (flag === '\\Flagged') return { type: 'star' };
|
||||||
|
if (flag.startsWith('$label:')) return { type: 'add_label', value: flag.slice('$label:'.length) };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
m = /^reject\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||||
|
if (m) return { type: 'reject', value: unescapeSieveString(m[1]) };
|
||||||
|
|
||||||
|
if (/^discard$/.test(s)) return { type: 'discard' };
|
||||||
|
if (/^keep$/.test(s)) return { type: 'keep' };
|
||||||
|
if (/^stop$/.test(s)) return { type: 'stop' };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
|
||||||
|
const stmt = block.statement;
|
||||||
|
const afterIf = stmt.replace(/^if\s+/, '');
|
||||||
|
const braceIdx = afterIf.indexOf('{');
|
||||||
|
const lastBraceIdx = afterIf.lastIndexOf('}');
|
||||||
|
if (braceIdx === -1 || lastBraceIdx === -1 || lastBraceIdx < braceIdx) return null;
|
||||||
|
|
||||||
|
const condStr = afterIf.slice(0, braceIdx).trim();
|
||||||
|
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
|
||||||
|
|
||||||
|
const cond = parseCondition(condStr);
|
||||||
|
if (!cond || cond.conditions.length === 0) return null;
|
||||||
|
|
||||||
|
const actionStmts = splitStatements(bodyStr);
|
||||||
|
const actions: FilterAction[] = [];
|
||||||
|
for (const st of actionStmts) {
|
||||||
|
const a = parseAction(st);
|
||||||
|
if (!a) return null;
|
||||||
|
actions.push(a);
|
||||||
|
}
|
||||||
|
if (actions.length === 0) return null;
|
||||||
|
|
||||||
|
let stopProcessing = false;
|
||||||
|
if (actions.length > 0 && actions[actions.length - 1].type === 'stop') {
|
||||||
|
const hasNonStop = actions.some(a => a.type !== 'stop');
|
||||||
|
if (hasNonStop) {
|
||||||
|
stopProcessing = true;
|
||||||
|
actions.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
|
||||||
|
const originLabel = detectOriginLabel(leading);
|
||||||
|
const name = extractName(leading, `Rule ${index + 1}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${idPrefix}-${index}`,
|
||||||
|
name,
|
||||||
|
enabled: true,
|
||||||
|
matchType: cond.matchType,
|
||||||
|
conditions: cond.conditions,
|
||||||
|
actions,
|
||||||
|
stopProcessing,
|
||||||
|
origin: 'external',
|
||||||
|
originLabel,
|
||||||
|
rawBlock: block.raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
|
||||||
|
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
|
||||||
|
const originLabel = detectOriginLabel(leading);
|
||||||
|
const name = extractName(leading, `External rule ${index + 1}`);
|
||||||
|
return {
|
||||||
|
id: `${idPrefix}-${index}`,
|
||||||
|
name,
|
||||||
|
enabled: true,
|
||||||
|
matchType: 'all',
|
||||||
|
conditions: [],
|
||||||
|
actions: [],
|
||||||
|
stopProcessing: false,
|
||||||
|
origin: 'opaque',
|
||||||
|
originLabel,
|
||||||
|
rawBlock: block.raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExternalRules(
|
||||||
|
content: string,
|
||||||
|
idPrefix: string,
|
||||||
|
): { rules: FilterRule[]; externalRequires: string[]; hasContent: boolean } {
|
||||||
|
const blocks = scanTopLevel(content);
|
||||||
|
const rules: FilterRule[] = [];
|
||||||
|
const externalRequires: string[] = [];
|
||||||
|
let index = 0;
|
||||||
|
let sawAnyStatement = false;
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.kind === 'require') {
|
||||||
|
sawAnyStatement = true;
|
||||||
|
for (const tok of extractRequireTokens(block.statement)) {
|
||||||
|
if (!externalRequires.includes(tok)) externalRequires.push(tok);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.kind === 'if') {
|
||||||
|
sawAnyStatement = true;
|
||||||
|
const rule = parseIfBlockToRule(block, idPrefix, index);
|
||||||
|
rules.push(rule ?? makeOpaqueRule(block, idPrefix, index));
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vacation/other: treat as opaque preserved block
|
||||||
|
sawAnyStatement = true;
|
||||||
|
rules.push(makeOpaqueRule(block, idPrefix, index));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rules, externalRequires, hasContent: sawAnyStatement };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseScript(content: string): ParseResult {
|
export function parseScript(content: string): ParseResult {
|
||||||
const beginIdx = content.indexOf(METADATA_BEGIN);
|
const beginIdx = content.indexOf(METADATA_BEGIN);
|
||||||
if (beginIdx === -1) {
|
|
||||||
// No metadata — check if it's a Stalwart vacation-only script
|
if (beginIdx !== -1) {
|
||||||
return detectVacationOnlyScript(content) || OPAQUE;
|
const endIdx = content.indexOf(METADATA_END, beginIdx);
|
||||||
|
if (endIdx === -1) return OPAQUE;
|
||||||
|
|
||||||
|
const jsonStart = beginIdx + METADATA_BEGIN.length;
|
||||||
|
const jsonStr = content.slice(jsonStart, endIdx).trim();
|
||||||
|
|
||||||
|
let metadata: FilterMetadata;
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(jsonStr);
|
||||||
|
} catch (e) {
|
||||||
|
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
|
||||||
|
return OPAQUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata || metadata.version !== 1) return OPAQUE;
|
||||||
|
if (!Array.isArray(metadata.rules)) return OPAQUE;
|
||||||
|
|
||||||
|
for (const rule of metadata.rules) {
|
||||||
|
if (!isValidRule(rule)) return OPAQUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the portion AFTER the metadata block for external rules.
|
||||||
|
const afterMetadata = content.slice(endIdx + METADATA_END.length);
|
||||||
|
const external = parseExternalRules(afterMetadata, 'ext');
|
||||||
|
|
||||||
|
// Parsed bulwark rules intentionally omit an explicit `origin` field so
|
||||||
|
// round-trip equality with metadata-only callers holds. Absence of origin
|
||||||
|
// is treated as 'bulwark' everywhere downstream.
|
||||||
|
const bulwarkRules: FilterRule[] = metadata.rules;
|
||||||
|
|
||||||
|
// Exclude requires and the vacation line that we emit ourselves from externalRequires.
|
||||||
|
const externalRequires = external.externalRequires;
|
||||||
|
|
||||||
|
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
|
||||||
|
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
|
||||||
|
const filteredExternal = external.rules.filter(r => {
|
||||||
|
const raw = r.rawBlock || '';
|
||||||
|
if (/#\s*Rule:\s*/.test(raw) && r.origin === 'external') {
|
||||||
|
// If the name matches a bulwark rule name exactly, treat as bulwark-emitted
|
||||||
|
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
|
||||||
|
const name = match ? match[1].trim() : '';
|
||||||
|
if (bulwarkRules.some(b => b.name === name)) return false;
|
||||||
|
}
|
||||||
|
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: [...bulwarkRules, ...filteredExternal],
|
||||||
|
isOpaque: false,
|
||||||
|
vacation: metadata.vacation,
|
||||||
|
externalRequires,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const endIdx = content.indexOf(METADATA_END, beginIdx);
|
// No metadata — check vacation-only first
|
||||||
if (endIdx === -1) return OPAQUE;
|
const vacationOnly = detectVacationOnlyScript(content);
|
||||||
|
if (vacationOnly) return vacationOnly;
|
||||||
|
|
||||||
const jsonStart = beginIdx + METADATA_BEGIN.length;
|
// Try to parse the whole script as external rules.
|
||||||
const jsonStr = content.slice(jsonStart, endIdx).trim();
|
const external = parseExternalRules(content, 'ext');
|
||||||
|
|
||||||
let metadata: FilterMetadata;
|
if (!external.hasContent) {
|
||||||
try {
|
// Entirely empty or whitespace/comments only — treat as empty, editable.
|
||||||
metadata = JSON.parse(jsonStr);
|
return { rules: [], isOpaque: false, externalRequires: [] };
|
||||||
} catch (e) {
|
|
||||||
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
|
|
||||||
return OPAQUE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata || metadata.version !== 1) return OPAQUE;
|
// If at least one block parsed into a structured rule, expose them as external.
|
||||||
if (!Array.isArray(metadata.rules)) return OPAQUE;
|
const anyParsed = external.rules.some(r => r.origin === 'external');
|
||||||
|
if (anyParsed || external.rules.length > 0) {
|
||||||
for (const rule of metadata.rules) {
|
return { rules: external.rules, isOpaque: false, externalRequires: external.externalRequires };
|
||||||
if (!isValidRule(rule)) return OPAQUE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rules: metadata.rules, isOpaque: false, vacation: metadata.vacation };
|
return OPAQUE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1416,7 +1416,9 @@
|
|||||||
"rule_summary": {
|
"rule_summary": {
|
||||||
"conditions_count": "{count, plural, one {# condition} other {# conditions}}",
|
"conditions_count": "{count, plural, one {# condition} other {# conditions}}",
|
||||||
"actions_count": "{count, plural, one {# action} other {# actions}}"
|
"actions_count": "{count, plural, one {# action} other {# actions}}"
|
||||||
}
|
},
|
||||||
|
"origin_external": "External",
|
||||||
|
"managed_by_tooltip": "Managed by {source}. Edit it in that app, or use the raw Sieve editor."
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"title": "Email Templates",
|
"title": "Email Templates",
|
||||||
|
|||||||
@@ -151,15 +151,26 @@ describe('filter-store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchFilters', () => {
|
describe('fetchFilters', () => {
|
||||||
it('should set isOpaque for scripts without metadata', async () => {
|
it('parses external rules from scripts without metadata', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
getSieveCapabilities: () => null,
|
getSieveCapabilities: () => null,
|
||||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||||
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
|
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
|
||||||
};
|
};
|
||||||
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
||||||
|
expect(useFilterStore.getState().isOpaque).toBe(false);
|
||||||
|
expect(useFilterStore.getState().rules).toHaveLength(1);
|
||||||
|
expect(useFilterStore.getState().rules[0].origin).toBe('external');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets isOpaque for truly unparseable content', async () => {
|
||||||
|
const mockClient = {
|
||||||
|
getSieveCapabilities: () => null,
|
||||||
|
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||||
|
getSieveScriptContent: async () => '/* @metadata:begin\n{corrupt\n@metadata:end */',
|
||||||
|
};
|
||||||
|
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
|
||||||
expect(useFilterStore.getState().isOpaque).toBe(true);
|
expect(useFilterStore.getState().isOpaque).toBe(true);
|
||||||
expect(useFilterStore.getState().rules).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse rules from metadata-bearing script', async () => {
|
it('should parse rules from metadata-bearing script', async () => {
|
||||||
|
|||||||
+54
-15
@@ -16,6 +16,7 @@ interface FilterStore {
|
|||||||
isOpaque: boolean;
|
isOpaque: boolean;
|
||||||
rawScript: string;
|
rawScript: string;
|
||||||
vacationSettings: VacationSieveConfig | null;
|
vacationSettings: VacationSieveConfig | null;
|
||||||
|
externalRequires: string[];
|
||||||
|
|
||||||
setSupported: (supported: boolean) => void;
|
setSupported: (supported: boolean) => void;
|
||||||
fetchFilters: (client: IJMAPClient) => Promise<void>;
|
fetchFilters: (client: IJMAPClient) => Promise<void>;
|
||||||
@@ -43,6 +44,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
isOpaque: false,
|
isOpaque: false,
|
||||||
rawScript: '',
|
rawScript: '',
|
||||||
vacationSettings: null,
|
vacationSettings: null,
|
||||||
|
externalRequires: [],
|
||||||
|
|
||||||
setSupported: (supported) => set({ isSupported: supported }),
|
setSupported: (supported) => set({ isSupported: supported }),
|
||||||
|
|
||||||
@@ -74,10 +76,22 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
|
|
||||||
if (result.isOpaque) {
|
if (result.isOpaque) {
|
||||||
debug.log('filters', 'Sieve script is opaque (hand-edited)');
|
debug.log('filters', 'Sieve script is opaque (hand-edited)');
|
||||||
set({ isLoading: false, isOpaque: true, rules: [], vacationSettings: result.vacation || null });
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
isOpaque: true,
|
||||||
|
rules: [],
|
||||||
|
vacationSettings: result.vacation || null,
|
||||||
|
externalRequires: result.externalRequires,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
debug.log('filters', 'Parsed', result.rules.length, 'filter rules');
|
debug.log('filters', 'Parsed', result.rules.length, 'filter rules');
|
||||||
set({ isLoading: false, isOpaque: false, rules: result.rules, vacationSettings: result.vacation || null });
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
isOpaque: false,
|
||||||
|
rules: result.rules,
|
||||||
|
vacationSettings: result.vacation || null,
|
||||||
|
externalRequires: result.externalRequires,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to fetch filters:', error);
|
debug.error('Failed to fetch filters:', error);
|
||||||
@@ -91,13 +105,13 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
saveFilters: async (client) => {
|
saveFilters: async (client) => {
|
||||||
set({ isSaving: true, error: null });
|
set({ isSaving: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings } = get();
|
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get();
|
||||||
|
|
||||||
let content: string;
|
let content: string;
|
||||||
if (isOpaque) {
|
if (isOpaque) {
|
||||||
content = rawScript;
|
content = rawScript;
|
||||||
} else {
|
} else {
|
||||||
content = generateScript(rules, vacationSettings || undefined);
|
content = generateScript(rules, vacationSettings || undefined, { externalRequires });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeScriptId) {
|
if (activeScriptId) {
|
||||||
@@ -124,40 +138,60 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addRule: (rule) => {
|
addRule: (rule) => {
|
||||||
set((state) => ({ rules: [...state.rules, rule] }));
|
// Insert new bulwark rules before external/opaque rules so Bulwark's
|
||||||
|
// managed section stays contiguous.
|
||||||
|
set((state) => {
|
||||||
|
const bulwark = state.rules.filter(r => !r.origin || r.origin === 'bulwark');
|
||||||
|
const external = state.rules.filter(r => r.origin === 'external' || r.origin === 'opaque');
|
||||||
|
return { rules: [...bulwark, rule, ...external] };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateRule: (ruleId, updates) => {
|
updateRule: (ruleId, updates) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
rules: state.rules.map(r => r.id === ruleId ? { ...r, ...updates } : r),
|
rules: state.rules.map(r => {
|
||||||
|
if (r.id !== ruleId) return r;
|
||||||
|
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
|
||||||
|
return { ...r, ...updates };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteRule: (ruleId) => {
|
deleteRule: (ruleId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
rules: state.rules.filter(r => r.id !== ruleId),
|
rules: state.rules.filter(r => {
|
||||||
|
if (r.id !== ruleId) return true;
|
||||||
|
return r.origin === 'external' || r.origin === 'opaque';
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderRules: (ruleIds) => {
|
reorderRules: (ruleIds) => {
|
||||||
|
// Only reorder bulwark rules; external rules always stay at the end in
|
||||||
|
// their original order.
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const ruleMap = new Map(state.rules.map(r => [r.id, r]));
|
const bulwarkMap = new Map(
|
||||||
const reordered = ruleIds.map(id => ruleMap.get(id)).filter(Boolean) as FilterRule[];
|
state.rules.filter(r => !r.origin || r.origin === 'bulwark').map(r => [r.id, r]),
|
||||||
return { rules: reordered };
|
);
|
||||||
|
const external = state.rules.filter(r => r.origin === 'external' || r.origin === 'opaque');
|
||||||
|
const reordered = ruleIds.map(id => bulwarkMap.get(id)).filter(Boolean) as FilterRule[];
|
||||||
|
return { rules: [...reordered, ...external] };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleRule: (ruleId) => {
|
toggleRule: (ruleId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
rules: state.rules.map(r =>
|
rules: state.rules.map(r => {
|
||||||
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
|
if (r.id !== ruleId) return r;
|
||||||
),
|
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
|
||||||
|
return { ...r, enabled: !r.enabled };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
setRawScript: (content) => set({ rawScript: content }),
|
setRawScript: (content) => set({ rawScript: content }),
|
||||||
|
|
||||||
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [] }),
|
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
|
||||||
|
|
||||||
syncVacationToScript: async (client, vacation) => {
|
syncVacationToScript: async (client, vacation) => {
|
||||||
try {
|
try {
|
||||||
@@ -173,6 +207,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
const activeScript = scripts.find(s => s.isActive) || scripts[0];
|
const activeScript = scripts.find(s => s.isActive) || scripts[0];
|
||||||
|
|
||||||
let rules = previousRules;
|
let rules = previousRules;
|
||||||
|
let externalRequires = get().externalRequires;
|
||||||
|
|
||||||
// If there's an active script, try to parse our metadata from it.
|
// If there's an active script, try to parse our metadata from it.
|
||||||
// If the server overwrote it (no metadata), fall back to stored rules.
|
// If the server overwrote it (no metadata), fall back to stored rules.
|
||||||
@@ -181,11 +216,12 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
const parsed = parseScript(content);
|
const parsed = parseScript(content);
|
||||||
if (!parsed.isOpaque) {
|
if (!parsed.isOpaque) {
|
||||||
rules = parsed.rules;
|
rules = parsed.rules;
|
||||||
|
externalRequires = parsed.externalRequires;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a combined script with our metadata, rules, and vacation
|
// Generate a combined script with our metadata, rules, and vacation
|
||||||
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined);
|
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined, { externalRequires });
|
||||||
|
|
||||||
if (activeScript) {
|
if (activeScript) {
|
||||||
// Preserve the script's current activation state — don't pass activate: true
|
// Preserve the script's current activation state — don't pass activate: true
|
||||||
@@ -198,6 +234,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
rules,
|
rules,
|
||||||
vacationSettings: vacation,
|
vacationSettings: vacation,
|
||||||
isOpaque: false,
|
isOpaque: false,
|
||||||
|
externalRequires,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Don't activate; there may be a server-managed 'vacation' script active.
|
// Don't activate; there may be a server-managed 'vacation' script active.
|
||||||
@@ -209,6 +246,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
rules,
|
rules,
|
||||||
vacationSettings: vacation,
|
vacationSettings: vacation,
|
||||||
isOpaque: false,
|
isOpaque: false,
|
||||||
|
externalRequires,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,5 +267,6 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
isOpaque: false,
|
isOpaque: false,
|
||||||
rawScript: '',
|
rawScript: '',
|
||||||
vacationSettings: null,
|
vacationSettings: null,
|
||||||
|
externalRequires: [],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user