Merge fix/sieve-filter-save-21: fix sieve activation (RFC 9661) - fixes #21

This commit is contained in:
Linus Rath
2026-03-10 15:30:24 +01:00
5 changed files with 517 additions and 41 deletions
+39 -37
View File
@@ -1502,17 +1502,22 @@ export class JMAPClient {
throw new Error('Invalid upload response: blobId not found');
}
async createSieveScript(name: string, content: string): Promise<SieveScript> {
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
const setArgs: Record<string, unknown> = {
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<void> {
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
const setArgs: Record<string, unknown> = {
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<void> {
async deactivateSieveScript(): Promise<void> {
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[] }> {
+68
View File
@@ -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');
});
});
});
+66
View File
@@ -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);
});
});
});
+342
View File
@@ -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> = {}): 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);
});
});
});
+2 -4
View File
@@ -94,11 +94,9 @@ export const useFilterStore = create<FilterStore>()((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 });
}