fix: keep the auto-reply running when filters are saved on the phone

Stalwart runs one active Sieve script and turns the auto-reply on by
activating its own `vacation` script. The webmail therefore re-activates
the filters script with `include :personal :optional "vacation"` and
records `includeVacation` in the rules metadata (198a3c0d). The phone
dropped both, so saving any filter on the phone silently switched the
auto-reply off, and turning the auto-reply on from the phone switched
every filter off.

Port the webmail's handling:
- the generator writes the include (and `require "include"`) and the
  parser reads the flag back, dropping the `include` require once the
  auto-reply is off;
- loading filters while the vacation script is active keeps it through
  the include, as does every save;
- switching the auto-reply on or off in the vacation settings adds or
  removes the include, and the settings read the include as "on",
  because VacationResponse reports it off in that state;
- the "vacation active" banner also shows for an include.

Scripts the webmail wrote with the include now save back unchanged;
`fixtures/webmail/` holds scripts from its current generator.
This commit is contained in:
Linus Rath
2026-09-24 19:29:36 +02:00
parent 4fdf39aa08
commit d53bfec68a
11 changed files with 393 additions and 13 deletions
+2 -2
View File
@@ -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(
@@ -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;
}
+21
View File
@@ -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' })]);
@@ -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);
});
});
}
});
+21
View File
@@ -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');
+13 -2
View File
@@ -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 } : {}),
};
}
+6
View File
@@ -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;
}
+97 -1
View File
@@ -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);
});
});
});
@@ -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> = {}): vacation.VacationResponse {
return {
@@ -27,6 +38,8 @@ function responder(overrides: Partial<vacation.VacationResponse> = {}): 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();
});
});
+86 -6
View File
@@ -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<void>;
@@ -60,6 +62,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
rawScript: '',
vacationSettings: null,
externalRequires: [],
includeVacation: false,
selectedAccountId: null,
fetchFilters: async (accountId) => {
@@ -74,18 +77,32 @@ export const useFilterStore = create<FilterStore>()((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<FilterStore>()((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<FilterStore>()((set, get) => ({
isOpaque: false,
vacationSettings: null,
externalRequires: [],
includeVacation: false,
});
await get().fetchFilters(accountId ?? undefined);
},
@@ -132,13 +151,14 @@ export const useFilterStore = create<FilterStore>()((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<FilterStore>()((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<boolean> {
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<void> {
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);
}
}
+19 -2
View File
@@ -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<VacationState>((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<VacationState>((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,