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
+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,