feat: add vacation support to Sieve script generation and parsing

This commit is contained in:
Linus Rath
2026-03-27 17:36:30 +01:00
parent 415c961937
commit f6536573ae
6 changed files with 131 additions and 11 deletions
+57
View File
@@ -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"');
});
});
});
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}