mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user