fix: keep webmail filter periods, forwarding and exact address rules through a phone save #1152

This commit is contained in:
Linus Rath
2026-10-29 02:57:00 +01:00
parent 388f46b5b9
commit 2167a9d700
16 changed files with 968 additions and 73 deletions
@@ -0,0 +1,32 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"anna","name":"Anna exactly","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"address_is","value":"anna@acme.com"},{"field":"cc","comparator":"address_is","value":["anna@acme.com","a.smith@acme.com"]}],"actions":[{"type":"star"}],"stopProcessing":false},{"id":"acme","name":"Acme domain","enabled":true,"matchType":"all","conditions":[{"field":"to","comparator":"domain_is","value":"acme.com"}],"actions":[{"type":"add_label","value":"acme"}],"stopProcessing":false},{"id":"drop","name":"Drop spam sender","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"domain_is","value":"spam.example"}],"actions":[{"type":"discard"}],"stopProcessing":true},{"id":"bounce","name":"Bounce","enabled":true,"matchType":"all","conditions":[{"field":"subject","comparator":"is","value":"win money"}],"actions":[{"type":"reject","value":"No thanks"}],"stopProcessing":true},{"id":"every","name":"Everything else","enabled":true,"matchType":"all","conditions":[{"field":"all","comparator":"any","value":""}],"actions":[{"type":"add_label","value":"seen-by-filter"}],"stopProcessing":false}]}
@metadata:end */
require ["imap4flags", "reject"];
# Rule: Anna exactly
if anyof(address :is "From" "anna@acme.com", address :is "Cc" ["anna@acme.com", "a.smith@acme.com"]) {
addflag "\\Flagged";
}
# Rule: Acme domain
if address :domain :is "To" "acme.com" {
addflag "$label:acme";
}
# Rule: Drop spam sender
if address :domain :is "From" "spam.example" {
discard;
stop;
}
# Rule: Bounce
if header :is "Subject" "win money" {
reject "No thanks";
stop;
}
# Rule: Everything else
if true {
addflag "$label:seen-by-filter";
}
@@ -0,0 +1,13 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"news","name":"News","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@"}],"actions":[{"type":"mark_read"}],"stopProcessing":false}],"includeVacation":true,"vacationForward":{"enabled":false,"to":"backup@example.org","keepCopy":true}}
@metadata:end */
require ["imap4flags", "include"];
# Vacation auto-reply
include :personal :optional "vacation";
# Rule: News
if header :contains "From" "news@" {
addflag "\\Seen";
}
@@ -1,7 +1,8 @@
// Sieve scripts written by the webmail's own generator (jmap-webmail
// lib/sieve/generator.ts at e33ab899), used to check that a save on the
// phone gives back the same script. Each one was generated from the rules in
// its metadata for a server listing `extensions`.
// lib/sieve/generator.ts at e33ab899; the version 2, address comparator and
// forwarding ones at 4aea0976), used to check that a save on the phone gives
// back the same script. Each one was generated from the rules in its metadata
// for a server listing `extensions`.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -28,6 +29,14 @@ export const WEBMAIL_FIXTURES: WebmailFixture[] = [
},
// Everything at once, plus a hand-written rule the webmail kept.
{ file: 'stalwart-full.sieve', extensions: STALWART_EXTENSIONS },
// Version 2 (webmail 1.13+): rule periods, forwarding from the vacation
// card in the vacation's period, the auto-reply for colleagues only.
{ file: 'period-forward-audience.sieve', extensions: STALWART_EXTENSIONS },
// Version 1 with the vacation forwarding switched off but remembered.
{ file: 'forward-off.sieve', extensions: STALWART_EXTENSIONS },
// "is the address" / "has the domain", "all messages", stop after discard
// and reject, names with runs of spaces.
{ file: 'address-comparators.sieve', extensions: STALWART_EXTENSIONS },
];
export function readWebmailFixture(file: string): string {
@@ -37,8 +46,9 @@ export function readWebmailFixture(file: string): string {
/**
* What the webmail itself writes when it saves the fixture again. That is the
* fixture unchanged, except that both clients add two blank lines in front of
* a script's external rules on every save (`<name>.resaved.sieve`).
* fixture unchanged, except that a script written before 4aea0976 had extra
* blank lines in front of its external rules, which a save now trims
* (`<name>.resaved.sieve`).
*/
export function readWebmailResave(file: string): string {
const resaved = file.replace(/\.sieve$/, '.resaved.sieve');
@@ -0,0 +1,27 @@
/* @metadata:begin
{"version":2,"rules":[{"id":"trip","name":"While travelling","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"domain_is","value":"client.example"}],"actions":[{"type":"forward","value":"deputy@example.org","keepCopy":true}],"stopProcessing":false,"activeFrom":"2026-11-02T07:00:00.000Z","activeUntil":"2026-11-13T17:30:00.000Z"},{"id":"later","name":"From December","enabled":true,"matchType":"all","conditions":[{"field":"subject","comparator":"contains","value":"report"}],"actions":[{"type":"move","value":"Reports","mailboxId":"m7"}],"stopProcessing":true,"activeFrom":"2026-12-01T00:00:00.000Z"}],"includeVacation":true,"vacationForward":{"enabled":true,"to":"backup@example.org","keepCopy":false,"activeFrom":"2026-11-02T00:00:00.000Z","activeUntil":"2026-11-14T00:00:00.000Z"},"vacationAudience":{"only":"internal","domains":["example.org","example.com"]}}
@metadata:end */
require ["comparator-i;ascii-numeric", "copy", "date", "envelope", "fileinto", "include", "mailbox", "mailboxid", "relational", "spamtestplus"];
# Vacation auto-reply
if envelope :domain :is "from" ["example.org", "example.com"] {
include :personal :optional "vacation";
}
# Vacation forwarding
if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-11-02", allof(currentdate :zone "+0000" :is "date" "2026-11-02", currentdate :zone "+0000" :value "ge" "time" "00:00:00")), anyof(currentdate :zone "+0000" :value "lt" "date" "2026-11-14", allof(currentdate :zone "+0000" :is "date" "2026-11-14", currentdate :zone "+0000" :value "le" "time" "00:00:00")), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {
redirect "backup@example.org";
stop;
}
# Rule: While travelling
if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-11-02", allof(currentdate :zone "+0000" :is "date" "2026-11-02", currentdate :zone "+0000" :value "ge" "time" "07:00:00")), anyof(currentdate :zone "+0000" :value "lt" "date" "2026-11-13", allof(currentdate :zone "+0000" :is "date" "2026-11-13", currentdate :zone "+0000" :value "le" "time" "17:30:00")), address :domain :is "From" "client.example") {
redirect :copy "deputy@example.org";
}
# Rule: From December
if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-12-01", allof(currentdate :zone "+0000" :is "date" "2026-12-01", currentdate :zone "+0000" :value "ge" "time" "00:00:00")), allof(header :contains "Subject" "report", not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50")) {
fileinto :mailboxid "m7" "Reports";
stop;
}
@@ -36,10 +36,6 @@ if size :over 10000000 {
}
# --- External rules (managed outside Bulwark) ---
# Hand-written rule
if header :contains "X-Spam-Flag" "YES" {
fileinto "Junk";
+80 -6
View File
@@ -241,25 +241,99 @@ describe('generateScript', () => {
expect(matches).toHaveLength(1);
});
it('does not append stop after discard', () => {
// discard and reject only cancel the implicit keep; the rules below
// would still file, flag or forward the message (#1153).
it('appends stop after discard', () => {
const script = generateScript([makeRule({
actions: [{ type: 'discard' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
expect(script).toContain(' discard;\n stop;\n}');
});
it('does not append stop after reject', () => {
it('appends stop after reject', () => {
const script = generateScript([makeRule({
actions: [{ type: 'reject', value: 'No' }],
stopProcessing: true,
})]);
const matches = script.match(/stop;/g);
expect(matches).toBeNull();
expect(script).toContain(' reject "No";\n stop;\n}');
});
});
describe('address comparators', () => {
it('writes "is the address" as an address test', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
})]);
expect(script).toContain('if address :is "From" "anna@acme.com" {');
expect(script).not.toContain('header :contains');
});
it('writes "has the domain" as an address :domain test', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'cc', comparator: 'domain_is', value: ['acme.com', 'acme.org'] }],
})]);
expect(script).toContain('if address :domain :is "Cc" ["acme.com", "acme.org"] {');
});
it('reads both back as the same comparators', () => {
const rules = [
makeRule({ id: 'a', name: 'A', conditions: [{ field: 'to', comparator: 'address_is', value: 'a@b.c' }] }),
makeRule({ id: 'b', name: 'B', conditions: [{ field: 'from', comparator: 'domain_is', value: 'b.c' }] }),
];
expect(parseScript(generateScript(rules)).rules).toEqual(rules);
});
it('refuses a comparator it does not know instead of broadening it', () => {
const rule = makeRule({
conditions: [{ field: 'from', comparator: 'sounds_like' as never, value: 'anna' }],
});
expect(() => generateScript([rule])).toThrow(/sounds_like/);
});
it('refuses an address comparator on the subject', () => {
const rule = makeRule({
conditions: [{ field: 'subject', comparator: 'address_is', value: 'x' }],
});
expect(() => generateScript([rule])).toThrow();
});
});
// #1154: a rule for every message is Sieve `true`.
it('writes the "all messages" condition as true', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'all', comparator: 'any', value: '' }],
actions: [{ type: 'add_label', value: 'x' }],
})]);
expect(script).toContain('if true {');
});
it('confines a rule with a period to it', () => {
const script = generateScript([makeRule({ activeFrom: '2026-11-02T07:00:00.000Z' })]);
expect(script).toContain('{"version":2,');
expect(script).toMatch(/require \[[^\]]*"date"[^\]]*"relational"/);
expect(script).toContain('if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-11-02"');
});
it('runs the vacation forwarding ahead of the rules', () => {
const script = generateScript([makeRule()], undefined, {
includeVacation: true,
vacationForward: { enabled: true, to: 'backup@example.org', keepCopy: false },
});
const forward = script.indexOf('# Vacation forwarding\nif true {\n redirect "backup@example.org";\n stop;\n}');
expect(forward).toBeGreaterThan(0);
expect(forward).toBeLessThan(script.indexOf('# Rule: Test Rule'));
});
it('includes the auto-reply for colleagues only', () => {
const script = generateScript([], undefined, {
includeVacation: true,
vacationAudience: { only: 'internal', domains: ['example.org'] },
});
expect(script).toContain('if envelope :domain :is "from" ["example.org"] {\n include :personal :optional "vacation";\n}');
expect(script).toMatch(/require \[[^\]]*"envelope"/);
});
describe('action order and targets', () => {
it('sets flags before the move so the moved message keeps them', () => {
const script = generateScript([makeRule({
+44 -2
View File
@@ -42,13 +42,55 @@ describe('parseScript', () => {
expect(result.rules).toEqual([]);
});
it('returns isOpaque for version mismatch', () => {
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
it('returns isOpaque for a metadata version it does not know', () => {
const script = '/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
});
// Webmail 1.13+ writes version 2 once a rule has a period or the vacation
// card forwards or narrows its recipients (#1152).
it('reads version 2 metadata as its own', () => {
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
});
it('keeps a rule period and the vacation forwarding and audience through a save', () => {
const rule = makeRule({ activeFrom: '2026-11-02T07:00:00.000Z', activeUntil: '2026-11-13T17:30:00.000Z' });
const vacationForward = { enabled: true, to: 'backup@example.org', keepCopy: true, activeUntil: '2026-11-14T00:00:00.000Z' };
const vacationAudience = { only: 'external' as const, domains: ['example.org'] };
const script = generateScript([rule], undefined, { includeVacation: true, vacationForward, vacationAudience });
expect(script).toContain('{"version":2,');
const parsed = parseScript(script);
expect(parsed.isOpaque).toBe(false);
expect(parsed.rules[0]).toMatchObject({ activeFrom: rule.activeFrom, activeUntil: rule.activeUntil });
expect(parsed.vacationForward).toEqual(vacationForward);
expect(parsed.vacationAudience).toEqual(vacationAudience);
expect(generateScript(parsed.rules, parsed.vacation, {
includeVacation: parsed.includeVacation,
vacationForward: parsed.vacationForward,
vacationAudience: parsed.vacationAudience,
})).toBe(script);
});
it('treats a script with an unreadable rule period as hand-edited', () => {
const script = '/* @metadata:begin\n' + JSON.stringify({ version: 2, rules: [makeRule({ activeFrom: 'next monday' })] }) + '\n@metadata:end */';
expect(parseScript(script).isOpaque).toBe(true);
});
// #1153: the comment collapses the run of spaces; compared as written the
// rule came back as someone else's, one more copy on every save.
it('matches a rule whose name has a run of spaces to its comment', () => {
const script = generateScript([makeRule({ name: 'Foo Bar\tBaz' })]);
expect(script).toContain('# Rule: Foo Bar Baz');
const parsed = parseScript(script);
expect(parsed.rules).toHaveLength(1);
expect(parsed.rules[0].origin).toBeUndefined();
});
it('returns isOpaque for empty metadata block', () => {
const script = '/* @metadata:begin\n\n@metadata:end */';
const result = parseScript(script);
@@ -9,9 +9,13 @@ import { generateScript } from '../generator';
import { parseScript } from '../parser';
import { readWebmailFixture, readWebmailResave, WEBMAIL_FIXTURES } from './fixtures/webmail';
function metadataRules(script: string): unknown[] {
function metadata(script: string): Record<string, unknown> {
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
return JSON.parse(match![1]).rules;
return JSON.parse(match![1]);
}
function metadataRules(script: string): unknown[] {
return metadata(script).rules as unknown[];
}
// Blank lines carry no meaning in Sieve.
@@ -27,6 +31,8 @@ describe('scripts written by the webmail', () => {
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
includeVacation: parsed.includeVacation,
vacationForward: parsed.vacationForward,
vacationAudience: parsed.vacationAudience,
extensions,
});
@@ -36,6 +42,13 @@ describe('scripts written by the webmail', () => {
expect(bulwark).toEqual(metadataRules(script));
});
it('keeps the vacation forwarding and audience the webmail stored', () => {
const stored = metadata(script);
expect(parsed.vacationForward).toEqual(stored.vacationForward);
expect(parsed.vacationAudience).toEqual(stored.vacationAudience);
expect(metadata(regenerated)).toEqual(stored);
});
it('saves back what the webmail would save', () => {
expect(regenerated).toBe(readWebmailResave(file));
});
+146 -21
View File
@@ -3,8 +3,13 @@ import type {
FilterCondition,
FilterAction,
FilterMetadata,
VacationAudience,
VacationForward,
VacationSieveConfig,
} from './types';
import { PERIOD_REQUIRES, hasPeriod, periodTests } from './period';
import { VACATION_FORWARD_MARKER, isValidVacationForward, normalizeVacationForward } from './vacation-forward';
import { isValidVacationAudience, normalizeVacationAudience } from './vacation-audience';
// Ported from the webmail's lib/sieve/generator.ts. Emits an RFC 5228 Sieve
// script with a leading `@metadata` JSON comment that lets the parser round-trip
@@ -18,6 +23,9 @@ const HEADER_MAP: Record<string, string> = {
subject: 'Subject',
};
/** Fields whose header holds addresses, so the `address` test applies. */
const ADDRESS_FIELDS = new Set<FilterCondition['field']>(['from', 'to', 'cc']);
function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
@@ -34,10 +42,11 @@ export function isValidSieveHeaderName(name: string): boolean {
/**
* A rule's name as it appears in its `# Rule:` comment, which ends at the
* line break: a CR or LF in the name would have turned the rest of it into
* script. The parser matches the comment back to the rule by this form.
* script. Every whitespace run becomes one space, as the webmail writes it;
* the parser matches the comment back to the rule by this form.
*/
export function ruleCommentName(name: string): string {
return name.replace(/[\r\n]+/g, ' ').trim();
return name.replace(/\s+/g, ' ');
}
/** A `header` condition whose name the server would refuse. */
@@ -66,9 +75,15 @@ function formatStringArg(values: string[], transform: (s: string) => string = (s
function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition;
// Every message: there is nothing to compare.
if (field === 'all') return 'true';
if (field === 'size') {
// Size is numeric, single value only.
const sizeValue = Array.isArray(value) ? value[0] : value;
// Size is numeric, single value only. It is written unquoted, so
// anything but a number (with an optional K/M/G quantifier) would be
// Sieve source; fall back to 0.
const raw = String((Array.isArray(value) ? value[0] : value) ?? '').trim();
const sizeValue = /^\d+[KMG]?$/i.test(raw) ? raw : '0';
const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${sizeValue}`;
}
@@ -100,7 +115,7 @@ function generateCondition(condition: FilterCondition): string {
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
}
const headerName = field === 'header'
const rawHeaderName = field === 'header'
? (condition.headerName?.trim() || 'X-Unknown')
: HEADER_MAP[field];
@@ -108,9 +123,22 @@ function generateCondition(condition: FilterCondition): string {
// must never be flattened into `header :contains "undefined"`: that would
// silently rewrite the user's rule server-side on the next save. Fail the
// save instead so the script stays untouched.
if (!headerName) {
if (!rawHeaderName) {
throw new Error(`Unsupported filter condition field: ${String(field)}`);
}
const headerName = escapeString(rawHeaderName);
// RFC 5228 §5.1: `address` compares the parsed address, so the display
// name and the angle brackets play no part. `header :contains` would let
// "anna@acme.com" match joanna@acme.com, and `header :matches "*@acme.com"`
// never matches "Anna <anna@acme.com>" because of the closing ">".
if (comparator === 'address_is' || comparator === 'domain_is') {
if (!ADDRESS_FIELDS.has(field)) {
throw new Error(`Unsupported filter comparator for ${String(field)}: ${comparator}`);
}
const part = comparator === 'domain_is' ? ':domain ' : '';
return `address ${part}:is "${headerName}" ${formatStringArg(values)}`;
}
switch (comparator) {
case 'contains':
@@ -128,7 +156,10 @@ function generateCondition(condition: FilterCondition): string {
case 'matches':
return `header :matches "${headerName}" ${formatStringArg(values)}`;
default:
return `header :contains "${headerName}" ${formatStringArg(values)}`;
// As with an unknown field: a comparator from a newer client written
// as `header :contains` would quietly broaden the rule ("is the
// address" catching every address that merely contains it).
throw new Error(`Unsupported filter comparator: ${String(comparator)}`);
}
}
@@ -176,6 +207,8 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
return 'fileinto "INBOX";';
case 'stop':
return 'stop;';
default:
throw new Error(`Unsupported filter action: ${String((action as FilterAction).type)}`);
}
});
}
@@ -185,7 +218,7 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric'];
function supportsSpamGuard(extensions: string[] | undefined): boolean {
export function supportsSpamGuard(extensions: string[] | undefined): boolean {
return ['spamtestplus', 'relational'].every(e => extensions?.includes(e));
}
@@ -217,6 +250,9 @@ function computeRequires(
if (needsSpamGuard(rule, serverExtensions)) {
for (const e of SPAM_GUARD_REQUIRES) extensions.add(e);
}
if (periodTests(rule)?.length) {
for (const e of PERIOD_REQUIRES) extensions.add(e);
}
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
if (condition.field === 'attachment') extensions.add('mime');
@@ -264,6 +300,8 @@ function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'origi
actions: r.actions,
stopProcessing: r.stopProcessing,
...(r.includeSpam ? { includeSpam: true } : {}),
...(r.activeFrom !== undefined ? { activeFrom: r.activeFrom } : {}),
...(r.activeUntil !== undefined ? { activeUntil: r.activeUntil } : {}),
};
}
@@ -280,6 +318,16 @@ export interface GenerateOptions {
* while the filters stay active.
*/
includeVacation?: boolean;
/**
* Forwarding from the vacation card. Always kept in the metadata; it runs
* while it is switched on, with or without the auto-reply.
*/
vacationForward?: VacationForward | null;
/**
* Who gets the auto-reply when not everyone should. Always kept in the
* metadata; it narrows the vacation include (`includeVacation`).
*/
vacationAudience?: VacationAudience | null;
/**
* The server's `sieveExtensions`. Folder moves use `:mailboxid` when it
* lists "mailboxid"; without it they target the folder path only.
@@ -313,6 +361,28 @@ export function generateScript(
if (options.includeVacation) {
metadata.includeVacation = true;
}
if (options.vacationForward) {
if (isValidVacationForward(options.vacationForward)) {
metadata.vacationForward = normalizeVacationForward(options.vacationForward);
} else {
console.warn('[filters] Dropping unusable vacation forwarding');
}
}
if (options.vacationAudience) {
if (isValidVacationAudience(options.vacationAudience)) {
metadata.vacationAudience = normalizeVacationAudience(options.vacationAudience);
} else {
console.warn('[filters] Dropping unusable vacation audience');
}
}
// A build that only knows version 1 would take such a script as its own and
// write it back without what it does not know: a rule without its period
// forwards for good, the auto-reply goes to everyone. It leaves version 2
// alone. A forward that is off only loses its remembered address that way,
// so it stays version 1, as the webmail writes it.
if (metadata.rules.some(hasPeriod) || metadata.vacationForward?.enabled || metadata.vacationAudience) {
metadata.version = 2;
}
// The rules ride in a `/* */` comment, which the first `*/` ends: one in a
// rule name or value cut the metadata short and turned the rest into
// script. JSON reads `\/` back as `/`, so every client parses it as before.
@@ -327,6 +397,19 @@ export function generateScript(
const useMailboxId = options.extensions?.includes('mailboxid') ?? false;
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
if (options.includeVacation) bulwarkRequires.push('include');
if (options.includeVacation && metadata.vacationAudience) bulwarkRequires.push('envelope');
// Forwarding from the vacation card runs whenever it is switched on, with
// or without the auto-reply, ahead of every rule. A period it cannot read
// would let it forward for good, so then it does not run.
const forward = metadata.vacationForward?.enabled ? metadata.vacationForward : null;
const forwardPeriod = forward ? periodTests(forward) : null;
const forwardSpamGuard = supportsSpamGuard(options.extensions);
if (forward && forwardPeriod) {
if (forward.keepCopy) bulwarkRequires.push('copy');
if (forwardPeriod.length > 0) bulwarkRequires.push(...PERIOD_REQUIRES);
if (forwardSpamGuard) bulwarkRequires.push(...SPAM_GUARD_REQUIRES);
}
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
@@ -339,7 +422,20 @@ export function generateScript(
// 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}";`);
const include = `include :personal :optional "${VACATION_SCRIPT_NAME}";`;
const audience = metadata.vacationAudience;
if (audience) {
// Only senders from (or not from) the account's own domains. The reply
// goes to the envelope sender, so that is the address to judge: a From
// header can name a colleague on mail from anywhere, and the reply
// meant for colleagues would go to whoever sent it.
const domains = audience.domains.map(d => `"${escapeString(d)}"`).join(', ');
lines.push(`if ${audience.only === 'external' ? 'not ' : ''}envelope :domain :is "from" [${domains}] {`);
lines.push(` ${include}`);
lines.push('}');
} else {
lines.push(include);
}
}
if (vacation?.isEnabled) {
@@ -353,6 +449,19 @@ export function generateScript(
lines.push(`vacation ${vacationParts.join(' ')};`);
}
if (forward && forwardPeriod) {
// Spam is not passed on: it stays here for the server's own handling.
const tests = [...forwardPeriod, ...(forwardSpamGuard ? [SPAM_GUARD] : [])];
const condition = tests.length === 0 ? 'true' : tests.length === 1 ? tests[0] : `allof(${tests.join(', ')})`;
lines.push('');
lines.push(VACATION_FORWARD_MARKER);
lines.push(`if ${condition} {`);
lines.push(` redirect ${forward.keepCopy ? ':copy ' : ''}"${escapeString(forward.to)}";`);
// Without a copy kept here, no rule below may file it either.
if (!forward.keepCopy) lines.push(' stop;');
lines.push('}');
}
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
for (const rule of enabledBulwarkRules) {
@@ -367,6 +476,13 @@ export function generateScript(
console.warn(`[filters] Skipping rule "${rule.name}": a header name the server would refuse`);
continue;
}
// Without its period a rule would act all the time - a forwarding rule
// would forward for good - so one whose period is unusable is dropped.
const period = periodTests(rule);
if (period === null) {
console.warn(`[filters] Skipping rule "${rule.name}": unusable period`);
continue;
}
lines.push('');
lines.push(`# Rule: ${ruleCommentName(rule.name)}`);
@@ -389,13 +505,20 @@ export function generateScript(
: `allof(${conditionStr}, ${SPAM_GUARD})`;
}
// The period comes first: outside it, nothing else is evaluated.
if (period.length > 0) {
conditionStr = `allof(${[...period, conditionStr].join(', ')})`;
}
const actionLines = generateActions(rule.actions, useMailboxId);
if (rule.stopProcessing) {
const lastAction = rule.actions[rule.actions.length - 1];
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
actionLines.push('stop;');
}
// discard and reject only cancel the implicit keep (RFC 5228 4.4,
// RFC 5429); the script goes on, and the rules below would still act on
// the message. So "stop processing" always writes a stop, unless the
// block has one already: it runs straight through, so any stop in it ends
// the script.
if (rule.stopProcessing && !actionLines.includes('stop;')) {
actionLines.push('stop;');
}
lines.push(`if ${conditionStr} {`);
@@ -405,15 +528,17 @@ export function generateScript(
lines.push('}');
}
// Append preserved external rules verbatim. Each rawBlock already carries its
// own leading comments and trailing whitespace from the source script.
if (externalRules.length > 0) {
// Append preserved external rules verbatim. Each rawBlock carries its own
// leading comments, but also the blank lines around it in the source
// script; trim those and put exactly one blank line between blocks, so a
// save writes the same script as the one before it.
const externalBlocks = externalRules
.map((ext) => (ext.rawBlock ?? '').trim())
.filter((block) => block.length > 0);
if (externalBlocks.length > 0) {
lines.push('');
lines.push('# --- External rules (managed outside Bulwark) ---');
for (const ext of externalRules) {
if (!ext.rawBlock) continue;
lines.push(ext.rawBlock.replace(/\s+$/, ''));
}
lines.push(externalBlocks.join('\n\n'));
}
lines.push('');
+86 -14
View File
@@ -5,15 +5,19 @@ import type {
FilterConditionField,
FilterMetadata,
FilterRule,
VacationAudience,
VacationForward,
VacationSieveConfig,
} from './types';
import { ruleCommentName } from './generator';
import { isPeriodBoundary } from './period';
import { VACATION_FORWARD_MARKER_RE, isValidVacationForward } from './vacation-forward';
import { isValidVacationAudience } from './vacation-audience';
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
// are best-effort parsed into read-only "external" rules or preserved opaquely.
export interface ParseResult {
rules: FilterRule[];
isOpaque: boolean;
@@ -21,6 +25,10 @@ export interface ParseResult {
externalRequires: string[];
/** The script runs the server's "vacation" script via `include`. */
includeVacation?: boolean;
/** Forwarding from the vacation card, on or off. */
vacationForward?: VacationForward;
/** Who gets the auto-reply, when not everyone. */
vacationAudience?: VacationAudience;
}
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
@@ -69,6 +77,12 @@ function isValidRule(rule: unknown): rule is FilterRule {
typeof r.stopProcessing !== 'boolean'
) return false;
// A period that cannot be read would be dropped on the next save and the
// rule would act all the time; treating the script as hand-edited leaves
// it untouched instead.
if (r.activeFrom !== undefined && !isPeriodBoundary(r.activeFrom)) return false;
if (r.activeUntil !== undefined && !isPeriodBoundary(r.activeUntil)) return false;
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
}
@@ -134,6 +148,19 @@ function skipBlockComment(s: string, i: number): number {
return end === -1 ? s.length : end + 2;
}
// Past a `text:` string: every line up to and including the lone ".".
function skipMultiLineString(s: string, i: number): number {
let line = s.indexOf('\n', i) + 1;
if (line === 0) return s.length;
while (line < s.length) {
const nl = s.indexOf('\n', line);
const next = nl === -1 ? s.length : nl + 1;
if (s.slice(line, next).replace(/\r?\n$/, '') === '.') return next;
line = next;
}
return s.length;
}
function skipStatement(s: string, i: number): number {
while (i < s.length) {
const c = s[i];
@@ -458,6 +485,22 @@ function parseAtom(raw: string): FilterCondition | null {
return null;
}
// `address [:all|:domain] :is` on From/To/Cc, as the generator writes the
// address_is / domain_is comparators. Sieve takes tagged arguments in any
// order, so the address part may also follow `:is`. Anything else about an
// address test (another part, another match type, a negation) has no
// builder equivalent and is left for the opaque fallback.
m = /^address\s+(?:(:all|:domain)\s+)?:is\s+(?:(:all|:domain)\s+)?"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
if (m) {
const [, partBefore, partAfter, headerName, rawTail] = m;
if (negated || (partBefore && partAfter)) return null;
const field = FIELD_FROM_HEADER[unescapeSieveString(headerName).toLowerCase()];
const value = parseValueTail(rawTail);
if (!field || field === 'subject' || value === null) return null;
const part = partBefore ?? partAfter;
return { field, comparator: part === ':domain' ? 'domain_is' : 'address_is', value };
}
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
if (m) {
const [, tag, headerName, rawTail] = m;
@@ -674,14 +717,25 @@ function extractNextcloudRegions(content: string): {
requires: string[];
} {
const marker = NEXTCLOUD_BLOCK_MARKER;
// Only a comment line counts. The same text inside a string (a rule's value,
// an auto-reply) is data, and taken for a region it would be written back
// as live Sieve.
const positions: number[] = [];
let searchFrom = 0;
while (true) {
const idx = content.indexOf(marker, searchFrom);
if (idx === -1) break;
const atLineStart = idx === 0 || content[idx - 1] === '\n';
if (atLineStart) positions.push(idx);
searchFrom = idx + marker.length;
let i = 0;
while (i < content.length) {
const c = content[i];
if (c === '"') { i = skipStringLit(content, i); continue; }
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
if (c === '#') {
if ((i === 0 || content[i - 1] === '\n') && content.startsWith(marker, i)) positions.push(i);
i = skipHashComment(content, i);
continue;
}
if (content.slice(i, i + 5).toLowerCase() === 'text:' && !/\w/.test(content[i - 1] ?? '')) {
i = skipMultiLineString(content, i);
continue;
}
i++;
}
if (positions.length < 2) {
@@ -801,12 +855,21 @@ export function parseScript(content: string): ParseResult {
return OPAQUE;
}
if (!metadata || metadata.version !== 1) return OPAQUE;
// Version 2 only marks fields older builds do not know (see FilterMetadata).
if (!metadata || (metadata.version !== 1 && metadata.version !== 2)) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
}
// Forwarding that cannot be read could be written back without its
// period; as with a rule, the script then counts as edited by hand.
if (metadata.vacationForward !== undefined && !isValidVacationForward(metadata.vacationForward)) {
return OPAQUE;
}
if (metadata.vacationAudience !== undefined && !isValidVacationAudience(metadata.vacationAudience)) {
return OPAQUE;
}
// Scan the portion AFTER the metadata block for external rules. A prior
// Bulwark save may have emitted its "External rules" header here; strip
@@ -836,13 +899,20 @@ export function parseScript(content: string): ParseResult {
// identifies it as ours.
const filteredExternal = external.rules.filter(r => {
const raw = r.rawBlock || '';
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
// The name ends with its line, and may be empty.
const match = raw.match(/#\s*Rule:[ \t]*(.*?)[ \t]*$/m);
if (match) {
const name = match[1].trim();
// The comment carries the name without line breaks (ruleCommentName).
if (bulwarkRules.some(b => ruleCommentName(b.name) === name)) return false;
// The generator writes the name on one line, its whitespace runs
// collapsed; compared as written, "Foo Bar" would come back as
// someone else's rule, and as one more copy on every save.
const oneLine = (name: string) => name.replace(/\s+/g, ' ').trim();
const name = oneLine(match[1]);
if (bulwarkRules.some(b => oneLine(b.name) === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
// The generator writes the forwarding block from the metadata again.
// Only written while switched on, so only then is such a block Bulwark's.
if (metadata.vacationForward?.enabled && VACATION_FORWARD_MARKER_RE.test(raw)) return false;
return true;
});
@@ -860,6 +930,8 @@ export function parseScript(content: string): ParseResult {
vacation: metadata.vacation,
externalRequires,
...(metadata.includeVacation === true ? { includeVacation: true } : {}),
...(metadata.vacationForward ? { vacationForward: metadata.vacationForward } : {}),
...(metadata.vacationAudience ? { vacationAudience: metadata.vacationAudience } : {}),
};
}
+87
View File
@@ -0,0 +1,87 @@
import type { FilterRule } from './types';
/**
* Time periods for filter rules: "only act on mail that arrives from ...
* until ...". Sieve has no rule scheduling of its own; the date extension
* (RFC 5260) lets a rule test the moment the message is being filtered.
*/
/** Sieve extensions a period needs: `currentdate` and `:value` comparisons. */
export const PERIOD_REQUIRES = ['date', 'relational'] as const;
export function supportsPeriods(extensions: readonly string[] | undefined | null): boolean {
return PERIOD_REQUIRES.every((e) => extensions?.includes(e));
}
// A moment with its zone spelled out. Without one, Date.parse reads the value
// as local time, which would make the script depend on the browser saving it.
const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/;
/** Whether `value` is a usable period boundary (see FilterRule.activeFrom). */
export function isPeriodBoundary(value: unknown): value is string {
if (typeof value !== 'string' || !ISO_WITH_ZONE.test(value)) return false;
const time = Date.parse(value);
// In UTC the zone can push it past year 9999 ("9999-12-31T23:59-01:00"),
// which the four-digit year of Sieve's "date" part cannot hold.
return !Number.isNaN(time) && /^\d{4}-/.test(new Date(time).toISOString());
}
export function hasPeriod(rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>): boolean {
return rule.activeFrom !== undefined || rule.activeUntil !== undefined;
}
/**
* The boundary as RFC 5260 "date" (yyyy-mm-dd) and "time" (hh:mm:ss) parts in
* UTC. The RFC fixes both formats, so comparing them as strings works on any
* server - unlike "iso8601", whose zone suffix is up to the implementation.
*/
function utcParts(boundary: string): { date: string; time: string } {
const iso = new Date(Date.parse(boundary)).toISOString();
return { date: iso.slice(0, 10), time: iso.slice(11, 19) };
}
const NOW = 'currentdate :zone "+0000"';
/** now >= boundary: a later day, or the same day at or after the time. */
function atOrAfter(boundary: string): string {
const { date, time } = utcParts(boundary);
return `anyof(${NOW} :value "gt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "ge" "time" "${time}"))`;
}
/** now <= boundary: an earlier day, or the same day at or before the time. */
function atOrBefore(boundary: string): string {
const { date, time } = utcParts(boundary);
return `anyof(${NOW} :value "lt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "le" "time" "${time}"))`;
}
/**
* The Sieve tests that confine `rule` to its period: none without a period,
* null when a boundary is unusable. A caller must then drop the rule rather
* than run it without its period - a forwarding rule would otherwise forward
* for good.
*/
export function periodTests(rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>): string[] | null {
const tests: string[] = [];
if (rule.activeFrom !== undefined) {
if (!isPeriodBoundary(rule.activeFrom)) return null;
tests.push(atOrAfter(rule.activeFrom));
}
if (rule.activeUntil !== undefined) {
if (!isPeriodBoundary(rule.activeUntil)) return null;
tests.push(atOrBefore(rule.activeUntil));
}
return tests;
}
export type PeriodStatus = 'scheduled' | 'active' | 'expired';
/** Where `now` falls in the rule's period; null for a rule without one. */
export function periodStatus(
rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>,
now: number = Date.now(),
): PeriodStatus | null {
if (!hasPeriod(rule)) return null;
if (isPeriodBoundary(rule.activeFrom) && now < Date.parse(rule.activeFrom)) return 'scheduled';
if (isPeriodBoundary(rule.activeUntil) && now > Date.parse(rule.activeUntil)) return 'expired';
return 'active';
}
+63 -3
View File
@@ -22,7 +22,9 @@ export interface SieveCapabilities {
export type FilterConditionField =
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
| 'attachment';
| 'attachment'
// Every message (Sieve `true`): no value, and always the comparator 'any'.
| 'all';
export type FilterComparator =
| 'contains' | 'not_contains'
@@ -34,7 +36,16 @@ export type FilterComparator =
// has_any → message has any attachment (Content-Disposition: attachment)
// has_type → message has an attachment whose Content-Type matches `value`
// (substring match, e.g. "application/pdf" or "image/")
| 'has_any' | 'has_type';
| 'has_any' | 'has_type'
// For field === 'from' | 'to' | 'cc', compared against the parsed address
// with the Sieve `address` test rather than the raw header text:
// address_is → the whole address equals `value` (anna@acme.com, but not
// joanna@acme.com)
// domain_is → the domain part equals `value` (acme.com, but neither
// sub.acme.com nor acme.com.evil)
| 'address_is' | 'domain_is'
// For field === 'all': any message at all.
| 'any';
export type FilterActionType =
| 'move' | 'copy' | 'forward'
@@ -84,6 +95,15 @@ export interface FilterRule {
* folder rule does not pull spam out of Junk.
*/
includeSpam?: boolean;
/**
* The rule only acts on mail that arrives from `activeFrom` until
* `activeUntil`, both inclusive, either one open-ended. Absolute moments
* as ISO 8601 UTC ("2026-10-05T06:00:00.000Z"), so the script does not
* depend on the time zone of whoever saves it next. Compiled to Sieve
* `currentdate` tests (RFC 5260), which need "date" and "relational".
*/
activeFrom?: string;
activeUntil?: string;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
@@ -95,10 +115,50 @@ export interface VacationSieveConfig {
textBody: string;
}
/**
* Forwarding set up in the vacation card: mail goes on to one address, with
* or without the auto-reply, in the vacation's period. It is not a filter
* rule and the filter list does not show it; it runs ahead of the rules.
*/
export interface VacationForward {
/** The forwarding switch, independent of the auto-reply's. */
enabled: boolean;
/** The one address mail is forwarded to. */
to: string;
/**
* Keep the message in this mailbox too (`redirect :copy`). Otherwise it
* is only forwarded, and no filter rule runs on it.
*/
keepCopy: boolean;
/** The auto-reply's period when it was saved, as for FilterRule.activeFrom. */
activeFrom?: string;
activeUntil?: string;
}
/**
* Who gets the auto-reply when not everyone should: only senders from the
* account's own domains, or only senders from elsewhere. It is decided on
* the envelope sender while the auto-reply runs from the filters script.
*/
export interface VacationAudience {
only: 'internal' | 'external';
/** The account's own domains (from its identities) when it was saved. */
domains: string[];
}
export interface FilterMetadata {
version: 1;
/**
* 2 once a rule has a period or the vacation's forwarding or recipients are
* stored. Builds that only know version 1 would write such a script back
* without these fields, so they leave anything else alone.
*/
version: 1 | 2;
rules: FilterRule[];
vacation?: VacationSieveConfig;
/** See VacationAudience; absent when everyone gets the auto-reply. */
vacationAudience?: VacationAudience;
/** See VacationForward; kept while switched off, so the card remembers it. */
vacationForward?: VacationForward;
/**
* The script runs the server-managed "vacation" script via `include`.
* Servers like Stalwart keep one active script, so a VacationResponse
+30
View File
@@ -0,0 +1,30 @@
import type { VacationAudience } from './types';
// A host name in the form the identities give it: labels of letters, digits
// and inner hyphens, at least two of them.
const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
/** Whether `value` is an audience Bulwark can write back as it reads. */
export function isValidVacationAudience(value: unknown): value is VacationAudience {
if (!value || typeof value !== 'object') return false;
const a = value as Record<string, unknown>;
if (a.only !== 'internal' && a.only !== 'external') return false;
return Array.isArray(a.domains) && a.domains.length > 0
&& a.domains.every((d) => typeof d === 'string' && DOMAIN.test(d));
}
/** The fields Bulwark stores, in a fixed order. */
export function normalizeVacationAudience(audience: VacationAudience): VacationAudience {
return { only: audience.only, domains: audience.domains };
}
/** The domains of the given addresses, lower-cased, each once, in order. */
export function ownDomains(addresses: string[]): string[] {
const domains: string[] = [];
for (const address of addresses) {
const at = address.lastIndexOf('@');
const domain = at === -1 ? '' : address.slice(at + 1).trim().toLowerCase();
if (DOMAIN.test(domain) && !domains.includes(domain)) domains.push(domain);
}
return domains;
}
+55
View File
@@ -0,0 +1,55 @@
import type { VacationForward } from './types';
import { isValidEmail } from '../recipients';
import { isPeriodBoundary } from './period';
/**
* Forwarding from the vacation card (see VacationForward). The generator
* writes it as its own block after the vacation include, marked by this
* comment; the parser knows the block by it and leaves it to the generator.
*/
export const VACATION_FORWARD_MARKER = '# Vacation forwarding';
/**
* The marker as the parser looks for it in a block: the whole line, so a
* comment of someone's own that only begins like it is not taken for it.
*/
export const VACATION_FORWARD_MARKER_RE = /^[ \t]*# Vacation forwarding[ \t]*$/m;
/** Whether `value` is forwarding Bulwark can write back as it reads. */
export function isValidVacationForward(value: unknown): value is VacationForward {
if (!value || typeof value !== 'object') return false;
const f = value as Record<string, unknown>;
if (typeof f.enabled !== 'boolean' || typeof f.keepCopy !== 'boolean') return false;
if (typeof f.to !== 'string' || !isValidEmail(f.to)) return false;
if (f.activeFrom !== undefined && !isPeriodBoundary(f.activeFrom)) return false;
if (f.activeUntil !== undefined && !isPeriodBoundary(f.activeUntil)) return false;
return true;
}
/**
* The forwarding with the vacation's period, whatever period it had: it runs
* while the vacation does (an empty date leaves that end open).
*/
export function withVacationPeriod(
forward: Pick<VacationForward, 'enabled' | 'to' | 'keepCopy'>,
period: { from: string | null; until: string | null },
): VacationForward {
return {
enabled: forward.enabled,
to: forward.to,
keepCopy: forward.keepCopy,
...(period.from ? { activeFrom: period.from } : {}),
...(period.until ? { activeUntil: period.until } : {}),
};
}
/** The fields Bulwark stores, in a fixed order. */
export function normalizeVacationForward(forward: VacationForward): VacationForward {
return {
enabled: forward.enabled,
to: forward.to,
keepCopy: forward.keepCopy,
...(forward.activeFrom !== undefined ? { activeFrom: forward.activeFrom } : {}),
...(forward.activeUntil !== undefined ? { activeUntil: forward.activeUntil } : {}),
};
}
+91 -1
View File
@@ -19,7 +19,9 @@ import {
readWebmailResave as webmailResave,
STALWART_EXTENSIONS,
} from '../../lib/sieve/__tests__/fixtures/webmail';
import { isVacationIncludedInFilters, syncVacationWithFilters, useFilterStore } from '../filter-store';
import {
isVacationIncludedInFilters, readVacationFilters, syncVacationWithFilters, useFilterStore,
} from '../filter-store';
const api = vi.mocked(sieve);
@@ -258,4 +260,92 @@ describe('saving scripts the webmail wrote', () => {
await useFilterStore.getState().saveFilters();
expect(api.updateSieveScript.mock.calls[0][1]).toBe(webmailResave('stalwart-full.sieve'));
});
// #1152: webmail 1.13+ writes version 2 for rule periods and the vacation
// card's forwarding and audience. Read as raw Sieve, "Reset to visual
// builder" wiped them; a save must keep them byte for byte.
it('edits a version 2 script and keeps periods, forwarding and audience', async () => {
const script = webmailFixture('period-forward-audience.sieve');
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
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);
const s = useFilterStore.getState();
expect(s.isOpaque).toBe(false);
expect(s.rules.map((r) => r.activeFrom)).toEqual(['2026-11-02T07:00:00.000Z', '2026-12-01T00:00:00.000Z']);
expect(s.vacationForward?.to).toBe('backup@example.org');
expect(s.vacationAudience?.only).toBe('internal');
await useFilterStore.getState().saveFilters();
expect(api.updateSieveScript).toHaveBeenCalledWith('s1', script, true, 'own');
});
it('keeps the forwarding and audience when the auto-reply is switched off on the phone', async () => {
const script = webmailFixture('period-forward-audience.sieve');
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
api.getSieveScripts.mockResolvedValue([
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: false },
]);
api.getSieveScriptContent.mockResolvedValue(script);
await syncVacationWithFilters(false);
const written = api.updateSieveScript.mock.calls[0][1] as string;
expect(written).not.toContain('include :personal');
expect(written).toContain('# Vacation forwarding');
expect(written).toContain('"vacationAudience":{"only":"internal","domains":["example.org","example.com"]}');
expect(written).toContain('"activeUntil":"2026-11-13T17:30:00.000Z"');
});
it('keeps the remembered forwarding of a version 1 script', async () => {
const script = webmailFixture('forward-off.sieve');
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]);
api.getSieveScriptContent.mockResolvedValue(script);
await useFilterStore.getState().selectAccount(null);
await useFilterStore.getState().saveFilters();
expect(api.updateSieveScript).toHaveBeenCalledWith('s1', script, true, 'own');
});
});
describe('readVacationFilters', () => {
it('reports the stored forwarding and audience and what the server can run', async () => {
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
api.getSieveScripts.mockResolvedValue([
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: false },
]);
api.getSieveScriptContent.mockResolvedValue(webmailFixture('period-forward-audience.sieve'));
const filters = await readVacationFilters();
expect(filters).toMatchObject({
includesVacation: true,
forward: { enabled: true, to: 'backup@example.org', keepCopy: false },
forwardAvailable: true,
audience: { only: 'internal', domains: ['example.org', 'example.com'] },
audienceAvailable: true,
notRunning: false,
});
});
it('sets up forwarding in the vacation period, creating the script when there is none', async () => {
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
api.getSieveScripts.mockResolvedValue([{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: true }]);
api.createSieveScript.mockResolvedValue({ id: 'new', name: 'filters', blobId: 'b', isActive: true });
await syncVacationWithFilters(
true, undefined, { enabled: true, to: 'backup@example.org', keepCopy: true },
undefined, { from: '2026-11-02T00:00:00Z', until: null },
);
const [name, content, activate] = api.createSieveScript.mock.calls[0];
expect(name).toBe('filters');
expect(activate).toBe(true);
expect(content).toContain('"vacationForward":{"enabled":true,"to":"backup@example.org","keepCopy":true,"activeFrom":"2026-11-02T00:00:00Z"}');
expect(content).toContain(' redirect :copy "backup@example.org";');
});
});
+184 -15
View File
@@ -1,7 +1,12 @@
import { create } from 'zustand';
import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '../lib/sieve/types';
import type {
FilterRule, SieveCapabilities, VacationAudience, VacationForward, VacationSieveConfig,
} from '../lib/sieve/types';
import { parseScript } from '../lib/sieve/parser';
import { generateScript, VACATION_SCRIPT_NAME } from '../lib/sieve/generator';
import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '../lib/sieve/generator';
import { supportsPeriods } from '../lib/sieve/period';
import { isValidVacationForward, withVacationPeriod } from '../lib/sieve/vacation-forward';
import { isValidVacationAudience, normalizeVacationAudience } from '../lib/sieve/vacation-audience';
import {
createSieveScript,
getSieveAccountId,
@@ -32,6 +37,10 @@ interface FilterStore {
externalRequires: string[];
/** The script runs the server's vacation script via `include`. */
includeVacation: boolean;
/** Forwarding from the vacation card; carried through every save. */
vacationForward: VacationForward | null;
/** Who gets the auto-reply, when not everyone; carried through every save. */
vacationAudience: VacationAudience | null;
selectedAccountId: string | null;
fetchFilters: (accountId?: string) => Promise<void>;
@@ -63,6 +72,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
vacationForward: null,
vacationAudience: null,
selectedAccountId: null,
fetchFilters: async (accountId) => {
@@ -102,6 +113,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
rawScript: '',
isOpaque: false,
includeVacation: vacationActive,
vacationForward: null,
vacationAudience: null,
});
return;
}
@@ -121,6 +134,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
includeVacation: !result.isOpaque && (!!result.includeVacation || vacationActive),
vacationForward: result.isOpaque ? null : result.vacationForward ?? null,
vacationAudience: result.isOpaque ? null : result.vacationAudience ?? null,
});
} catch (error) {
if (stale()) return;
@@ -143,6 +158,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
vacationForward: null,
vacationAudience: null,
});
await get().fetchFilters(accountId ?? undefined);
},
@@ -152,7 +169,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
try {
const {
isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, includeVacation,
selectedAccountId, sieveCapabilities,
vacationForward, vacationAudience, selectedAccountId, sieveCapabilities,
} = get();
const accountId = selectedAccountId ?? undefined;
@@ -161,6 +178,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
: generateScript(rules, vacationSettings || undefined, {
externalRequires,
includeVacation,
vacationForward,
vacationAudience,
extensions: sieveCapabilities?.sieveExtensions,
});
@@ -257,6 +276,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
vacationForward: null,
vacationAudience: null,
selectedAccountId: null,
}),
}));
@@ -285,36 +306,184 @@ export async function isVacationIncludedInFilters(accountId?: string): Promise<b
return !!(vacationScript && target?.isActive && parsed?.includeVacation);
}
/** What the vacation card needs from the account's filters script (webmail readVacationFilters). */
export interface VacationFilters {
/**
* The 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.
*/
includesVacation: boolean;
/** The forwarding as stored, on or off; null when none is set up. */
forward: VacationForward | null;
/**
* Forwarding can be set up: it runs from the filters script next to the
* included vacation script, so it needs `include`, a script Bulwark can
* read, a server that allows a redirect and what the block uses besides
* (its period, its spam check, the copy). Forwarding that is on is offered
* all the same, so that it can be switched off.
*/
forwardAvailable: boolean;
/** Who gets the auto-reply as stored; null when everyone does. */
audience: VacationAudience | null;
/**
* The auto-reply can be narrowed to some senders: it then runs from the
* filters script, so this needs `include`, `envelope` and a script Bulwark
* can read.
*/
audienceAvailable: boolean;
/**
* Forwarding that is on, or an auto-reply for some senders only, is stored
* but does not run: both run only from the active filters script, and
* Stalwart's own vacation script took over (a save cut short, another
* client), or no script runs at all. Saving the card sets it right.
*/
notRunning: boolean;
}
/** The script was edited by hand and the builder cannot read it back. */
export class OpaqueFiltersError extends Error {
constructor() {
super('The filters script was edited by hand');
this.name = 'OpaqueFiltersError';
}
}
/** See VacationFilters.notRunning. */
function storedButIdle(
filtersActive: boolean,
vacationActive: boolean,
forward: VacationForward | null,
audience: VacationAudience | null,
): boolean {
if (filtersActive) return false;
return !!forward?.enabled || (!!audience && vacationActive);
}
/** The server runs the forwarding block: a redirect, its period, its spam check and the copy. */
function canForward(capabilities: SieveCapabilities | null | undefined): boolean {
const extensions = capabilities?.sieveExtensions;
return capabilities?.maxNumberRedirects !== 0 &&
supportsPeriods(extensions) &&
supportsSpamGuard(extensions) &&
!!extensions?.includes('copy');
}
export async function readVacationFilters(accountId?: string): Promise<VacationFilters> {
const sieveAccountId = accountId ?? getSieveAccountId();
const capabilities = getSieveCapabilities(sieveAccountId);
const { vacationScript, target, parsed } = await loadManagedScript(sieveAccountId);
const filtersUsable = supportsInclude(capabilities) && !(target && !parsed);
const forward = parsed?.vacationForward ?? null;
const audience = parsed?.vacationAudience ?? null;
return {
includesVacation: !!(vacationScript && target?.isActive && parsed?.includeVacation),
forward,
forwardAvailable: filtersUsable && (canForward(capabilities) || !!forward?.enabled),
audience,
// Told apart by the envelope sender, which the auto-reply goes to.
audienceAvailable: filtersUsable && !!capabilities?.sieveExtensions?.includes('envelope'),
notRunning: storedButIdle(!!target?.isActive, !!vacationScript?.isActive, forward, audience),
};
}
function same<T>(a: T | null, b: T | null, normalize: (value: T) => T): boolean {
if (!a || !b) return a === b;
return JSON.stringify(normalize(a)) === JSON.stringify(normalize(b));
}
// The same moment, however it is written: the server hands the vacation's
// dates back in its own form.
function sameMoment(a: string | undefined, b: string | undefined): boolean {
if (a === undefined || b === undefined) return a === b;
return a === b || Date.parse(a) === Date.parse(b);
}
function sameForward(a: VacationForward | null, b: VacationForward | null): boolean {
if (!a || !b) return a === b;
return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy &&
sameMoment(a.activeFrom, b.activeFrom) && sameMoment(a.activeUntil, b.activeUntil);
}
/**
* Keep the filters and the auto-reply both running after VacationResponse/set
* (webmail 198a3c0d).
* Keep the filters, the auto-reply and its forwarding running after
* VacationResponse/set (webmail 198a3c0d, 1.13 forwarding).
*
* 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.
*
* `forward` replaces the stored forwarding (null removes it, undefined keeps
* it). Forwarding runs with or without the auto-reply, but only from an
* active filters script, so for it the script is activated even without
* rules, and created when there is none. `audience` likewise replaces who
* gets the auto-reply (null: everyone). `period` is the vacation's, which
* whatever forwarding there is keeps to.
*/
export async function syncVacationWithFilters(enabled: boolean, accountId?: string): Promise<void> {
export async function syncVacationWithFilters(
enabled: boolean,
accountId?: string,
forward?: VacationForward | null,
audience?: VacationAudience | null,
period?: { from: string | null; until: string | null },
): Promise<void> {
const sieveAccountId = accountId ?? getSieveAccountId();
const capabilities = getSieveCapabilities(sieveAccountId);
if (enabled && !supportsInclude(capabilities)) return;
const canInclude = supportsInclude(capabilities);
if (enabled && !canInclude && forward === undefined && audience === undefined) return;
const { vacationScript, target, parsed } = await loadManagedScript(sieveAccountId);
if (!target || !parsed) return;
const opaque = !!target && !parsed;
const storedForward = parsed?.vacationForward ?? null;
const storedAudience = parsed?.vacationAudience ?? null;
const requestedForward = forward === undefined ? storedForward : forward;
const nextForward = requestedForward && period ? withVacationPeriod(requestedForward, period) : requestedForward;
const nextAudience = audience === undefined ? storedAudience : audience;
const changed =
!sameForward(storedForward, nextForward) ||
!same(storedAudience, nextAudience, normalizeVacationAudience);
if (changed) {
// The card only offers these where they can be stored and run.
if (opaque) throw new OpaqueFiltersError();
if (!canInclude) throw new Error('Forwarding and reply recipients need the Sieve "include" extension');
// The generator leaves out what it could not read back, and the save
// would look done while nothing forwards, or everyone gets the reply.
if (nextForward && !isValidVacationForward(nextForward)) throw new Error('Unusable vacation forwarding');
if (nextAudience && !isValidVacationAudience(nextAudience)) throw new Error('Unusable vacation reply recipients');
}
if ((enabled && !canInclude) || opaque) return;
const rules = parsed?.rules ?? [];
const forwarding = !!nextForward?.enabled;
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;
// Act when the vacation script took over from filters that must keep
// running, or when something changed. An auto-reply for some senders
// only must run from the filters script: on its own it answers everyone.
const tookOver = !!vacationScript?.isActive && !target?.isActive;
const needsFilters = rules.length > 0 || forwarding || !!nextAudience;
if (!changed && !(tookOver && needsFilters)) return;
} else {
// Forwarding also runs without the auto-reply, from an active filters
// script; turning the auto-reply off can leave no script active.
const forwardingIdle = forwarding && !target?.isActive;
if (!changed && !parsed?.includeVacation && !forwardingIdle) return;
}
const content = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
const content = generateScript(rules, parsed?.vacation, {
externalRequires: parsed?.externalRequires,
includeVacation: enabled,
vacationForward: nextForward,
vacationAudience: nextAudience,
extensions: capabilities?.sieveExtensions,
});
await updateSieveScript(target.id, content, enabled || target.isActive, sieveAccountId);
const activate = enabled || forwarding || !!target?.isActive;
if (target) {
await updateSieveScript(target.id, content, activate, sieveAccountId);
} else {
await createSieveScript('filters', content, activate, sieveAccountId);
}
const store = useFilterStore.getState();
if (store.selectedAccountId === sieveAccountId) {