feat: add email filters with JMAP Sieve Scripts (RFC 9661)

Server-side email filtering with visual rule builder and raw Sieve editor.
Conditions (From/To/Subject/Size/Body), actions (Move/Forward/Star/Discard),
auto-save with rollback, drag-and-drop reorder, opaque script reset,
accessibility focus traps, toast validation, and 8-language i18n support.
This commit is contained in:
Matthieu MALVACHE
2026-02-16 23:09:18 +01:00
committed by Matthieu MALVACHE
parent ff65693e26
commit 73612313c0
24 changed files with 3511 additions and 203 deletions
+242
View File
@@ -1,4 +1,5 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -1598,6 +1599,227 @@ export class JMAPClient {
return this.hasCapability("urn:ietf:params:jmap:calendars");
}
supportsSieve(): boolean {
return this.hasCapability("urn:ietf:params:jmap:sieve");
}
getSieveAccountId(): string {
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
return sieveAccount || this.accountId;
}
private sieveUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"];
}
getSieveCapabilities(): SieveCapabilities | null {
const sieveAccountId = this.getSieveAccountId();
const accountInfo = this.accounts[sieveAccountId];
if (!accountInfo?.accountCapabilities) return null;
const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"];
return (caps as SieveCapabilities) || null;
}
async getSieveScripts(): Promise<SieveScript[]> {
const response = await this.request([
["SieveScript/get", {
accountId: this.getSieveAccountId(),
}, "0"]
], this.sieveUsing());
if (response.methodResponses?.[0]?.[0] === "SieveScript/get") {
return (response.methodResponses[0][1].list || []) as SieveScript[];
}
throw new Error('Failed to fetch Sieve scripts');
}
async getSieveScriptContent(blobId: string): Promise<string> {
const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve');
const response = await fetch(url, {
headers: { 'Authorization': this.authHeader },
});
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
return response.text();
}
private async uploadSieveBlob(content: string): Promise<string> {
if (!this.session?.uploadUrl) {
throw new Error('Upload URL not available');
}
const uploadUrl = this.session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(this.getSieveAccountId())
);
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/sieve',
},
body: content,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload sieve script: ${response.status} - ${errorText.substring(0, 200)}`);
}
const result = await response.json();
if (result.blobId) return result.blobId;
const blobInfo = result[this.getSieveAccountId()];
if (blobInfo?.blobId) return blobInfo.blobId;
throw new Error('Invalid upload response: blobId not found');
}
async createSieveScript(name: string, content: string): Promise<SieveScript> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
const response = await this.request([
["SieveScript/set", {
accountId,
create: {
"new-script": { name, blobId }
}
}, "0"]
], this.sieveUsing());
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
const result = response.methodResponses[0][1];
if (result.notCreated?.["new-script"]) {
const error = result.notCreated["new-script"];
throw new Error(error.description || "Failed to create sieve script");
}
const createdId = result.created?.["new-script"]?.id;
if (createdId) {
const scripts = await this.getSieveScripts();
const script = scripts.find(s => s.id === createdId);
if (script) return script;
}
}
throw new Error("Failed to create sieve script");
}
async updateSieveScript(scriptId: string, content: string): Promise<void> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
const response = await this.request([
["SieveScript/set", {
accountId,
update: {
[scriptId]: { blobId }
}
}, "0"]
], this.sieveUsing());
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[scriptId]) {
const error = result.notUpdated[scriptId];
throw new Error(error.description || "Failed to update sieve script");
}
return;
}
throw new Error("Failed to update sieve script");
}
async deleteSieveScript(scriptId: string): Promise<void> {
const accountId = this.getSieveAccountId();
const response = await this.request([
["SieveScript/set", {
accountId,
destroy: [scriptId]
}, "0"]
], this.sieveUsing());
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
const result = response.methodResponses[0][1];
if (result.notDestroyed?.[scriptId]) {
const error = result.notDestroyed[scriptId];
throw new Error(error.description || "Failed to delete sieve script");
}
return;
}
throw new Error("Failed to delete sieve script");
}
async activateSieveScript(scriptId: string): Promise<void> {
const accountId = this.getSieveAccountId();
const response = await this.request([
["SieveScript/set", {
accountId,
update: {
[scriptId]: { isActive: true }
}
}, "0"]
], this.sieveUsing());
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[scriptId]) {
const error = result.notUpdated[scriptId];
throw new Error(error.description || "Failed to activate sieve script");
}
return;
}
throw new Error("Failed to activate sieve script");
}
async deactivateSieveScript(scriptId: string): Promise<void> {
const accountId = this.getSieveAccountId();
const response = await this.request([
["SieveScript/set", {
accountId,
update: {
[scriptId]: { isActive: false }
}
}, "0"]
], this.sieveUsing());
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[scriptId]) {
const error = result.notUpdated[scriptId];
throw new Error(error.description || "Failed to deactivate sieve script");
}
return;
}
throw new Error("Failed to deactivate sieve script");
}
async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
const response = await this.request([
["SieveScript/validate", {
accountId,
blobId,
}, "0"]
], this.sieveUsing());
if (response.methodResponses?.[0]?.[0] === "SieveScript/validate") {
const result = response.methodResponses[0][1];
if (result.error) {
return { isValid: false, errors: [result.error.description || "Validation failed"] };
}
return { isValid: true };
}
if (response.methodResponses?.[0]?.[0]?.endsWith('/error')) {
const error = response.methodResponses[0][1];
return { isValid: false, errors: [error.description || "Validation failed"] };
}
return { isValid: false, errors: ['Unexpected validation response'] };
}
getContactsAccountId(): string {
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
return contactsAccount || this.accountId;
@@ -2185,6 +2407,14 @@ export class JMAPClient {
);
}
if (this.supportsSieve()) {
using.push('urn:ietf:params:jmap:sieve');
const sieveAccountId = this.getSieveAccountId();
methodCalls.push(
['SieveScript/get', { accountId: sieveAccountId, ids: [], properties: ['id'] }, 'e'],
);
}
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
@@ -2209,6 +2439,9 @@ export class JMAPClient {
if (method === 'CalendarEvent/get' && result.state) {
this.pollingStates['CalendarEvent'] = result.state;
}
if (method === 'SieveScript/get' && result.state) {
this.pollingStates['SieveScript'] = result.state;
}
}
}
} catch {
@@ -2233,6 +2466,14 @@ export class JMAPClient {
);
}
if (this.supportsSieve()) {
using.push('urn:ietf:params:jmap:sieve');
const sieveAccountId = this.getSieveAccountId();
methodCalls.push(
['SieveScript/get', { accountId: sieveAccountId, ids: [], properties: ['id'] }, 'e'],
);
}
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
@@ -2253,6 +2494,7 @@ export class JMAPClient {
'Email/get': 'Email',
'Calendar/get': 'Calendar',
'CalendarEvent/get': 'CalendarEvent',
'SieveScript/get': 'SieveScript',
};
const stateKey = typeMap[method];
if (stateKey && result.state) {
+55
View File
@@ -0,0 +1,55 @@
export interface SieveScript {
id: string;
name: string;
blobId: string;
isActive: boolean;
}
export interface SieveCapabilities {
implementation: string;
maxSizeScript: number;
sieveExtensions: string[];
notificationMethods: string[];
externalLists: string[];
}
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
export type FilterComparator =
| 'contains' | 'not_contains'
| 'is' | 'not_is'
| 'starts_with' | 'ends_with'
| 'matches'
| 'greater_than' | 'less_than';
export type FilterActionType =
| 'move' | 'copy' | 'forward'
| 'mark_read' | 'star' | 'add_label'
| 'discard' | 'reject' | 'keep' | 'stop';
export interface FilterCondition {
field: FilterConditionField;
comparator: FilterComparator;
value: string;
headerName?: string;
}
export interface FilterAction {
type: FilterActionType;
value?: string;
}
export interface FilterRule {
id: string;
name: string;
enabled: boolean;
matchType: 'all' | 'any';
conditions: FilterCondition[];
actions: FilterAction[];
stopProcessing: boolean;
}
export interface FilterMetadata {
version: 1;
rules: FilterRule[];
}
+1
View File
@@ -500,6 +500,7 @@ export interface StateChange {
AddressBook?: string;
Calendar?: string;
CalendarEvent?: string;
SieveScript?: string;
};
};
}
+402
View File
@@ -0,0 +1,402 @@
import { describe, it, expect } from 'vitest';
import { generateScript } from '../generator';
import { parseScript } from '../parser';
import type { FilterRule } from '@/lib/jmap/sieve-types';
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'rule-1',
name: 'Test Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('generateScript', () => {
it('outputs metadata and no require for empty rules', () => {
const script = generateScript([]);
expect(script).toContain('/* @metadata:begin');
expect(script).toContain('@metadata:end */');
expect(script).not.toContain('require');
});
it('embeds compact metadata JSON', () => {
const rules = [makeRule()];
const script = generateScript(rules);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
expect(match).not.toBeNull();
const metadata = JSON.parse(match![1]);
expect(metadata.version).toBe(1);
expect(metadata.rules).toHaveLength(1);
expect(metadata.rules[0].id).toBe('rule-1');
});
it('generates single rule with from/contains', () => {
const script = generateScript([makeRule()]);
expect(script).toContain('# Rule: Test Rule');
expect(script).toContain('if header :contains "From" "test@example.com"');
expect(script).toContain('fileinto "Archive";');
});
describe('condition fields', () => {
it('maps to field to "To" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'to', comparator: 'contains', value: 'me@x.com' }],
})]);
expect(script).toContain('header :contains "To" "me@x.com"');
});
it('maps cc field to "Cc" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'cc', comparator: 'is', value: 'cc@x.com' }],
})]);
expect(script).toContain('header :is "Cc" "cc@x.com"');
});
it('maps subject field to "Subject" header', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'hello' }],
})]);
expect(script).toContain('header :contains "Subject" "hello"');
});
it('maps header field with custom headerName', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-Custom' }],
})]);
expect(script).toContain('header :contains "X-Custom" "test"');
});
it('handles size greater_than', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
})]);
expect(script).toContain('size :over 1000000');
});
it('handles size less_than', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'size', comparator: 'less_than', value: '500' }],
})]);
expect(script).toContain('size :under 500');
});
it('handles body contains', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'contains', value: 'keyword' }],
})]);
expect(script).toContain('body :contains "keyword"');
expect(script).toContain('"body"');
});
it('handles body is', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'is', value: 'exact' }],
})]);
expect(script).toContain('body :is "exact"');
});
});
describe('comparators', () => {
it('generates not_contains with not wrapper', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'not_contains', value: 'spam' }],
})]);
expect(script).toContain('not header :contains "From" "spam"');
});
it('generates not_is with not wrapper', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'not_is', value: 'bad@x.com' }],
})]);
expect(script).toContain('not header :is "From" "bad@x.com"');
});
it('generates starts_with as :matches with trailing *', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Re:' }],
})]);
expect(script).toContain('header :matches "Subject" "Re:*"');
});
it('generates ends_with as :matches with leading *', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'ends_with', value: 'urgent' }],
})]);
expect(script).toContain('header :matches "Subject" "*urgent"');
});
it('generates matches as :matches', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'matches', value: '*@company.com' }],
})]);
expect(script).toContain('header :matches "From" "*@company.com"');
});
});
describe('match types', () => {
it('wraps multiple conditions with allof for matchType all', () => {
const script = generateScript([makeRule({
matchType: 'all',
conditions: [
{ field: 'from', comparator: 'contains', value: 'a' },
{ field: 'subject', comparator: 'contains', value: 'b' },
],
})]);
expect(script).toContain('allof(header :contains "From" "a", header :contains "Subject" "b")');
});
it('wraps multiple conditions with anyof for matchType any', () => {
const script = generateScript([makeRule({
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'contains', value: 'x' },
{ field: 'to', comparator: 'contains', value: 'y' },
],
})]);
expect(script).toContain('anyof(header :contains "From" "x", header :contains "To" "y")');
});
it('uses no wrapper for single condition', () => {
const script = generateScript([makeRule()]);
expect(script).not.toContain('allof');
expect(script).not.toContain('anyof');
});
});
describe('actions', () => {
it('generates move as fileinto', () => {
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'Spam' }] })]);
expect(script).toContain('fileinto "Spam";');
});
it('generates copy as fileinto :copy', () => {
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'Backup' }] })]);
expect(script).toContain('fileinto :copy "Backup";');
});
it('generates forward as redirect', () => {
const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'fwd@x.com' }] })]);
expect(script).toContain('redirect "fwd@x.com";');
});
it('generates mark_read as addflag \\Seen', () => {
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
expect(script).toContain('addflag "\\\\Seen";');
});
it('generates star as addflag \\Flagged', () => {
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
expect(script).toContain('addflag "\\\\Flagged";');
});
it('generates add_label as addflag $Label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
expect(script).toContain('addflag "$Important";');
});
it('generates discard', () => {
const script = generateScript([makeRule({ actions: [{ type: 'discard' }] })]);
expect(script).toContain('discard;');
});
it('generates reject with message', () => {
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'Go away' }] })]);
expect(script).toContain('reject "Go away";');
});
it('generates keep', () => {
const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]);
expect(script).toContain('keep;');
});
it('generates stop', () => {
const script = generateScript([makeRule({ actions: [{ type: 'stop' }] })]);
expect(script).toContain('stop;');
});
});
describe('stopProcessing', () => {
it('appends stop when stopProcessing is true', () => {
const script = generateScript([makeRule({ stopProcessing: true })]);
const ifBlock = script.slice(script.indexOf('if '));
expect(ifBlock).toContain('stop;');
});
it('does not duplicate stop if last action is stop', () => {
const script = generateScript([makeRule({
actions: [{ type: 'move', value: 'X' }, { type: 'stop' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toHaveLength(1);
});
it('does not append stop after discard', () => {
const script = generateScript([makeRule({
actions: [{ type: 'discard' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
});
it('does not append stop after reject', () => {
const script = generateScript([makeRule({
actions: [{ type: 'reject', value: 'No' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
});
});
describe('disabled rules', () => {
it('excludes disabled rules from Sieve code', () => {
const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]);
expect(script).not.toContain('# Rule: Hidden');
expect(script).not.toContain('if header');
});
it('preserves disabled rules in metadata', () => {
const rules = [makeRule({ enabled: false })];
const script = generateScript(rules);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules[0].enabled).toBe(false);
});
it('handles mixed enabled and disabled rules', () => {
const rules = [
makeRule({ id: '1', name: 'Active', enabled: true }),
makeRule({ id: '2', name: 'Inactive', enabled: false }),
makeRule({ id: '3', name: 'Also Active', enabled: true }),
];
const script = generateScript(rules);
expect(script).toContain('# Rule: Active');
expect(script).not.toContain('# Rule: Inactive');
expect(script).toContain('# Rule: Also Active');
});
});
describe('require extensions', () => {
it('includes fileinto for move', () => {
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'X' }] })]);
expect(script).toContain('"fileinto"');
});
it('includes fileinto and copy for copy action', () => {
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'X' }] })]);
expect(script).toContain('"copy"');
expect(script).toContain('"fileinto"');
});
it('includes imap4flags for mark_read', () => {
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes imap4flags for star', () => {
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes imap4flags for add_label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'X' }] })]);
expect(script).toContain('"imap4flags"');
});
it('includes reject for reject action', () => {
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'No' }] })]);
expect(script).toContain('"reject"');
});
it('includes body extension for body conditions', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'body', comparator: 'contains', value: 'test' }],
})]);
expect(script).toContain('"body"');
});
it('deduplicates extensions', () => {
const script = generateScript([
makeRule({ id: '1', actions: [{ type: 'star' }] }),
makeRule({ id: '2', actions: [{ type: 'mark_read' }] }),
]);
const matches = script.match(/"imap4flags"/g);
expect(matches).toHaveLength(1);
});
it('only considers enabled rules for requires', () => {
const script = generateScript([
makeRule({ id: '1', enabled: false, actions: [{ type: 'reject', value: 'X' }] }),
makeRule({ id: '2', enabled: true, actions: [{ type: 'move', value: 'Y' }] }),
]);
const requireLine = script.split('\n').find(l => l.startsWith('require'));
expect(requireLine).not.toContain('reject');
expect(requireLine).toContain('fileinto');
});
});
describe('escaping', () => {
it('escapes double quotes in values', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello"' }],
})]);
expect(script).toContain('say \\"hello\\"');
});
it('escapes backslashes in values', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'path\\to\\file' }],
})]);
expect(script).toContain('path\\\\to\\\\file');
});
it('escapes folder names in actions', () => {
const script = generateScript([makeRule({
actions: [{ type: 'move', value: 'My "Folder"' }],
})]);
expect(script).toContain('fileinto "My \\"Folder\\"";');
});
});
describe('round-trip', () => {
it('preserves rules through generate → parse cycle', () => {
const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Rule A', enabled: true }),
makeRule({ id: '2', name: 'Rule B', enabled: false }),
makeRule({
id: '3',
name: 'Complex',
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'contains', value: 'boss' },
{ field: 'subject', comparator: 'starts_with', value: 'URGENT' },
],
actions: [{ type: 'star' }, { type: 'mark_read' }],
stopProcessing: true,
}),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
});
it('generates multiple rules in order', () => {
const rules = [
makeRule({ id: '1', name: 'First', actions: [{ type: 'move', value: 'A' }] }),
makeRule({ id: '2', name: 'Second', actions: [{ type: 'move', value: 'B' }] }),
];
const script = generateScript(rules);
const firstIdx = script.indexOf('# Rule: First');
const secondIdx = script.indexOf('# Rule: Second');
expect(firstIdx).toBeLessThan(secondIdx);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { parseScript } from '../parser';
import { generateScript } from '../generator';
import type { FilterRule } from '@/lib/jmap/sieve-types';
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'rule-1',
name: 'Test Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('parseScript', () => {
it('extracts rules from valid metadata', () => {
const rules = [makeRule()];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('returns isOpaque for missing metadata', () => {
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for corrupted JSON', () => {
const script = '/* @metadata:begin\n{not valid json\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for version mismatch', () => {
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for empty metadata block', () => {
const script = '/* @metadata:begin\n\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for missing rules array', () => {
const script = '/* @metadata:begin\n{"version":1}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('returns isOpaque for invalid rule objects', () => {
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x"}]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
it('handles metadata with extra whitespace', () => {
const rules = [makeRule()];
const json = JSON.stringify({ version: 1, rules });
const script = `/* @metadata:begin\n ${json} \n@metadata:end */\n\nrequire ["fileinto"];`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('handles script with only metadata block', () => {
const rules = [makeRule({ enabled: false })];
const json = JSON.stringify({ version: 1, rules });
const script = `/* @metadata:begin\n${json}\n@metadata:end */`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('returns isOpaque for missing end marker', () => {
const script = '/* @metadata:begin\n{"version":1,"rules":[]}';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
});
it('returns isOpaque for empty string', () => {
const result = parseScript('');
expect(result.isOpaque).toBe(true);
});
describe('round-trip', () => {
it('preserves complex rules through generate → parse', () => {
const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }),
makeRule({
id: '2',
name: 'VIP',
matchType: 'any',
conditions: [
{ field: 'from', comparator: 'is', value: 'boss@company.com' },
{ field: 'from', comparator: 'is', value: 'ceo@company.com' },
],
actions: [{ type: 'star' }, { type: 'mark_read' }],
}),
makeRule({ id: '3', name: 'Disabled', enabled: false }),
];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
});
it('preserves rules with special characters', () => {
const rules = [makeRule({
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello" \\ world' }],
actions: [{ type: 'move', value: 'My "Folder"' }],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
it('preserves all action types', () => {
const rules = [makeRule({
actions: [
{ type: 'move', value: 'Folder' },
{ type: 'copy', value: 'Backup' },
{ type: 'forward', value: 'fwd@x.com' },
{ type: 'mark_read' },
{ type: 'star' },
{ type: 'add_label', value: 'Tag' },
{ type: 'keep' },
],
})];
const script = generateScript(rules);
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
});
});
+169
View File
@@ -0,0 +1,169 @@
import type { FilterRule, FilterCondition, FilterAction, FilterMetadata } from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
const HEADER_MAP: Record<string, string> = {
from: 'From',
to: 'To',
cc: 'Cc',
subject: 'Subject',
};
function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition;
if (field === 'size') {
const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${value}`;
}
if (field === 'body') {
const matchType = comparator === 'is' ? ':is' : ':contains';
return `body ${matchType} "${escapeString(value)}"`;
}
const headerName = field === 'header'
? (condition.headerName || 'X-Unknown')
: HEADER_MAP[field];
const escaped = escapeString(value);
switch (comparator) {
case 'contains':
return `header :contains "${headerName}" "${escaped}"`;
case 'not_contains':
return `not header :contains "${headerName}" "${escaped}"`;
case 'is':
return `header :is "${headerName}" "${escaped}"`;
case 'not_is':
return `not header :is "${headerName}" "${escaped}"`;
case 'starts_with':
return `header :matches "${headerName}" "${escaped}*"`;
case 'ends_with':
return `header :matches "${headerName}" "*${escaped}"`;
case 'matches':
return `header :matches "${headerName}" "${escaped}"`;
default:
return `header :contains "${headerName}" "${escaped}"`;
}
}
function generateActions(actions: FilterAction[]): string[] {
return actions.map(action => {
switch (action.type) {
case 'move':
return `fileinto "${escapeString(action.value || '')}";`;
case 'copy':
return `fileinto :copy "${escapeString(action.value || '')}";`;
case 'forward':
return `redirect "${escapeString(action.value || '')}";`;
case 'mark_read':
return 'addflag "\\\\Seen";';
case 'star':
return 'addflag "\\\\Flagged";';
case 'add_label':
return `addflag "$${escapeString(action.value || '')}";`;
case 'discard':
return 'discard;';
case 'reject':
return `reject "${escapeString(action.value || '')}";`;
case 'keep':
return 'keep;';
case 'stop':
return 'stop;';
}
});
}
function computeRequires(rules: FilterRule[]): string[] {
const extensions = new Set<string>();
const enabledRules = rules.filter(r => r.enabled);
for (const rule of enabledRules) {
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
}
for (const action of rule.actions) {
switch (action.type) {
case 'move':
extensions.add('fileinto');
break;
case 'copy':
extensions.add('fileinto');
extensions.add('copy');
break;
case 'mark_read':
case 'star':
case 'add_label':
extensions.add('imap4flags');
break;
case 'reject':
extensions.add('reject');
break;
}
}
}
return [...extensions].sort();
}
export function generateScript(rules: FilterRule[]): string {
const metadata: FilterMetadata = { version: 1, rules };
const metadataJson = JSON.stringify(metadata);
const lines: string[] = [];
lines.push('/* @metadata:begin');
lines.push(metadataJson);
lines.push('@metadata:end */');
lines.push('');
const requires = computeRequires(rules);
if (requires.length > 0) {
lines.push(`require [${requires.map(r => `"${r}"`).join(', ')}];`);
}
const enabledRules = rules.filter(r => r.enabled);
for (const rule of enabledRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) {
debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`);
continue;
}
lines.push('');
lines.push(`# Rule: ${rule.name}`);
const conditions = rule.conditions.map(generateCondition);
let conditionStr: string;
if (conditions.length === 0) {
conditionStr = 'true';
} else if (conditions.length === 1) {
conditionStr = conditions[0];
} else {
const wrapper = rule.matchType === 'all' ? 'allof' : 'anyof';
conditionStr = `${wrapper}(${conditions.join(', ')})`;
}
const actionLines = generateActions(rule.actions);
if (rule.stopProcessing) {
const lastAction = rule.actions[rule.actions.length - 1];
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
actionLines.push('stop;');
}
}
lines.push(`if ${conditionStr} {`);
for (const actionLine of actionLines) {
lines.push(` ${actionLine}`);
}
lines.push('}');
}
lines.push('');
return lines.join('\n');
}
+68
View File
@@ -0,0 +1,68 @@
import type { FilterRule, FilterMetadata } from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
export interface ParseResult {
rules: FilterRule[];
isOpaque: boolean;
}
const OPAQUE: ParseResult = { rules: [], isOpaque: true };
const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */';
function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>;
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
}
function isValidAction(a: unknown): boolean {
if (!a || typeof a !== 'object') return false;
const act = a as Record<string, unknown>;
return typeof act.type === 'string';
}
function isValidRule(rule: unknown): rule is FilterRule {
if (!rule || typeof rule !== 'object') return false;
const r = rule as Record<string, unknown>;
if (
typeof r.id !== 'string' ||
typeof r.name !== 'string' ||
typeof r.enabled !== 'boolean' ||
(r.matchType !== 'all' && r.matchType !== 'any') ||
!Array.isArray(r.conditions) ||
!Array.isArray(r.actions) ||
typeof r.stopProcessing !== 'boolean'
) return false;
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
}
export function parseScript(content: string): ParseResult {
const beginIdx = content.indexOf(METADATA_BEGIN);
if (beginIdx === -1) return 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('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;
}
return { rules: metadata.rules, isOpaque: false };
}