mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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