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
@@ -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;
}