feat: add vacation support to Sieve script generation and parsing
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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> = {}): 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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+22
-4
@@ -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<string, string> = {
|
||||
@@ -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<string>();
|
||||
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) {
|
||||
|
||||
+3
-2
@@ -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 };
|
||||
}
|
||||
|
||||
+27
-5
@@ -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<void>;
|
||||
@@ -27,6 +28,7 @@ interface FilterStore {
|
||||
toggleRule: (ruleId: string) => void;
|
||||
setRawScript: (content: string) => void;
|
||||
resetToVisualBuilder: () => void;
|
||||
syncVacationToScript: (client: IJMAPClient, vacation: VacationSieveConfig) => Promise<void>;
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
activeScriptId: null,
|
||||
isOpaque: false,
|
||||
rawScript: '',
|
||||
vacationSettings: null,
|
||||
|
||||
setSupported: (supported) => set({ isSupported: supported }),
|
||||
|
||||
@@ -67,10 +70,10 @@ export const useFilterStore = create<FilterStore>()((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<FilterStore>()((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<FilterStore>()((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<FilterStore>()((set, get) => ({
|
||||
activeScriptId: null,
|
||||
isOpaque: false,
|
||||
rawScript: '',
|
||||
vacationSettings: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user