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