fix: show the rules again after saving a script the visual builder can read from the raw Sieve editor

This commit is contained in:
Linus Rath
2026-10-28 03:27:55 +01:00
parent 7986939ae3
commit deb171022d
2 changed files with 96 additions and 29 deletions
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { generateScript } from '@/lib/sieve/generator';
import { mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
import { useFilterStore } from '../filter-store';
// After a save from the raw Sieve editor the page reads the script the way
// a page load does: a script the builder understands shows its rules again,
// rather than "edited outside the visual builder" until the next visit.
const EXTENSIONS = ['fileinto', 'mailbox', 'imap4flags'];
const rule: FilterRule = {
id: 'r1',
name: 'Newsletters',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'news@example.com' }],
actions: [{ type: 'mark_read' }],
stopProcessing: false,
};
/** What the settings page does when the raw editor saves. */
async function saveRaw(client: never, content: string) {
useFilterStore.getState().setRawScript(content);
useFilterStore.setState({ isOpaque: true, rules: [] });
await useFilterStore.getState().saveFilters(client);
}
afterEach(() => useFilterStore.getState().clearState());
describe('saving from the raw Sieve editor', () => {
it('shows the rules of a script the builder understands, as a fresh load does', async () => {
const script = generateScript([rule], undefined, { extensions: EXTENSIONS });
const server = mockStalwartAccount('a', [{ name: 'filters', content: script, isActive: true }], EXTENSIONS);
await useFilterStore.getState().fetchFilters(server.client as never, 'a');
expect(useFilterStore.getState().rules).toHaveLength(1);
await saveRaw(server.client as never, script);
const after = useFilterStore.getState();
expect(after.isOpaque).toBe(false);
expect(after.rules.map((r) => r.name)).toEqual(['Newsletters']);
// The same as a fresh visit to the page.
await useFilterStore.getState().fetchFilters(server.client as never, 'a');
expect(useFilterStore.getState().isOpaque).toBe(after.isOpaque);
expect(useFilterStore.getState().rules.map((r) => r.name)).toEqual(['Newsletters']);
});
it('stays in raw mode for a script the builder cannot read', async () => {
const server = mockStalwartAccount('a', [{ name: 'filters', content: 'keep;', isActive: true }], EXTENSIONS);
await useFilterStore.getState().fetchFilters(server.client as never, 'a');
await saveRaw(server.client as never, '/* @metadata:begin\n{corrupt\n@metadata:end */');
expect(useFilterStore.getState().isOpaque).toBe(true);
expect(useFilterStore.getState().rules).toEqual([]);
});
});
+36 -29
View File
@@ -93,6 +93,30 @@ interface FilterStore {
*/
let fetchGeneration = 0;
/** What the filter view holds for a parsed script, on load and after a raw save. */
function stateFromScript(result: ReturnType<typeof parseScript>, vacationActive: boolean) {
if (result.isOpaque) {
return {
isOpaque: true,
rules: [] as FilterRule[],
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
includeVacation: false,
vacationForward: null,
vacationAudience: null,
};
}
return {
isOpaque: false,
rules: result.rules,
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
includeVacation: !!result.includeVacation || vacationActive,
vacationForward: result.vacationForward ?? null,
vacationAudience: result.vacationAudience ?? null,
};
}
export const useFilterStore = create<FilterStore>()((set, get) => ({
rules: [],
isLoading: false,
@@ -163,34 +187,10 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
set({ rawScript: content });
const result = parseScript(content);
if (result.isOpaque) {
debug.log('filters', 'Sieve script is opaque (hand-edited)');
emitFiltersLoaded([]);
set({
isLoading: false,
isOpaque: true,
rules: [],
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
includeVacation: false,
vacationForward: null,
vacationAudience: null,
});
} else {
debug.log('filters', 'Parsed', result.rules.length, 'filter rules');
emitFiltersLoaded(result.rules);
set({
isLoading: false,
isOpaque: false,
rules: result.rules,
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
includeVacation: !!result.includeVacation || vacationActive,
vacationForward: result.vacationForward ?? null,
vacationAudience: result.vacationAudience ?? null,
});
}
if (result.isOpaque) debug.log('filters', 'Sieve script is opaque (hand-edited)');
else debug.log('filters', 'Parsed', result.rules.length, 'filter rules');
emitFiltersLoaded(result.isOpaque ? [] : result.rules);
set({ isLoading: false, ...stateFromScript(result, vacationActive) });
} catch (error) {
if (stale()) return;
debug.error('Failed to fetch filters:', error);
@@ -245,7 +245,14 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const written = await writeFiltersScript(client, selectedAccountId || null, content, activeScriptId);
if (!activeScriptId) set({ activeScriptId: written.scriptId });
set({ isSaving: false, rawScript: written.content });
if (isOpaque) {
// A script saved from the raw editor is read the way a page load
// reads it: one the builder understands shows its rules again.
const result = parseScript(written.content);
set({ isSaving: false, rawScript: written.content, ...stateFromScript(result, includeVacation) });
} else {
set({ isSaving: false, rawScript: written.content });
}
debug.log('filters', 'Filters saved successfully');
} catch (error) {
debug.error('Failed to save filters:', error);