diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ee589d72..56ba6402 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1502,17 +1502,22 @@ export class JMAPClient { throw new Error('Invalid upload response: blobId not found'); } - async createSieveScript(name: string, content: string): Promise { + async createSieveScript(name: string, content: string, activate?: boolean): Promise { const blobId = await this.uploadSieveBlob(content); const accountId = this.getSieveAccountId(); + const setArgs: Record = { + accountId, + create: { + "new-script": { name, blobId } + }, + }; + if (activate) { + setArgs.onSuccessActivateScript = "#new-script"; + } + const response = await this.request([ - ["SieveScript/set", { - accountId, - create: { - "new-script": { name, blobId } - } - }, "0"] + ["SieveScript/set", setArgs, "0"] ], this.sieveUsing()); if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { @@ -1531,17 +1536,22 @@ export class JMAPClient { throw new Error("Failed to create sieve script"); } - async updateSieveScript(scriptId: string, content: string): Promise { + async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise { const blobId = await this.uploadSieveBlob(content); const accountId = this.getSieveAccountId(); + const setArgs: Record = { + accountId, + update: { + [scriptId]: { blobId } + }, + }; + if (activate) { + setArgs.onSuccessActivateScript = scriptId; + } + const response = await this.request([ - ["SieveScript/set", { - accountId, - update: { - [scriptId]: { blobId } - } - }, "0"] + ["SieveScript/set", setArgs, "0"] ], this.sieveUsing()); if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { @@ -1582,44 +1592,36 @@ export class JMAPClient { const response = await this.request([ ["SieveScript/set", { accountId, - update: { - [scriptId]: { isActive: true } - } + onSuccessActivateScript: scriptId, }, "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; + const [methodName, result] = response.methodResponses?.[0] || []; + if (methodName === "error") { + throw new Error(result?.description || "Failed to activate sieve script"); + } + if (methodName !== "SieveScript/set") { + throw new Error("Failed to activate sieve script"); } - throw new Error("Failed to activate sieve script"); } - async deactivateSieveScript(scriptId: string): Promise { + async deactivateSieveScript(): Promise { const accountId = this.getSieveAccountId(); const response = await this.request([ ["SieveScript/set", { accountId, - update: { - [scriptId]: { isActive: false } - } + onSuccessActivateScript: null, }, "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; + const [methodName, result] = response.methodResponses?.[0] || []; + if (methodName === "error") { + throw new Error(result?.description || "Failed to deactivate sieve script"); + } + if (methodName !== "SieveScript/set") { + throw new Error("Failed to deactivate sieve script"); } - throw new Error("Failed to deactivate sieve script"); } async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> { diff --git a/lib/sieve/__tests__/generator.test.ts b/lib/sieve/__tests__/generator.test.ts index 140c7d93..9b59c831 100644 --- a/lib/sieve/__tests__/generator.test.ts +++ b/lib/sieve/__tests__/generator.test.ts @@ -399,4 +399,72 @@ describe('generateScript', () => { const secondIdx = script.indexOf('# Rule: Second'); expect(firstIdx).toBeLessThan(secondIdx); }); + + describe('edge cases', () => { + it('skips rules with empty conditions', () => { + const script = generateScript([makeRule({ name: 'Empty', conditions: [], actions: [{ type: 'keep' }] })]); + expect(script).not.toContain('# Rule: Empty'); + expect(script).not.toContain('if '); + }); + + it('skips rules with empty actions', () => { + const script = generateScript([makeRule({ name: 'NoAction', actions: [] })]); + expect(script).not.toContain('# Rule: NoAction'); + expect(script).not.toContain('if '); + }); + + it('uses X-Unknown for header field without headerName', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'header', comparator: 'contains', value: 'test' }], + })]); + expect(script).toContain('header :contains "X-Unknown" "test"'); + }); + + it('handles all enabled rules with different extensions combined', () => { + const rules = [ + makeRule({ id: '1', actions: [{ type: 'move', value: 'A' }] }), + makeRule({ id: '2', actions: [{ type: 'reject', value: 'No' }] }), + makeRule({ id: '3', conditions: [{ field: 'body', comparator: 'contains', value: 'x' }], actions: [{ type: 'star' }] }), + ]; + const script = generateScript(rules); + const requireLine = script.split('\n').find(l => l.startsWith('require'))!; + expect(requireLine).toContain('"fileinto"'); + expect(requireLine).toContain('"reject"'); + expect(requireLine).toContain('"body"'); + expect(requireLine).toContain('"imap4flags"'); + }); + + it('generates no require line when only keep/discard/stop/forward actions', () => { + const script = generateScript([makeRule({ + actions: [{ type: 'keep' }, { type: 'forward', value: 'a@b.com' }], + })]); + expect(script).not.toContain('require'); + }); + + it('handles multiple actions on same rule', () => { + const script = generateScript([makeRule({ + actions: [ + { type: 'move', value: 'Folder' }, + { type: 'mark_read' }, + { type: 'star' }, + { type: 'stop' }, + ], + })]); + expect(script).toContain('fileinto "Folder";'); + expect(script).toContain('addflag "\\\\Seen";'); + expect(script).toContain('addflag "\\\\Flagged";'); + expect(script).toContain('stop;'); + }); + + it('generates valid script for all-disabled rules', () => { + const rules = [ + makeRule({ id: '1', enabled: false }), + makeRule({ id: '2', enabled: false }), + ]; + const script = generateScript(rules); + expect(script).toContain('@metadata:begin'); + expect(script).not.toContain('if '); + expect(script).not.toContain('require'); + }); + }); }); diff --git a/lib/sieve/__tests__/parser.test.ts b/lib/sieve/__tests__/parser.test.ts index 2511146a..b8412be1 100644 --- a/lib/sieve/__tests__/parser.test.ts +++ b/lib/sieve/__tests__/parser.test.ts @@ -144,4 +144,70 @@ describe('parseScript', () => { expect(result.rules).toEqual(rules); }); }); + + describe('validation edge cases', () => { + it('returns isOpaque when rule has non-string id', () => { + const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: 123, name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`; + expect(parseScript(script).isOpaque).toBe(true); + }); + + it('returns isOpaque when rule has non-boolean enabled', () => { + const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: 'yes', matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`; + expect(parseScript(script).isOpaque).toBe(true); + }); + + it('returns isOpaque when rule has invalid matchType', () => { + const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'none', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`; + expect(parseScript(script).isOpaque).toBe(true); + }); + + it('returns isOpaque when condition missing value', () => { + const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`; + expect(parseScript(script).isOpaque).toBe(true); + }); + + it('returns isOpaque when action missing type', () => { + const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ value: 'Inbox' }], stopProcessing: false }] })}\n@metadata:end */`; + expect(parseScript(script).isOpaque).toBe(true); + }); + + it('accepts valid empty rules array', () => { + const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [] })}\n@metadata:end */`; + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toEqual([]); + }); + + it('preserves all comparator types through round-trip', () => { + const comparators = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'] as const; + const rules = comparators.map((comparator, i) => makeRule({ + id: `r${i}`, + name: `Rule ${comparator}`, + conditions: [{ field: 'from', comparator, value: 'test' }], + })); + const script = generateScript(rules); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toEqual(rules); + }); + + it('preserves size comparators through round-trip', () => { + const rules = [ + makeRule({ id: 'r1', conditions: [{ field: 'size', comparator: 'greater_than', value: '1000' }], actions: [{ type: 'discard' }] }), + makeRule({ id: 'r2', conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], actions: [{ type: 'keep' }] }), + ]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.rules).toEqual(rules); + }); + + it('preserves header field with custom headerName', () => { + const rules = [makeRule({ + conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-My-Header' }], + })]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.rules).toEqual(rules); + }); + }); }); diff --git a/stores/__tests__/filter-store.test.ts b/stores/__tests__/filter-store.test.ts new file mode 100644 index 00000000..30d68532 --- /dev/null +++ b/stores/__tests__/filter-store.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useFilterStore } from '../filter-store'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; + +const makeRule = (overrides: Partial = {}): FilterRule => ({ + 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('filter-store', () => { + beforeEach(() => { + useFilterStore.getState().clearState(); + }); + + describe('addRule', () => { + it('should append a rule', () => { + useFilterStore.getState().addRule(makeRule()); + expect(useFilterStore.getState().rules).toHaveLength(1); + expect(useFilterStore.getState().rules[0].id).toBe('rule-1'); + }); + + it('should not replace existing rules', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1' })); + useFilterStore.getState().addRule(makeRule({ id: 'r2' })); + expect(useFilterStore.getState().rules).toHaveLength(2); + }); + }); + + describe('updateRule', () => { + it('should update matching rule', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1', name: 'Old' })); + useFilterStore.getState().updateRule('r1', { name: 'New' }); + expect(useFilterStore.getState().rules[0].name).toBe('New'); + }); + + it('should not modify other rules', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1', name: 'A' })); + useFilterStore.getState().addRule(makeRule({ id: 'r2', name: 'B' })); + useFilterStore.getState().updateRule('r1', { name: 'Updated' }); + expect(useFilterStore.getState().rules[1].name).toBe('B'); + }); + + it('should no-op for non-existent rule', () => { + useFilterStore.getState().addRule(makeRule()); + useFilterStore.getState().updateRule('nonexistent', { name: 'X' }); + expect(useFilterStore.getState().rules[0].name).toBe('Test Rule'); + }); + }); + + describe('deleteRule', () => { + it('should remove rule by id', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1' })); + useFilterStore.getState().addRule(makeRule({ id: 'r2' })); + useFilterStore.getState().deleteRule('r1'); + expect(useFilterStore.getState().rules).toHaveLength(1); + expect(useFilterStore.getState().rules[0].id).toBe('r2'); + }); + + it('should no-op for unknown id', () => { + useFilterStore.getState().addRule(makeRule()); + useFilterStore.getState().deleteRule('unknown'); + expect(useFilterStore.getState().rules).toHaveLength(1); + }); + }); + + describe('toggleRule', () => { + it('should toggle enabled to disabled', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1', enabled: true })); + useFilterStore.getState().toggleRule('r1'); + expect(useFilterStore.getState().rules[0].enabled).toBe(false); + }); + + it('should toggle disabled to enabled', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1', enabled: false })); + useFilterStore.getState().toggleRule('r1'); + expect(useFilterStore.getState().rules[0].enabled).toBe(true); + }); + + it('should not affect other rules', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1', enabled: true })); + useFilterStore.getState().addRule(makeRule({ id: 'r2', enabled: true })); + useFilterStore.getState().toggleRule('r1'); + expect(useFilterStore.getState().rules[1].enabled).toBe(true); + }); + }); + + describe('reorderRules', () => { + it('should reorder rules by id array', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1', name: 'First' })); + useFilterStore.getState().addRule(makeRule({ id: 'r2', name: 'Second' })); + useFilterStore.getState().addRule(makeRule({ id: 'r3', name: 'Third' })); + useFilterStore.getState().reorderRules(['r3', 'r1', 'r2']); + const names = useFilterStore.getState().rules.map(r => r.name); + expect(names).toEqual(['Third', 'First', 'Second']); + }); + + it('should filter out unknown ids', () => { + useFilterStore.getState().addRule(makeRule({ id: 'r1' })); + useFilterStore.getState().reorderRules(['r1', 'unknown']); + expect(useFilterStore.getState().rules).toHaveLength(1); + }); + }); + + describe('setRawScript', () => { + it('should update rawScript', () => { + useFilterStore.getState().setRawScript('require ["fileinto"];'); + expect(useFilterStore.getState().rawScript).toBe('require ["fileinto"];'); + }); + }); + + describe('resetToVisualBuilder', () => { + it('should clear opaque state, rawScript, and rules', () => { + useFilterStore.setState({ isOpaque: true, rawScript: 'some script', rules: [makeRule()] }); + useFilterStore.getState().resetToVisualBuilder(); + expect(useFilterStore.getState().isOpaque).toBe(false); + expect(useFilterStore.getState().rawScript).toBe(''); + expect(useFilterStore.getState().rules).toEqual([]); + }); + }); + + describe('clearState', () => { + it('should reset all state to defaults', () => { + useFilterStore.setState({ + rules: [makeRule()], + isLoading: true, + isSaving: true, + error: 'some error', + isSupported: true, + activeScriptId: 'script-1', + isOpaque: true, + rawScript: 'content', + }); + useFilterStore.getState().clearState(); + const state = useFilterStore.getState(); + expect(state.rules).toEqual([]); + expect(state.isLoading).toBe(false); + expect(state.isSaving).toBe(false); + expect(state.error).toBeNull(); + expect(state.isSupported).toBe(false); + expect(state.activeScriptId).toBeNull(); + expect(state.isOpaque).toBe(false); + expect(state.rawScript).toBe(''); + }); + }); + + describe('fetchFilters', () => { + it('should set isOpaque for scripts without metadata', async () => { + const mockClient = { + getSieveCapabilities: () => null, + getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], + getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }', + }; + await useFilterStore.getState().fetchFilters(mockClient as any); + expect(useFilterStore.getState().isOpaque).toBe(true); + expect(useFilterStore.getState().rules).toEqual([]); + }); + + it('should parse rules from metadata-bearing script', async () => { + const rules = [makeRule()]; + const { generateScript } = await import('@/lib/sieve/generator'); + const script = generateScript(rules); + const mockClient = { + getSieveCapabilities: () => null, + getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], + getSieveScriptContent: async () => script, + }; + await useFilterStore.getState().fetchFilters(mockClient as any); + expect(useFilterStore.getState().isOpaque).toBe(false); + expect(useFilterStore.getState().rules).toEqual(rules); + }); + + it('should handle empty script list', async () => { + const mockClient = { + getSieveCapabilities: () => null, + getSieveScripts: async () => [], + getSieveScriptContent: async () => '', + }; + await useFilterStore.getState().fetchFilters(mockClient as any); + expect(useFilterStore.getState().rules).toEqual([]); + expect(useFilterStore.getState().activeScriptId).toBeNull(); + }); + + it('should set error on failure', async () => { + const mockClient = { + getSieveCapabilities: () => null, + getSieveScripts: async () => { throw new Error('Network error'); }, + }; + await useFilterStore.getState().fetchFilters(mockClient as any); + expect(useFilterStore.getState().error).toBe('Network error'); + expect(useFilterStore.getState().isLoading).toBe(false); + }); + }); + + describe('saveFilters', () => { + it('should call updateSieveScript with activate when activeScriptId exists', async () => { + const calls: Array<{ method: string; args: unknown[] }> = []; + const mockClient = { + updateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'updateSieveScript', args }); }, + createSieveScript: async (...args: unknown[]) => { calls.push({ method: 'createSieveScript', args }); return { id: 'new-id' }; }, + }; + useFilterStore.setState({ + activeScriptId: 'existing-id', + rules: [makeRule()], + isOpaque: false, + }); + await useFilterStore.getState().saveFilters(mockClient as any); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('updateSieveScript'); + expect(calls[0].args[0]).toBe('existing-id'); + expect(calls[0].args[2]).toBe(true); // activate flag + }); + + it('should call createSieveScript with activate when no activeScriptId', async () => { + const calls: Array<{ method: string; args: unknown[] }> = []; + const mockClient = { + updateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'updateSieveScript', args }); }, + createSieveScript: async (...args: unknown[]) => { + calls.push({ method: 'createSieveScript', args }); + return { id: 'new-id', name: 'filters', blobId: 'b1', isActive: true }; + }, + }; + useFilterStore.setState({ + activeScriptId: null, + rules: [makeRule()], + isOpaque: false, + }); + await useFilterStore.getState().saveFilters(mockClient as any); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('createSieveScript'); + expect(calls[0].args[2]).toBe(true); // activate flag + expect(useFilterStore.getState().activeScriptId).toBe('new-id'); + }); + + it('should use rawScript when isOpaque', async () => { + let savedContent = ''; + const mockClient = { + updateSieveScript: async (_id: string, content: string) => { savedContent = content; }, + }; + useFilterStore.setState({ + activeScriptId: 'existing-id', + rules: [], + isOpaque: true, + rawScript: 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }', + }); + await useFilterStore.getState().saveFilters(mockClient as any); + expect(savedContent).toContain('require ["fileinto"]'); + }); + + it('should set error on failure', async () => { + const mockClient = { + updateSieveScript: async () => { throw new Error('Server error'); }, + }; + useFilterStore.setState({ activeScriptId: 'existing-id', rules: [makeRule()] }); + await expect(useFilterStore.getState().saveFilters(mockClient as any)).rejects.toThrow('Server error'); + expect(useFilterStore.getState().error).toBe('Server error'); + expect(useFilterStore.getState().isSaving).toBe(false); + }); + + it('should generate script content from rules when not opaque', async () => { + let savedContent = ''; + const mockClient = { + updateSieveScript: async (_id: string, content: string) => { savedContent = content; }, + }; + const rules = [makeRule({ name: 'My Filter' })]; + useFilterStore.setState({ activeScriptId: 'existing-id', rules, isOpaque: false }); + await useFilterStore.getState().saveFilters(mockClient as any); + expect(savedContent).toContain('@metadata:begin'); + expect(savedContent).toContain('My Filter'); + expect(savedContent).toContain('fileinto "Archive"'); + }); + + it('should update rawScript after successful save', async () => { + const mockClient = { + updateSieveScript: async () => {}, + }; + useFilterStore.setState({ activeScriptId: 'existing-id', rules: [makeRule()], isOpaque: false, rawScript: '' }); + await useFilterStore.getState().saveFilters(mockClient as any); + expect(useFilterStore.getState().rawScript).toContain('@metadata:begin'); + }); + }); + + describe('setSupported', () => { + it('should set isSupported flag', () => { + useFilterStore.getState().setSupported(true); + expect(useFilterStore.getState().isSupported).toBe(true); + useFilterStore.getState().setSupported(false); + expect(useFilterStore.getState().isSupported).toBe(false); + }); + }); + + describe('fetchFilters', () => { + it('should prefer the active script over the first script', async () => { + const { generateScript } = await import('@/lib/sieve/generator'); + const activeRules = [makeRule({ name: 'Active' })]; + const inactiveRules = [makeRule({ name: 'Inactive' })]; + + const mockClient = { + getSieveCapabilities: () => null, + getSieveScripts: async () => [ + { id: 's1', name: 'old', blobId: 'b1', isActive: false }, + { id: 's2', name: 'main', blobId: 'b2', isActive: true }, + ], + getSieveScriptContent: async (blobId: string) => { + if (blobId === 'b2') return generateScript(activeRules); + return generateScript(inactiveRules); + }, + }; + await useFilterStore.getState().fetchFilters(mockClient as any); + expect(useFilterStore.getState().activeScriptId).toBe('s2'); + expect(useFilterStore.getState().rules[0].name).toBe('Active'); + }); + + it('should set sieveCapabilities from client', async () => { + const caps = { implementation: 'test', maxSizeScript: 10000, sieveExtensions: ['fileinto'], notificationMethods: [], externalLists: [] }; + const mockClient = { + getSieveCapabilities: () => caps, + getSieveScripts: async () => [], + }; + await useFilterStore.getState().fetchFilters(mockClient as any); + expect(useFilterStore.getState().sieveCapabilities).toEqual(caps); + }); + + it('should set rawScript from script content', async () => { + const { generateScript } = await import('@/lib/sieve/generator'); + const rules = [makeRule()]; + const script = generateScript(rules); + const mockClient = { + getSieveCapabilities: () => null, + getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], + getSieveScriptContent: async () => script, + }; + await useFilterStore.getState().fetchFilters(mockClient as any); + expect(useFilterStore.getState().rawScript).toBe(script); + }); + }); +}); diff --git a/stores/filter-store.ts b/stores/filter-store.ts index a5bc40d2..25f2aaad 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -94,11 +94,9 @@ export const useFilterStore = create()((set, get) => ({ } if (activeScriptId) { - await client.updateSieveScript(activeScriptId, content); - await client.activateSieveScript(activeScriptId); + await client.updateSieveScript(activeScriptId, content, true); } else { - const script = await client.createSieveScript('filters', content); - await client.activateSieveScript(script.id); + const script = await client.createSieveScript('filters', content, true); set({ activeScriptId: script.id }); }