mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep webmail filter periods, forwarding and exact address rules through a phone save #1152
This commit is contained in:
@@ -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
|
// 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
|
// lib/sieve/generator.ts at e33ab899; the version 2, address comparator and
|
||||||
// phone gives back the same script. Each one was generated from the rules in
|
// forwarding ones at 4aea0976), used to check that a save on the phone gives
|
||||||
// its metadata for a server listing `extensions`.
|
// 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 { existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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.
|
// Everything at once, plus a hand-written rule the webmail kept.
|
||||||
{ file: 'stalwart-full.sieve', extensions: STALWART_EXTENSIONS },
|
{ 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 {
|
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
|
* 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
|
* fixture unchanged, except that a script written before 4aea0976 had extra
|
||||||
* a script's external rules on every save (`<name>.resaved.sieve`).
|
* blank lines in front of its external rules, which a save now trims
|
||||||
|
* (`<name>.resaved.sieve`).
|
||||||
*/
|
*/
|
||||||
export function readWebmailResave(file: string): string {
|
export function readWebmailResave(file: string): string {
|
||||||
const resaved = file.replace(/\.sieve$/, '.resaved.sieve');
|
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) ---
|
# --- External rules (managed outside Bulwark) ---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Hand-written rule
|
# Hand-written rule
|
||||||
if header :contains "X-Spam-Flag" "YES" {
|
if header :contains "X-Spam-Flag" "YES" {
|
||||||
fileinto "Junk";
|
fileinto "Junk";
|
||||||
|
|||||||
@@ -241,25 +241,99 @@ describe('generateScript', () => {
|
|||||||
expect(matches).toHaveLength(1);
|
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({
|
const script = generateScript([makeRule({
|
||||||
actions: [{ type: 'discard' }],
|
actions: [{ type: 'discard' }],
|
||||||
stopProcessing: true,
|
stopProcessing: true,
|
||||||
})]);
|
})]);
|
||||||
const matches = script.match(/stop;/g);
|
expect(script).toContain(' discard;\n stop;\n}');
|
||||||
expect(matches).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not append stop after reject', () => {
|
it('appends stop after reject', () => {
|
||||||
const script = generateScript([makeRule({
|
const script = generateScript([makeRule({
|
||||||
actions: [{ type: 'reject', value: 'No' }],
|
actions: [{ type: 'reject', value: 'No' }],
|
||||||
stopProcessing: true,
|
stopProcessing: true,
|
||||||
})]);
|
})]);
|
||||||
const matches = script.match(/stop;/g);
|
expect(script).toContain(' reject "No";\n stop;\n}');
|
||||||
expect(matches).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('action order and targets', () => {
|
||||||
it('sets flags before the move so the moved message keeps them', () => {
|
it('sets flags before the move so the moved message keeps them', () => {
|
||||||
const script = generateScript([makeRule({
|
const script = generateScript([makeRule({
|
||||||
|
|||||||
@@ -42,13 +42,55 @@ describe('parseScript', () => {
|
|||||||
expect(result.rules).toEqual([]);
|
expect(result.rules).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns isOpaque for version mismatch', () => {
|
it('returns isOpaque for a metadata version it does not know', () => {
|
||||||
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
|
const script = '/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */';
|
||||||
const result = parseScript(script);
|
const result = parseScript(script);
|
||||||
expect(result.isOpaque).toBe(true);
|
expect(result.isOpaque).toBe(true);
|
||||||
expect(result.rules).toEqual([]);
|
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', () => {
|
it('returns isOpaque for empty metadata block', () => {
|
||||||
const script = '/* @metadata:begin\n\n@metadata:end */';
|
const script = '/* @metadata:begin\n\n@metadata:end */';
|
||||||
const result = parseScript(script);
|
const result = parseScript(script);
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import { generateScript } from '../generator';
|
|||||||
import { parseScript } from '../parser';
|
import { parseScript } from '../parser';
|
||||||
import { readWebmailFixture, readWebmailResave, WEBMAIL_FIXTURES } from './fixtures/webmail';
|
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/);
|
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.
|
// Blank lines carry no meaning in Sieve.
|
||||||
@@ -27,6 +31,8 @@ describe('scripts written by the webmail', () => {
|
|||||||
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
||||||
externalRequires: parsed.externalRequires,
|
externalRequires: parsed.externalRequires,
|
||||||
includeVacation: parsed.includeVacation,
|
includeVacation: parsed.includeVacation,
|
||||||
|
vacationForward: parsed.vacationForward,
|
||||||
|
vacationAudience: parsed.vacationAudience,
|
||||||
extensions,
|
extensions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,6 +42,13 @@ describe('scripts written by the webmail', () => {
|
|||||||
expect(bulwark).toEqual(metadataRules(script));
|
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', () => {
|
it('saves back what the webmail would save', () => {
|
||||||
expect(regenerated).toBe(readWebmailResave(file));
|
expect(regenerated).toBe(readWebmailResave(file));
|
||||||
});
|
});
|
||||||
|
|||||||
+146
-21
@@ -3,8 +3,13 @@ import type {
|
|||||||
FilterCondition,
|
FilterCondition,
|
||||||
FilterAction,
|
FilterAction,
|
||||||
FilterMetadata,
|
FilterMetadata,
|
||||||
|
VacationAudience,
|
||||||
|
VacationForward,
|
||||||
VacationSieveConfig,
|
VacationSieveConfig,
|
||||||
} from './types';
|
} 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
|
// 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
|
// 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',
|
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 {
|
function escapeString(value: string): string {
|
||||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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
|
* 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
|
* 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 {
|
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. */
|
/** 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 {
|
function generateCondition(condition: FilterCondition): string {
|
||||||
const { field, comparator, value } = condition;
|
const { field, comparator, value } = condition;
|
||||||
|
|
||||||
|
// Every message: there is nothing to compare.
|
||||||
|
if (field === 'all') return 'true';
|
||||||
|
|
||||||
if (field === 'size') {
|
if (field === 'size') {
|
||||||
// Size is numeric, single value only.
|
// Size is numeric, single value only. It is written unquoted, so
|
||||||
const sizeValue = Array.isArray(value) ? value[0] : value;
|
// 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';
|
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||||
return `size ${op} ${sizeValue}`;
|
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}*`)}`;
|
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')
|
? (condition.headerName?.trim() || 'X-Unknown')
|
||||||
: HEADER_MAP[field];
|
: HEADER_MAP[field];
|
||||||
|
|
||||||
@@ -108,9 +123,22 @@ function generateCondition(condition: FilterCondition): string {
|
|||||||
// must never be flattened into `header :contains "undefined"`: that would
|
// must never be flattened into `header :contains "undefined"`: that would
|
||||||
// silently rewrite the user's rule server-side on the next save. Fail the
|
// silently rewrite the user's rule server-side on the next save. Fail the
|
||||||
// save instead so the script stays untouched.
|
// save instead so the script stays untouched.
|
||||||
if (!headerName) {
|
if (!rawHeaderName) {
|
||||||
throw new Error(`Unsupported filter condition field: ${String(field)}`);
|
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) {
|
switch (comparator) {
|
||||||
case 'contains':
|
case 'contains':
|
||||||
@@ -128,7 +156,10 @@ function generateCondition(condition: FilterCondition): string {
|
|||||||
case 'matches':
|
case 'matches':
|
||||||
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
||||||
default:
|
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";';
|
return 'fileinto "INBOX";';
|
||||||
case 'stop':
|
case 'stop':
|
||||||
return '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 = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
|
||||||
const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric'];
|
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));
|
return ['spamtestplus', 'relational'].every(e => extensions?.includes(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +250,9 @@ function computeRequires(
|
|||||||
if (needsSpamGuard(rule, serverExtensions)) {
|
if (needsSpamGuard(rule, serverExtensions)) {
|
||||||
for (const e of SPAM_GUARD_REQUIRES) extensions.add(e);
|
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) {
|
for (const condition of rule.conditions) {
|
||||||
if (condition.field === 'body') extensions.add('body');
|
if (condition.field === 'body') extensions.add('body');
|
||||||
if (condition.field === 'attachment') extensions.add('mime');
|
if (condition.field === 'attachment') extensions.add('mime');
|
||||||
@@ -264,6 +300,8 @@ function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'origi
|
|||||||
actions: r.actions,
|
actions: r.actions,
|
||||||
stopProcessing: r.stopProcessing,
|
stopProcessing: r.stopProcessing,
|
||||||
...(r.includeSpam ? { includeSpam: true } : {}),
|
...(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.
|
* while the filters stay active.
|
||||||
*/
|
*/
|
||||||
includeVacation?: boolean;
|
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
|
* The server's `sieveExtensions`. Folder moves use `:mailboxid` when it
|
||||||
* lists "mailboxid"; without it they target the folder path only.
|
* lists "mailboxid"; without it they target the folder path only.
|
||||||
@@ -313,6 +361,28 @@ export function generateScript(
|
|||||||
if (options.includeVacation) {
|
if (options.includeVacation) {
|
||||||
metadata.includeVacation = true;
|
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
|
// 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
|
// 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.
|
// 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 useMailboxId = options.extensions?.includes('mailboxid') ?? false;
|
||||||
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
|
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
|
||||||
if (options.includeVacation) bulwarkRequires.push('include');
|
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 externalRequires = options.externalRequires ?? [];
|
||||||
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
|
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.
|
// the parser does not re-import it as an external rule.
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('# Vacation auto-reply');
|
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) {
|
if (vacation?.isEnabled) {
|
||||||
@@ -353,6 +449,19 @@ export function generateScript(
|
|||||||
lines.push(`vacation ${vacationParts.join(' ')};`);
|
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);
|
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
|
||||||
|
|
||||||
for (const rule of enabledBulwarkRules) {
|
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`);
|
console.warn(`[filters] Skipping rule "${rule.name}": a header name the server would refuse`);
|
||||||
continue;
|
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('');
|
||||||
lines.push(`# Rule: ${ruleCommentName(rule.name)}`);
|
lines.push(`# Rule: ${ruleCommentName(rule.name)}`);
|
||||||
@@ -389,13 +505,20 @@ export function generateScript(
|
|||||||
: `allof(${conditionStr}, ${SPAM_GUARD})`;
|
: `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);
|
const actionLines = generateActions(rule.actions, useMailboxId);
|
||||||
|
|
||||||
if (rule.stopProcessing) {
|
// discard and reject only cancel the implicit keep (RFC 5228 4.4,
|
||||||
const lastAction = rule.actions[rule.actions.length - 1];
|
// RFC 5429); the script goes on, and the rules below would still act on
|
||||||
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
|
// the message. So "stop processing" always writes a stop, unless the
|
||||||
actionLines.push('stop;');
|
// 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} {`);
|
lines.push(`if ${conditionStr} {`);
|
||||||
@@ -405,15 +528,17 @@ export function generateScript(
|
|||||||
lines.push('}');
|
lines.push('}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append preserved external rules verbatim. Each rawBlock already carries its
|
// Append preserved external rules verbatim. Each rawBlock carries its own
|
||||||
// own leading comments and trailing whitespace from the source script.
|
// leading comments, but also the blank lines around it in the source
|
||||||
if (externalRules.length > 0) {
|
// 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('');
|
||||||
lines.push('# --- External rules (managed outside Bulwark) ---');
|
lines.push('# --- External rules (managed outside Bulwark) ---');
|
||||||
for (const ext of externalRules) {
|
lines.push(externalBlocks.join('\n\n'));
|
||||||
if (!ext.rawBlock) continue;
|
|
||||||
lines.push(ext.rawBlock.replace(/\s+$/, ''));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|||||||
+86
-14
@@ -5,15 +5,19 @@ import type {
|
|||||||
FilterConditionField,
|
FilterConditionField,
|
||||||
FilterMetadata,
|
FilterMetadata,
|
||||||
FilterRule,
|
FilterRule,
|
||||||
|
VacationAudience,
|
||||||
|
VacationForward,
|
||||||
VacationSieveConfig,
|
VacationSieveConfig,
|
||||||
} from './types';
|
} 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
|
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
|
||||||
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
|
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
|
||||||
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
|
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
|
||||||
// are best-effort parsed into read-only "external" rules or preserved opaquely.
|
// are best-effort parsed into read-only "external" rules or preserved opaquely.
|
||||||
|
|
||||||
|
|
||||||
export interface ParseResult {
|
export interface ParseResult {
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
isOpaque: boolean;
|
isOpaque: boolean;
|
||||||
@@ -21,6 +25,10 @@ export interface ParseResult {
|
|||||||
externalRequires: string[];
|
externalRequires: string[];
|
||||||
/** The script runs the server's "vacation" script via `include`. */
|
/** The script runs the server's "vacation" script via `include`. */
|
||||||
includeVacation?: boolean;
|
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: [] };
|
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
|
||||||
@@ -69,6 +77,12 @@ function isValidRule(rule: unknown): rule is FilterRule {
|
|||||||
typeof r.stopProcessing !== 'boolean'
|
typeof r.stopProcessing !== 'boolean'
|
||||||
) return false;
|
) 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);
|
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;
|
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 {
|
function skipStatement(s: string, i: number): number {
|
||||||
while (i < s.length) {
|
while (i < s.length) {
|
||||||
const c = s[i];
|
const c = s[i];
|
||||||
@@ -458,6 +485,22 @@ function parseAtom(raw: string): FilterCondition | null {
|
|||||||
return 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);
|
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||||
if (m) {
|
if (m) {
|
||||||
const [, tag, headerName, rawTail] = m;
|
const [, tag, headerName, rawTail] = m;
|
||||||
@@ -674,14 +717,25 @@ function extractNextcloudRegions(content: string): {
|
|||||||
requires: string[];
|
requires: string[];
|
||||||
} {
|
} {
|
||||||
const marker = NEXTCLOUD_BLOCK_MARKER;
|
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[] = [];
|
const positions: number[] = [];
|
||||||
let searchFrom = 0;
|
let i = 0;
|
||||||
while (true) {
|
while (i < content.length) {
|
||||||
const idx = content.indexOf(marker, searchFrom);
|
const c = content[i];
|
||||||
if (idx === -1) break;
|
if (c === '"') { i = skipStringLit(content, i); continue; }
|
||||||
const atLineStart = idx === 0 || content[idx - 1] === '\n';
|
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
|
||||||
if (atLineStart) positions.push(idx);
|
if (c === '#') {
|
||||||
searchFrom = idx + marker.length;
|
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) {
|
if (positions.length < 2) {
|
||||||
@@ -801,12 +855,21 @@ export function parseScript(content: string): ParseResult {
|
|||||||
return OPAQUE;
|
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;
|
if (!Array.isArray(metadata.rules)) return OPAQUE;
|
||||||
|
|
||||||
for (const rule of metadata.rules) {
|
for (const rule of metadata.rules) {
|
||||||
if (!isValidRule(rule)) return OPAQUE;
|
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
|
// Scan the portion AFTER the metadata block for external rules. A prior
|
||||||
// Bulwark save may have emitted its "External rules" header here; strip
|
// 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.
|
// identifies it as ours.
|
||||||
const filteredExternal = external.rules.filter(r => {
|
const filteredExternal = external.rules.filter(r => {
|
||||||
const raw = r.rawBlock || '';
|
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) {
|
if (match) {
|
||||||
const name = match[1].trim();
|
// The generator writes the name on one line, its whitespace runs
|
||||||
// The comment carries the name without line breaks (ruleCommentName).
|
// collapsed; compared as written, "Foo Bar" would come back as
|
||||||
if (bulwarkRules.some(b => ruleCommentName(b.name) === name)) return false;
|
// 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;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -860,6 +930,8 @@ export function parseScript(content: string): ParseResult {
|
|||||||
vacation: metadata.vacation,
|
vacation: metadata.vacation,
|
||||||
externalRequires,
|
externalRequires,
|
||||||
...(metadata.includeVacation === true ? { includeVacation: true } : {}),
|
...(metadata.includeVacation === true ? { includeVacation: true } : {}),
|
||||||
|
...(metadata.vacationForward ? { vacationForward: metadata.vacationForward } : {}),
|
||||||
|
...(metadata.vacationAudience ? { vacationAudience: metadata.vacationAudience } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -22,7 +22,9 @@ export interface SieveCapabilities {
|
|||||||
|
|
||||||
export type FilterConditionField =
|
export type FilterConditionField =
|
||||||
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
|
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
|
||||||
| 'attachment';
|
| 'attachment'
|
||||||
|
// Every message (Sieve `true`): no value, and always the comparator 'any'.
|
||||||
|
| 'all';
|
||||||
|
|
||||||
export type FilterComparator =
|
export type FilterComparator =
|
||||||
| 'contains' | 'not_contains'
|
| 'contains' | 'not_contains'
|
||||||
@@ -34,7 +36,16 @@ export type FilterComparator =
|
|||||||
// has_any → message has any attachment (Content-Disposition: attachment)
|
// has_any → message has any attachment (Content-Disposition: attachment)
|
||||||
// has_type → message has an attachment whose Content-Type matches `value`
|
// has_type → message has an attachment whose Content-Type matches `value`
|
||||||
// (substring match, e.g. "application/pdf" or "image/")
|
// (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 =
|
export type FilterActionType =
|
||||||
| 'move' | 'copy' | 'forward'
|
| 'move' | 'copy' | 'forward'
|
||||||
@@ -84,6 +95,15 @@ export interface FilterRule {
|
|||||||
* folder rule does not pull spam out of Junk.
|
* folder rule does not pull spam out of Junk.
|
||||||
*/
|
*/
|
||||||
includeSpam?: boolean;
|
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;
|
origin?: FilterOrigin;
|
||||||
originLabel?: string;
|
originLabel?: string;
|
||||||
rawBlock?: string;
|
rawBlock?: string;
|
||||||
@@ -95,10 +115,50 @@ export interface VacationSieveConfig {
|
|||||||
textBody: string;
|
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 {
|
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[];
|
rules: FilterRule[];
|
||||||
vacation?: VacationSieveConfig;
|
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`.
|
* The script runs the server-managed "vacation" script via `include`.
|
||||||
* Servers like Stalwart keep one active script, so a VacationResponse
|
* Servers like Stalwart keep one active script, so a VacationResponse
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
readWebmailResave as webmailResave,
|
readWebmailResave as webmailResave,
|
||||||
STALWART_EXTENSIONS,
|
STALWART_EXTENSIONS,
|
||||||
} from '../../lib/sieve/__tests__/fixtures/webmail';
|
} 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);
|
const api = vi.mocked(sieve);
|
||||||
|
|
||||||
@@ -258,4 +260,92 @@ describe('saving scripts the webmail wrote', () => {
|
|||||||
await useFilterStore.getState().saveFilters();
|
await useFilterStore.getState().saveFilters();
|
||||||
expect(api.updateSieveScript.mock.calls[0][1]).toBe(webmailResave('stalwart-full.sieve'));
|
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
@@ -1,7 +1,12 @@
|
|||||||
import { create } from 'zustand';
|
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 { 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 {
|
import {
|
||||||
createSieveScript,
|
createSieveScript,
|
||||||
getSieveAccountId,
|
getSieveAccountId,
|
||||||
@@ -32,6 +37,10 @@ interface FilterStore {
|
|||||||
externalRequires: string[];
|
externalRequires: string[];
|
||||||
/** The script runs the server's vacation script via `include`. */
|
/** The script runs the server's vacation script via `include`. */
|
||||||
includeVacation: boolean;
|
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;
|
selectedAccountId: string | null;
|
||||||
|
|
||||||
fetchFilters: (accountId?: string) => Promise<void>;
|
fetchFilters: (accountId?: string) => Promise<void>;
|
||||||
@@ -63,6 +72,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
vacationSettings: null,
|
vacationSettings: null,
|
||||||
externalRequires: [],
|
externalRequires: [],
|
||||||
includeVacation: false,
|
includeVacation: false,
|
||||||
|
vacationForward: null,
|
||||||
|
vacationAudience: null,
|
||||||
selectedAccountId: null,
|
selectedAccountId: null,
|
||||||
|
|
||||||
fetchFilters: async (accountId) => {
|
fetchFilters: async (accountId) => {
|
||||||
@@ -102,6 +113,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
rawScript: '',
|
rawScript: '',
|
||||||
isOpaque: false,
|
isOpaque: false,
|
||||||
includeVacation: vacationActive,
|
includeVacation: vacationActive,
|
||||||
|
vacationForward: null,
|
||||||
|
vacationAudience: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,6 +134,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
vacationSettings: result.vacation || null,
|
vacationSettings: result.vacation || null,
|
||||||
externalRequires: result.externalRequires,
|
externalRequires: result.externalRequires,
|
||||||
includeVacation: !result.isOpaque && (!!result.includeVacation || vacationActive),
|
includeVacation: !result.isOpaque && (!!result.includeVacation || vacationActive),
|
||||||
|
vacationForward: result.isOpaque ? null : result.vacationForward ?? null,
|
||||||
|
vacationAudience: result.isOpaque ? null : result.vacationAudience ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
@@ -143,6 +158,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
vacationSettings: null,
|
vacationSettings: null,
|
||||||
externalRequires: [],
|
externalRequires: [],
|
||||||
includeVacation: false,
|
includeVacation: false,
|
||||||
|
vacationForward: null,
|
||||||
|
vacationAudience: null,
|
||||||
});
|
});
|
||||||
await get().fetchFilters(accountId ?? undefined);
|
await get().fetchFilters(accountId ?? undefined);
|
||||||
},
|
},
|
||||||
@@ -152,7 +169,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, includeVacation,
|
isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, includeVacation,
|
||||||
selectedAccountId, sieveCapabilities,
|
vacationForward, vacationAudience, selectedAccountId, sieveCapabilities,
|
||||||
} = get();
|
} = get();
|
||||||
const accountId = selectedAccountId ?? undefined;
|
const accountId = selectedAccountId ?? undefined;
|
||||||
|
|
||||||
@@ -161,6 +178,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
: generateScript(rules, vacationSettings || undefined, {
|
: generateScript(rules, vacationSettings || undefined, {
|
||||||
externalRequires,
|
externalRequires,
|
||||||
includeVacation,
|
includeVacation,
|
||||||
|
vacationForward,
|
||||||
|
vacationAudience,
|
||||||
extensions: sieveCapabilities?.sieveExtensions,
|
extensions: sieveCapabilities?.sieveExtensions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +276,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
|||||||
vacationSettings: null,
|
vacationSettings: null,
|
||||||
externalRequires: [],
|
externalRequires: [],
|
||||||
includeVacation: false,
|
includeVacation: false,
|
||||||
|
vacationForward: null,
|
||||||
|
vacationAudience: null,
|
||||||
selectedAccountId: null,
|
selectedAccountId: null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -285,36 +306,184 @@ export async function isVacationIncludedInFilters(accountId?: string): Promise<b
|
|||||||
return !!(vacationScript && target?.isActive && parsed?.includeVacation);
|
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
|
* Keep the filters, the auto-reply and its forwarding running after
|
||||||
* (webmail 198a3c0d).
|
* VacationResponse/set (webmail 198a3c0d, 1.13 forwarding).
|
||||||
*
|
*
|
||||||
* Stalwart allows one active Sieve script and turns the auto-reply on by
|
* Stalwart allows one active Sieve script and turns the auto-reply on by
|
||||||
* activating its own "vacation" script, which switches every filter off.
|
* activating its own "vacation" script, which switches every filter off.
|
||||||
* When that happened, re-activate the filters script with an `include` of
|
* 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.
|
* 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 sieveAccountId = accountId ?? getSieveAccountId();
|
||||||
const capabilities = getSieveCapabilities(sieveAccountId);
|
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);
|
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) {
|
if (enabled) {
|
||||||
// Only act when the vacation script took over from existing filters.
|
// Act when the vacation script took over from filters that must keep
|
||||||
if (!vacationScript?.isActive || target.isActive || parsed.rules.length === 0) return;
|
// running, or when something changed. An auto-reply for some senders
|
||||||
} else if (!parsed.includeVacation) {
|
// only must run from the filters script: on its own it answers everyone.
|
||||||
return;
|
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, {
|
const content = generateScript(rules, parsed?.vacation, {
|
||||||
externalRequires: parsed.externalRequires,
|
externalRequires: parsed?.externalRequires,
|
||||||
includeVacation: enabled,
|
includeVacation: enabled,
|
||||||
|
vacationForward: nextForward,
|
||||||
|
vacationAudience: nextAudience,
|
||||||
extensions: capabilities?.sieveExtensions,
|
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();
|
const store = useFilterStore.getState();
|
||||||
if (store.selectedAccountId === sieveAccountId) {
|
if (store.selectedAccountId === sieveAccountId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user