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:
@@ -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) {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -500,6 +500,7 @@ export interface StateChange {
|
||||
AddressBook?: string;
|
||||
Calendar?: string;
|
||||
CalendarEvent?: string;
|
||||
SieveScript?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user