diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index 0bdfd24b..73893a2e 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -142,7 +142,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { const vacationEnabled = useVacationStore((s) => s.accountId === null && s.isEnabled); const { - rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings, + rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings, includeVacation, selectAccount, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule, setOpaqueScript, resetToVisualBuilder, validateScript, } = useFilterStore(); @@ -163,7 +163,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { }, [managedAccountId, selectAccount]); const showVacationBanner = - !managedAccountId && ((vacationEnabled || vacationSettings?.isEnabled) ?? false); + !managedAccountId && ((vacationEnabled || vacationSettings?.isEnabled || includeVacation) ?? false); // Editable (Bulwark-managed) rules, in order, for reorder math. const editableIds = useMemo( diff --git a/src/lib/sieve/__tests__/fixtures/webmail/vacation-include.sieve b/src/lib/sieve/__tests__/fixtures/webmail/vacation-include.sieve new file mode 100644 index 00000000..a3f8c02e --- /dev/null +++ b/src/lib/sieve/__tests__/fixtures/webmail/vacation-include.sieve @@ -0,0 +1,20 @@ +/* @metadata:begin +{"version":1,"rules":[{"id":"news","name":"Newsletters","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"mark_read"},{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"boss","name":"Boss","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"is","value":"boss@example.com"}],"actions":[{"type":"keep"}],"stopProcessing":true}],"includeVacation":true} +@metadata:end */ + +require ["fileinto", "imap4flags", "include"]; + +# Vacation auto-reply +include :personal :optional "vacation"; + +# Rule: Newsletters +if header :contains "From" "news@example.com" { + addflag "\\Seen"; + fileinto "Newsletters"; +} + +# Rule: Boss +if header :is "From" "boss@example.com" { + fileinto "INBOX"; + stop; +} diff --git a/src/lib/sieve/__tests__/generator.test.ts b/src/lib/sieve/__tests__/generator.test.ts index 6f5a2ae0..9bbb01c6 100644 --- a/src/lib/sieve/__tests__/generator.test.ts +++ b/src/lib/sieve/__tests__/generator.test.ts @@ -260,6 +260,27 @@ describe('generateScript', () => { }); }); + describe('vacation include', () => { + it('includes the server vacation script before the rules', () => { + const script = generateScript([makeRule()], undefined, { includeVacation: true }); + expect(script).toContain('require ["fileinto", "include"];'); + const include = script.indexOf('include :personal :optional "vacation";'); + expect(include).toBeGreaterThan(-1); + expect(include).toBeLessThan(script.indexOf('# Rule: Test Rule')); + }); + + it('round-trips the flag and drops "include" once it is turned off', () => { + const parsed = parseScript(generateScript([makeRule()], undefined, { includeVacation: true })); + expect(parsed.includeVacation).toBe(true); + expect(parsed.rules).toHaveLength(1); + expect(parsed.externalRequires).not.toContain('include'); + + const off = generateScript(parsed.rules, parsed.vacation, { externalRequires: parsed.externalRequires }); + expect(off).not.toContain('include'); + expect(parseScript(off).includeVacation).toBeUndefined(); + }); + }); + describe('disabled rules', () => { it('excludes disabled rules from Sieve code', () => { const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]); diff --git a/src/lib/sieve/__tests__/webmail-fixtures.test.ts b/src/lib/sieve/__tests__/webmail-fixtures.test.ts new file mode 100644 index 00000000..fb01d165 --- /dev/null +++ b/src/lib/sieve/__tests__/webmail-fixtures.test.ts @@ -0,0 +1,55 @@ +// Scripts written by the webmail must survive a save on the phone: parsing +// them into rules and generating the script again has to give back the same +// rules and the same Sieve, or saving filters on the phone would silently +// change what the server does (switch the auto-reply off, drop folder ids, +// let spam into folders...). +// +// The fixtures in fixtures/webmail/ were written by the webmail's own +// generator (jmap-webmail lib/sieve/generator.ts at e33ab899), run on the +// rules in each file's metadata. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { generateScript } from '../generator'; +import { parseScript } from '../parser'; + +interface WebmailFixture { + file: string; +} + +const FIXTURES: WebmailFixture[] = [ + { file: 'vacation-include.sieve' }, +]; + +function readFixture(file: string): string { + // Git may check the fixture out with CRLF line ends on Windows. + return readFileSync(join(__dirname, 'fixtures', 'webmail', file), 'utf-8').replace(/\r\n/g, '\n'); +} + +function metadataRules(script: string): unknown[] { + const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/); + return JSON.parse(match![1]).rules; +} + +describe('scripts written by the webmail', () => { + for (const { file } of FIXTURES) { + describe(file, () => { + const script = readFixture(file); + const parsed = parseScript(script); + + it('parses into the rules the webmail stored', () => { + expect(parsed.isOpaque).toBe(false); + const bulwark = parsed.rules.filter((r) => !r.origin || r.origin === 'bulwark'); + expect(bulwark).toEqual(metadataRules(script)); + }); + + it('saves back unchanged', () => { + const regenerated = generateScript(parsed.rules, parsed.vacation, { + externalRequires: parsed.externalRequires, + includeVacation: parsed.includeVacation, + }); + expect(regenerated).toBe(script); + }); + }); + } +}); diff --git a/src/lib/sieve/generator.ts b/src/lib/sieve/generator.ts index e850840b..b22c910e 100644 --- a/src/lib/sieve/generator.ts +++ b/src/lib/sieve/generator.ts @@ -197,8 +197,17 @@ export interface GenerateOptions { * own requires are deduplicated. */ externalRequires?: string[]; + /** + * Run the server-managed "vacation" script (VacationResponse) from this + * one. Only one script can be active, so this keeps the auto-reply working + * while the filters stay active. + */ + includeVacation?: boolean; } +/** Name of the script a server builds for VacationResponse (RFC 9661). */ +export const VACATION_SCRIPT_NAME = 'vacation'; + export function generateScript( rules: FilterRule[], vacation?: VacationSieveConfig, @@ -219,6 +228,9 @@ export function generateScript( if (vacation?.isEnabled) { metadata.vacation = vacation; } + if (options.includeVacation) { + metadata.includeVacation = true; + } const metadataJson = JSON.stringify(metadata); const lines: string[] = []; @@ -228,6 +240,7 @@ export function generateScript( lines.push(''); const bulwarkRequires = computeRequires(bulwarkRules, vacation); + if (options.includeVacation) bulwarkRequires.push('include'); const externalRequires = options.externalRequires ?? []; const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort(); @@ -235,6 +248,14 @@ export function generateScript( lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`); } + if (options.includeVacation) { + // The "# Vacation auto-reply" comment marks the block as Bulwark's, so + // the parser does not re-import it as an external rule. + lines.push(''); + lines.push('# Vacation auto-reply'); + lines.push(`include :personal :optional "${VACATION_SCRIPT_NAME}";`); + } + if (vacation?.isEnabled) { lines.push(''); lines.push('# Vacation auto-reply'); diff --git a/src/lib/sieve/parser.ts b/src/lib/sieve/parser.ts index 3ea615b0..c239afe2 100644 --- a/src/lib/sieve/parser.ts +++ b/src/lib/sieve/parser.ts @@ -18,6 +18,8 @@ export interface ParseResult { isOpaque: boolean; vacation?: VacationSieveConfig; externalRequires: string[]; + /** The script runs the server's "vacation" script via `include`. */ + includeVacation?: boolean; } const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] }; @@ -807,7 +809,7 @@ export function parseScript(content: string): ParseResult { // is treated as 'bulwark' everywhere downstream. const bulwarkRules: FilterRule[] = metadata.rules; - const externalRequires = [ + let externalRequires = [ ...external.externalRequires, ...nextcloud.requires.filter(r => !external.externalRequires.includes(r)), ]; @@ -829,11 +831,20 @@ export function parseScript(content: string): ParseResult { return true; }); + // "include" belongs to the vacation include, which the generator adds + // back on its own; keep it only if an external rule uses it too, so that + // turning the auto-reply off drops it from the script. + const otherRules = [...nextcloud.rules, ...filteredExternal]; + if (!otherRules.some(r => /\binclude\b/.test(r.rawBlock || ''))) { + externalRequires = externalRequires.filter(r => r !== 'include'); + } + return { - rules: [...bulwarkRules, ...nextcloud.rules, ...filteredExternal], + rules: [...bulwarkRules, ...otherRules], isOpaque: false, vacation: metadata.vacation, externalRequires, + ...(metadata.includeVacation === true ? { includeVacation: true } : {}), }; } diff --git a/src/lib/sieve/types.ts b/src/lib/sieve/types.ts index d4409455..f96b7f47 100644 --- a/src/lib/sieve/types.ts +++ b/src/lib/sieve/types.ts @@ -85,4 +85,10 @@ export interface FilterMetadata { version: 1; rules: FilterRule[]; vacation?: VacationSieveConfig; + /** + * The script runs the server-managed "vacation" script via `include`. + * Servers like Stalwart keep one active script, so a VacationResponse + * that activates its own script would otherwise switch the filters off. + */ + includeVacation?: boolean; } diff --git a/src/stores/__tests__/filter-store.test.ts b/src/stores/__tests__/filter-store.test.ts index 5409bd85..f809fa23 100644 --- a/src/stores/__tests__/filter-store.test.ts +++ b/src/stores/__tests__/filter-store.test.ts @@ -11,10 +11,12 @@ vi.mock('../../api/sieve', () => ({ validateSieveScript: vi.fn(async () => ({ isValid: true })), })); +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import * as sieve from '../../api/sieve'; import { generateScript } from '../../lib/sieve/generator'; import type { FilterRule } from '../../lib/sieve/types'; -import { useFilterStore } from '../filter-store'; +import { isVacationIncludedInFilters, syncVacationWithFilters, useFilterStore } from '../filter-store'; const api = vi.mocked(sieve); @@ -36,10 +38,21 @@ function serveScript(rules: FilterRule[]) { api.getSieveScriptContent.mockResolvedValue(generateScript(rules)); } +function webmailFixture(file: string): string { + return readFileSync(join(__dirname, '../../lib/sieve/__tests__/fixtures/webmail', file), 'utf-8') + .replace(/\r\n/g, '\n'); +} + +const WITH_INCLUDE = { + implementation: 'test', maxSizeScript: 100000, sieveExtensions: ['fileinto', 'include'], + notificationMethods: [], externalLists: [], +}; + beforeEach(() => { vi.clearAllMocks(); api.getSieveAccountId.mockReturnValue('own'); api.isSieveSupported.mockReturnValue(true); + api.getSieveCapabilities.mockReturnValue(null); useFilterStore.getState().clearState(); }); @@ -118,3 +131,86 @@ describe('filter-store account selection', () => { expect(api.getSieveScriptContent).not.toHaveBeenCalled(); }); }); + +describe('filter-store and the server vacation script', () => { + it('keeps an active server vacation script by including it', async () => { + api.getSieveCapabilities.mockReturnValue(WITH_INCLUDE); + api.getSieveScripts.mockResolvedValue([ + { id: 's1', name: 'filters', blobId: 'b1', isActive: false }, + { id: 'v1', name: 'vacation', blobId: 'bv', isActive: true }, + ]); + api.getSieveScriptContent.mockResolvedValue(generateScript([makeRule()])); + + await useFilterStore.getState().selectAccount(null); + expect(useFilterStore.getState().activeScriptId).toBe('s1'); + expect(useFilterStore.getState().includeVacation).toBe(true); + + await useFilterStore.getState().saveFilters(); + expect(api.updateSieveScript.mock.calls[0][1]).toContain('include :personal :optional "vacation";'); + }); + + it('saves a webmail script that includes the vacation script unchanged', async () => { + const script = webmailFixture('vacation-include.sieve'); + api.getSieveCapabilities.mockReturnValue(WITH_INCLUDE); + api.getSieveScripts.mockResolvedValue([ + { id: 's1', name: 'filters', blobId: 'b1', isActive: true }, + { id: 'v1', name: 'vacation', blobId: 'bv', isActive: false }, + ]); + api.getSieveScriptContent.mockResolvedValue(script); + + await useFilterStore.getState().selectAccount(null); + await useFilterStore.getState().saveFilters(); + expect(api.updateSieveScript).toHaveBeenCalledWith('s1', script, true, 'own'); + }); + + describe('syncVacationWithFilters', () => { + function serve(vacationActive: boolean, filtersActive: boolean, includeVacation = false) { + api.getSieveCapabilities.mockReturnValue(WITH_INCLUDE); + api.getSieveScripts.mockResolvedValue([ + { id: 's1', name: 'filters', blobId: 'b1', isActive: filtersActive }, + { id: 'v1', name: 'vacation', blobId: 'bv', isActive: vacationActive }, + ]); + api.getSieveScriptContent.mockResolvedValue(generateScript([makeRule()], undefined, { includeVacation })); + } + + it('re-activates the filters with an include when the vacation script took over', async () => { + serve(true, false); + await syncVacationWithFilters(true); + expect(api.updateSieveScript).toHaveBeenCalledTimes(1); + const [id, content, activate, accountId] = api.updateSieveScript.mock.calls[0]; + expect(id).toBe('s1'); + expect(content).toContain('include :personal :optional "vacation";'); + expect(activate).toBe(true); + expect(accountId).toBe('own'); + }); + + it('drops the include when the auto-reply is turned off', async () => { + serve(false, true, true); + await syncVacationWithFilters(false, 'team'); + expect(api.updateSieveScript).toHaveBeenCalledTimes(1); + expect(api.updateSieveScript.mock.calls[0][1]).not.toContain('include'); + expect(api.updateSieveScript.mock.calls[0][3]).toBe('team'); + }); + + it('leaves the scripts alone when the filters are still active', async () => { + serve(false, true); + await syncVacationWithFilters(true); + await syncVacationWithFilters(false); + expect(api.updateSieveScript).not.toHaveBeenCalled(); + }); + + it('does nothing on servers without the include extension', async () => { + serve(true, false); + api.getSieveCapabilities.mockReturnValue(null); + await syncVacationWithFilters(true); + expect(api.updateSieveScript).not.toHaveBeenCalled(); + }); + + it('reports the auto-reply as on while the filters include it', async () => { + serve(false, true, true); + expect(await isVacationIncludedInFilters()).toBe(true); + serve(false, true); + expect(await isVacationIncludedInFilters()).toBe(false); + }); + }); +}); diff --git a/src/stores/__tests__/vacation-store.test.ts b/src/stores/__tests__/vacation-store.test.ts index 43d336e5..94bb2e63 100644 --- a/src/stores/__tests__/vacation-store.test.ts +++ b/src/stores/__tests__/vacation-store.test.ts @@ -6,10 +6,21 @@ vi.mock('../../api/vacation', () => ({ isVacationSupported: vi.fn(() => true), })); +vi.mock('../../api/sieve', () => ({ isSieveSupported: vi.fn(() => false) })); + +vi.mock('../filter-store', () => ({ + isVacationIncludedInFilters: vi.fn(async () => false), + syncVacationWithFilters: vi.fn(async () => undefined), +})); + import * as vacation from '../../api/vacation'; +import * as sieve from '../../api/sieve'; +import * as filters from '../filter-store'; import { useVacationStore } from '../vacation-store'; const api = vi.mocked(vacation); +const sieveApi = vi.mocked(sieve); +const filterStore = vi.mocked(filters); function responder(overrides: Partial = {}): vacation.VacationResponse { return { @@ -27,6 +38,8 @@ function responder(overrides: Partial = {}): vacation beforeEach(() => { vi.clearAllMocks(); api.isVacationSupported.mockReturnValue(true); + sieveApi.isSieveSupported.mockReturnValue(false); + filterStore.isVacationIncludedInFilters.mockResolvedValue(false); useVacationStore.getState().reset(); }); @@ -79,3 +92,43 @@ describe('vacation-store account scoping', () => { expect(useVacationStore.getState()).toMatchObject({ accountId: null, subject: 'Mine' }); }); }); + +describe('vacation-store and the filters script (webmail 198a3c0d)', () => { + it('reports the auto-reply as on while the filters script includes it', async () => { + sieveApi.isSieveSupported.mockReturnValue(true); + filterStore.isVacationIncludedInFilters.mockResolvedValue(true); + api.getVacationResponse.mockResolvedValue(responder({ isEnabled: false, subject: 'Away' })); + + await useVacationStore.getState().fetch('team'); + + expect(filterStore.isVacationIncludedInFilters).toHaveBeenCalledWith('team'); + expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, subject: 'Away' }); + }); + + it('keeps the filters running next to the auto-reply when it is switched', async () => { + sieveApi.isSieveSupported.mockReturnValue(true); + api.getVacationResponse.mockResolvedValue(responder()); + await useVacationStore.getState().fetch(); + + await useVacationStore.getState().save({ isEnabled: true }); + expect(filterStore.syncVacationWithFilters).toHaveBeenCalledWith(true, undefined); + + await useVacationStore.getState().save({ subject: 'Only the subject' }); + expect(filterStore.syncVacationWithFilters).toHaveBeenCalledTimes(1); + }); + + it('still saves the responder when keeping the filters running fails', async () => { + sieveApi.isSieveSupported.mockReturnValue(true); + filterStore.syncVacationWithFilters.mockRejectedValueOnce(new Error('boom')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(useVacationStore.getState().save({ isEnabled: false })).resolves.toBeUndefined(); + expect(useVacationStore.getState()).toMatchObject({ isEnabled: false, isSaving: false, error: null }); + warn.mockRestore(); + }); + + it('leaves the filters alone on servers without Sieve', async () => { + await useVacationStore.getState().save({ isEnabled: true }); + expect(filterStore.syncVacationWithFilters).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stores/filter-store.ts b/src/stores/filter-store.ts index 3031071c..74219a8b 100644 --- a/src/stores/filter-store.ts +++ b/src/stores/filter-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '../lib/sieve/types'; import { parseScript } from '../lib/sieve/parser'; -import { generateScript } from '../lib/sieve/generator'; +import { generateScript, VACATION_SCRIPT_NAME } from '../lib/sieve/generator'; import { createSieveScript, getSieveAccountId, @@ -30,6 +30,8 @@ interface FilterStore { rawScript: string; vacationSettings: VacationSieveConfig | null; externalRequires: string[]; + /** The script runs the server's vacation script via `include`. */ + includeVacation: boolean; selectedAccountId: string | null; fetchFilters: (accountId?: string) => Promise; @@ -60,6 +62,7 @@ export const useFilterStore = create()((set, get) => ({ rawScript: '', vacationSettings: null, externalRequires: [], + includeVacation: false, selectedAccountId: null, fetchFilters: async (accountId) => { @@ -74,18 +77,32 @@ export const useFilterStore = create()((set, get) => ({ // land in the store: the next save would write it into the other account. const stale = () => get().selectedAccountId !== resolvedId; try { - set({ sieveCapabilities: getSieveCapabilities(resolvedId) }); + const capabilities = getSieveCapabilities(resolvedId); + set({ sieveCapabilities: capabilities }); const allScripts = await getSieveScripts(resolvedId); if (stale()) return; // Skip the server-managed 'vacation' script (RFC 9661 ยง4) - it can only // be modified via VacationResponse/set, not SieveScript/set. - const scripts = allScripts.filter((s) => s.name !== 'vacation'); + const scripts = allScripts.filter((s) => s.name !== VACATION_SCRIPT_NAME); + + // Saving activates the filters script, which switches off an active + // server vacation script. Include it instead so both keep working. + const vacationActive = + allScripts.some((s) => s.name === VACATION_SCRIPT_NAME && s.isActive) && + supportsInclude(capabilities); const activeScript = scripts.find((s) => s.isActive) || scripts[0]; if (!activeScript) { - set({ isLoading: false, rules: [], activeScriptId: null, rawScript: '', isOpaque: false }); + set({ + isLoading: false, + rules: [], + activeScriptId: null, + rawScript: '', + isOpaque: false, + includeVacation: vacationActive, + }); return; } @@ -103,6 +120,7 @@ export const useFilterStore = create()((set, get) => ({ rules: result.isOpaque ? [] : result.rules, vacationSettings: result.vacation || null, externalRequires: result.externalRequires, + includeVacation: !result.isOpaque && (!!result.includeVacation || vacationActive), }); } catch (error) { if (stale()) return; @@ -124,6 +142,7 @@ export const useFilterStore = create()((set, get) => ({ isOpaque: false, vacationSettings: null, externalRequires: [], + includeVacation: false, }); await get().fetchFilters(accountId ?? undefined); }, @@ -132,13 +151,14 @@ export const useFilterStore = create()((set, get) => ({ set({ isSaving: true, error: null }); try { const { - isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, selectedAccountId, + isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, includeVacation, + selectedAccountId, } = get(); const accountId = selectedAccountId ?? undefined; const content = isOpaque ? rawScript - : generateScript(rules, vacationSettings || undefined, { externalRequires }); + : generateScript(rules, vacationSettings || undefined, { externalRequires, includeVacation }); if (activeScriptId) { await updateSieveScript(activeScriptId, content, true, accountId); @@ -232,6 +252,66 @@ export const useFilterStore = create()((set, get) => ({ rawScript: '', vacationSettings: null, externalRequires: [], + includeVacation: false, selectedAccountId: null, }), })); + +function supportsInclude(capabilities: SieveCapabilities | null): boolean { + return capabilities?.sieveExtensions?.includes('include') ?? false; +} + +async function loadManagedScript(accountId: string) { + const scripts = await getSieveScripts(accountId); + const vacationScript = scripts.find((s) => s.name === VACATION_SCRIPT_NAME); + const filters = scripts.filter((s) => s.name !== VACATION_SCRIPT_NAME); + const target = filters.find((s) => s.isActive) || filters[0]; + if (!target) return { vacationScript, target: undefined, parsed: undefined }; + const parsed = parseScript(await getSieveScriptContent(target.blobId, accountId)); + return { vacationScript, target, parsed: parsed.isOpaque ? undefined : parsed }; +} + +/** + * Whether the account's filters script runs the server's vacation script. + * VacationResponse.isEnabled reads false in that case, because the vacation + * script itself is not the active one. + */ +export async function isVacationIncludedInFilters(accountId?: string): Promise { + const { vacationScript, target, parsed } = await loadManagedScript(accountId ?? getSieveAccountId()); + return !!(vacationScript && target?.isActive && parsed?.includeVacation); +} + +/** + * Keep the filters and the auto-reply both running after VacationResponse/set + * (webmail 198a3c0d). + * + * Stalwart allows one active Sieve script and turns the auto-reply on by + * activating its own "vacation" script, which switches every filter off. + * When that happened, re-activate the filters script with an `include` of + * the vacation script. When the auto-reply is turned off, drop the include. + */ +export async function syncVacationWithFilters(enabled: boolean, accountId?: string): Promise { + const sieveAccountId = accountId ?? getSieveAccountId(); + if (enabled && !supportsInclude(getSieveCapabilities(sieveAccountId))) return; + + const { vacationScript, target, parsed } = await loadManagedScript(sieveAccountId); + if (!target || !parsed) return; + + if (enabled) { + // Only act when the vacation script took over from existing filters. + if (!vacationScript?.isActive || target.isActive || parsed.rules.length === 0) return; + } else if (!parsed.includeVacation) { + return; + } + + const content = generateScript(parsed.rules, parsed.vacation, { + externalRequires: parsed.externalRequires, + includeVacation: enabled, + }); + await updateSieveScript(target.id, content, enabled || target.isActive, sieveAccountId); + + const store = useFilterStore.getState(); + if (store.selectedAccountId === sieveAccountId) { + await store.fetchFilters(sieveAccountId); + } +} diff --git a/src/stores/vacation-store.ts b/src/stores/vacation-store.ts index 0a476fc4..50ff2eb0 100644 --- a/src/stores/vacation-store.ts +++ b/src/stores/vacation-store.ts @@ -5,6 +5,8 @@ import { isVacationSupported, type VacationResponse, } from '../api/vacation'; +import { isSieveSupported } from '../api/sieve'; +import { isVacationIncludedInFilters, syncVacationWithFilters } from './filter-store'; export interface VacationState { isEnabled: boolean; @@ -55,8 +57,15 @@ export const useVacationStore = create((set, get) => ({ try { const vacation = await getVacationResponse(accountId); if (stale()) return; + let isEnabled = vacation.isEnabled; + if (!isEnabled && isSieveSupported(accountId)) { + // Running from the filters script leaves the vacation script itself + // inactive, so VacationResponse reports it as off. + isEnabled = await isVacationIncludedInFilters(accountId).catch(() => false); + if (stale()) return; + } set({ - isEnabled: vacation.isEnabled, + isEnabled, fromDate: vacation.fromDate, toDate: vacation.toDate, subject: vacation.subject ?? '', @@ -78,7 +87,15 @@ export const useVacationStore = create((set, get) => ({ save: async (updates) => { set({ isSaving: true, error: null }); try { - await setVacationResponse(updates, get().accountId ?? undefined); + const accountId = get().accountId ?? undefined; + await setVacationResponse(updates, accountId); + if (updates.isEnabled !== undefined && isSieveSupported(accountId)) { + try { + await syncVacationWithFilters(updates.isEnabled, accountId); + } catch (err) { + console.warn('[vacation] Failed to keep filters active next to the vacation response:', err); + } + } set((s) => ({ ...s, ...updates,