From f6536573ae58af7f7a5f40fe86ac838a7dd427c0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:36:30 +0100 Subject: [PATCH] feat: add vacation support to Sieve script generation and parsing --- components/settings/vacation-settings.tsx | 15 ++++++ lib/__tests__/sieve-generator.test.ts | 57 +++++++++++++++++++++++ lib/jmap/sieve-types.ts | 7 +++ lib/sieve/generator.ts | 26 +++++++++-- lib/sieve/parser.ts | 5 +- stores/filter-store.ts | 32 +++++++++++-- 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index 15feb26b..d5df9913 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; import { useVacationStore } from '@/stores/vacation-store'; +import { useFilterStore } from '@/stores/filter-store'; import { useAuthStore } from '@/stores/auth-store'; import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react'; import { toast } from '@/stores/toast-store'; @@ -102,6 +103,20 @@ export function VacationSettings() { subject: localSubject, textBody: localTextBody, }); + + // Re-save the filter script to preserve metadata and include vacation block. + // This prevents the server from injecting vacation Sieve code that destroys + // the metadata comment the visual filter builder relies on. + try { + await useFilterStore.getState().syncVacationToScript(client, { + isEnabled: localEnabled, + subject: localSubject, + textBody: localTextBody, + }); + } catch { + // Non-critical: vacation was saved via JMAP, script sync is best-effort + } + toast.success(tNotifications('vacation_saved')); } catch (error) { console.error('Failed to save vacation response:', error); diff --git a/lib/__tests__/sieve-generator.test.ts b/lib/__tests__/sieve-generator.test.ts index 17146a53..6f1feb8e 100644 --- a/lib/__tests__/sieve-generator.test.ts +++ b/lib/__tests__/sieve-generator.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { generateScript } from '@/lib/sieve/generator'; import { parseScript } from '@/lib/sieve/parser'; import type { FilterRule } from '@/lib/jmap/sieve-types'; +import type { VacationSieveConfig } from '@/lib/jmap/sieve-types'; const makeRule = (overrides: Partial = {}): FilterRule => ({ id: 'rule-1', @@ -114,4 +115,60 @@ describe('sieve generator', () => { expect(script).toContain('stop;'); }); }); + + describe('vacation support', () => { + const vacation: VacationSieveConfig = { + isEnabled: true, + subject: 'Out of Office', + textBody: 'I am currently away.', + }; + + it('should generate vacation block when vacation is enabled', () => { + const script = generateScript([], vacation); + expect(script).toContain('require ["vacation"]'); + expect(script).toContain('vacation :subject "Out of Office" "I am currently away.";'); + }); + + it('should not generate vacation block when vacation is disabled', () => { + const disabled: VacationSieveConfig = { isEnabled: false, subject: '', textBody: '' }; + const script = generateScript([], disabled); + expect(script).not.toContain('vacation'); + }); + + it('should generate vacation block without subject when subject is empty', () => { + const noSubject: VacationSieveConfig = { isEnabled: true, subject: '', textBody: 'Away' }; + const script = generateScript([], noSubject); + expect(script).toContain('vacation "Away";'); + expect(script).not.toContain(':subject'); + }); + + it('should include both vacation and filter rules', () => { + const rules = [makeRule()]; + const script = generateScript(rules, vacation); + expect(script).toContain('"vacation"'); + expect(script).toContain('"fileinto"'); + expect(script).toContain('vacation :subject "Out of Office"'); + expect(script).toContain('fileinto "Archive"'); + }); + + it('should preserve vacation settings in metadata round-trip', () => { + const rules = [makeRule()]; + const script = generateScript(rules, vacation); + const parsed = parseScript(script); + expect(parsed.isOpaque).toBe(false); + expect(parsed.vacation).toEqual(vacation); + expect(parsed.rules).toHaveLength(1); + }); + + it('should escape special characters in vacation text', () => { + const special: VacationSieveConfig = { + isEnabled: true, + subject: 'Re: "Test"', + textBody: 'Line with "quotes" and \\backslash', + }; + const script = generateScript([], special); + expect(script).toContain(':subject "Re: \\"Test\\""'); + expect(script).toContain('"Line with \\"quotes\\" and \\\\backslash"'); + }); + }); }); diff --git a/lib/jmap/sieve-types.ts b/lib/jmap/sieve-types.ts index cfa577ec..1f8e4035 100644 --- a/lib/jmap/sieve-types.ts +++ b/lib/jmap/sieve-types.ts @@ -49,7 +49,14 @@ export interface FilterRule { stopProcessing: boolean; } +export interface VacationSieveConfig { + isEnabled: boolean; + subject: string; + textBody: string; +} + export interface FilterMetadata { version: 1; rules: FilterRule[]; + vacation?: VacationSieveConfig; } diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts index 3ee02a1d..ad705e30 100644 --- a/lib/sieve/generator.ts +++ b/lib/sieve/generator.ts @@ -1,4 +1,4 @@ -import type { FilterRule, FilterCondition, FilterAction, FilterMetadata } from '@/lib/jmap/sieve-types'; +import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { debug } from '@/lib/debug'; const HEADER_MAP: Record = { @@ -78,10 +78,14 @@ function generateActions(actions: FilterAction[]): string[] { }); } -function computeRequires(rules: FilterRule[]): string[] { +function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): string[] { const extensions = new Set(); const enabledRules = rules.filter(r => r.enabled); + if (vacation?.isEnabled) { + extensions.add('vacation'); + } + for (const rule of enabledRules) { for (const condition of rule.conditions) { if (condition.field === 'body') extensions.add('body'); @@ -110,8 +114,11 @@ function computeRequires(rules: FilterRule[]): string[] { return [...extensions].sort(); } -export function generateScript(rules: FilterRule[]): string { +export function generateScript(rules: FilterRule[], vacation?: VacationSieveConfig): string { const metadata: FilterMetadata = { version: 1, rules }; + if (vacation?.isEnabled) { + metadata.vacation = vacation; + } const metadataJson = JSON.stringify(metadata); const lines: string[] = []; @@ -120,11 +127,22 @@ export function generateScript(rules: FilterRule[]): string { lines.push('@metadata:end */'); lines.push(''); - const requires = computeRequires(rules); + const requires = computeRequires(rules, vacation); if (requires.length > 0) { lines.push(`require [${requires.map(r => `"${r}"`).join(', ')}];`); } + if (vacation?.isEnabled) { + lines.push(''); + lines.push('# Vacation auto-reply'); + const vacationParts: string[] = []; + if (vacation.subject) { + vacationParts.push(`:subject "${escapeString(vacation.subject)}"`); + } + vacationParts.push(`"${escapeString(vacation.textBody || '')}"`); + lines.push(`vacation ${vacationParts.join(' ')};`); + } + const enabledRules = rules.filter(r => r.enabled); for (const rule of enabledRules) { diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts index e08d18d4..9f153a82 100644 --- a/lib/sieve/parser.ts +++ b/lib/sieve/parser.ts @@ -1,9 +1,10 @@ -import type { FilterRule, FilterMetadata } from '@/lib/jmap/sieve-types'; +import type { FilterRule, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { debug } from '@/lib/debug'; export interface ParseResult { rules: FilterRule[]; isOpaque: boolean; + vacation?: VacationSieveConfig; } const OPAQUE: ParseResult = { rules: [], isOpaque: true }; @@ -64,5 +65,5 @@ export function parseScript(content: string): ParseResult { if (!isValidRule(rule)) return OPAQUE; } - return { rules: metadata.rules, isOpaque: false }; + return { rules: metadata.rules, isOpaque: false, vacation: metadata.vacation }; } diff --git a/stores/filter-store.ts b/stores/filter-store.ts index bff1dbfe..2257a4be 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types'; +import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { parseScript } from '@/lib/sieve/parser'; import { generateScript } from '@/lib/sieve/generator'; import { debug } from '@/lib/debug'; @@ -15,6 +15,7 @@ interface FilterStore { activeScriptId: string | null; isOpaque: boolean; rawScript: string; + vacationSettings: VacationSieveConfig | null; setSupported: (supported: boolean) => void; fetchFilters: (client: IJMAPClient) => Promise; @@ -27,6 +28,7 @@ interface FilterStore { toggleRule: (ruleId: string) => void; setRawScript: (content: string) => void; resetToVisualBuilder: () => void; + syncVacationToScript: (client: IJMAPClient, vacation: VacationSieveConfig) => Promise; clearState: () => void; } @@ -40,6 +42,7 @@ export const useFilterStore = create()((set, get) => ({ activeScriptId: null, isOpaque: false, rawScript: '', + vacationSettings: null, setSupported: (supported) => set({ isSupported: supported }), @@ -67,10 +70,10 @@ export const useFilterStore = create()((set, get) => ({ if (result.isOpaque) { debug.log('Sieve script is opaque (hand-edited)'); - set({ isLoading: false, isOpaque: true, rules: [] }); + set({ isLoading: false, isOpaque: true, rules: [], vacationSettings: result.vacation || null }); } else { debug.log('Parsed', result.rules.length, 'filter rules'); - set({ isLoading: false, isOpaque: false, rules: result.rules }); + set({ isLoading: false, isOpaque: false, rules: result.rules, vacationSettings: result.vacation || null }); } } catch (error) { debug.error('Failed to fetch filters:', error); @@ -84,13 +87,13 @@ export const useFilterStore = create()((set, get) => ({ saveFilters: async (client) => { set({ isSaving: true, error: null }); try { - const { isOpaque, rawScript, rules, activeScriptId } = get(); + const { isOpaque, rawScript, rules, activeScriptId, vacationSettings } = get(); let content: string; if (isOpaque) { content = rawScript; } else { - content = generateScript(rules); + content = generateScript(rules, vacationSettings || undefined); } if (activeScriptId) { @@ -152,6 +155,24 @@ export const useFilterStore = create()((set, get) => ({ resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [] }), + syncVacationToScript: async (client, vacation) => { + const state = get(); + if (!state.isSupported) return; + + // Ensure filters are loaded first + if (state.activeScriptId === null && !state.isLoading) { + await get().fetchFilters(client); + } + + set({ vacationSettings: vacation }); + + // Only re-save if we have a parseable script (not opaque) + const currentState = get(); + if (!currentState.isOpaque) { + await get().saveFilters(client); + } + }, + clearState: () => set({ rules: [], isLoading: false, @@ -162,5 +183,6 @@ export const useFilterStore = create()((set, get) => ({ activeScriptId: null, isOpaque: false, rawScript: '', + vacationSettings: null, }), }));