From deb171022d18baff5fac6b08db1dc428359b120b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:27:55 +0100 Subject: [PATCH] fix: show the rules again after saving a script the visual builder can read from the raw Sieve editor --- .../__tests__/filter-store-raw-save.test.ts | 60 +++++++++++++++++ stores/filter-store.ts | 65 ++++++++++--------- 2 files changed, 96 insertions(+), 29 deletions(-) create mode 100644 stores/__tests__/filter-store-raw-save.test.ts diff --git a/stores/__tests__/filter-store-raw-save.test.ts b/stores/__tests__/filter-store-raw-save.test.ts new file mode 100644 index 000000000..e4c89792b --- /dev/null +++ b/stores/__tests__/filter-store-raw-save.test.ts @@ -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([]); + }); +}); diff --git a/stores/filter-store.ts b/stores/filter-store.ts index b3f08f69c..453e40c0c 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -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, 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()((set, get) => ({ rules: [], isLoading: false, @@ -163,34 +187,10 @@ export const useFilterStore = create()((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()((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);