fix: stop filters saved on the phone from moving spam out of Junk

Stalwart only files spam into Junk when no rule decided where the
message goes, so the webmail now adds
`not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"`
to every move and copy rule when the server supports `spamtestplus`
and `relational`, with a per-rule "Also move messages marked as spam"
opt-in stored as `includeSpam` (6241ed61). Saving filters on the phone
dropped the guard and the opt-in, so every folder rule started pulling
spam out of Junk again, and opted-in rules lost their setting.

Port the guard and keep `includeSpam` in the rules metadata. The rule
editor gets the opt-in switch while the rule moves or copies mail.

The round-trip fixtures now cover the guard and a script with every
newer feature plus a hand-written rule. Both clients add two blank
lines before a script's external rules on each save, so that fixture is
compared with what the webmail itself writes when it saves it again.
This commit is contained in:
Linus Rath
2026-09-24 19:29:36 +02:00
parent 3f88868aad
commit 6c31c8d0c6
10 changed files with 323 additions and 45 deletions
+16 -1
View File
@@ -86,6 +86,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
const [conditions, setConditions] = useState<FilterCondition[]>(() => seedConditions(rule));
const [actions, setActions] = useState<FilterAction[]>(() => seedActions(rule));
const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false);
const [includeSpam, setIncludeSpam] = useState(rule?.includeSpam ?? false);
// The Modal stays mounted between opens, so re-seed every field whenever it
// is (re)opened for a different rule - otherwise "Add Rule" after editing
@@ -98,6 +99,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
setConditions(seedConditions(rule));
setActions(seedActions(rule));
setStopProcessing(rule?.stopProcessing ?? false);
setIncludeSpam(rule?.includeSpam ?? false);
}, [visible, rule]);
const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]);
@@ -217,8 +219,11 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
conditions: validConditions,
actions: validActions,
stopProcessing,
// Only folder moves are kept out of Junk, so the opt-in only means
// something (and is only stored) while the rule has one.
...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}),
});
}, [name, conditions, actions, matchType, stopProcessing, rule, onSave, t, mailboxTargets]);
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
@@ -443,6 +448,16 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
</Text>
<ToggleSwitch checked={stopProcessing} onChange={setStopProcessing} />
</View>
{/* Folder rules skip spam unless the rule opts in (webmail 6241ed61). */}
{actions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) && (
<View style={styles.stopRow}>
<Text style={styles.stopLabel}>
{t('settings.filters.include_spam', 'Also move messages marked as spam')}
</Text>
<ToggleSwitch checked={includeSpam} onChange={setIncludeSpam} />
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
@@ -0,0 +1,46 @@
// 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`.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface WebmailFixture {
file: string;
/** The server's sieveExtensions the webmail generated the script for. */
extensions?: string[];
}
// A Stalwart 0.16 style extension list (a subset of what it advertises).
export const STALWART_EXTENSIONS = [
'body', 'comparator-i;ascii-casemap', 'comparator-i;ascii-numeric', 'comparator-i;octet',
'copy', 'date', 'envelope', 'fileinto', 'imap4flags', 'include', 'mailbox', 'mailboxid',
'mime', 'regex', 'reject', 'relational', 'spamtest', 'spamtestplus', 'subaddress',
'vacation', 'variables',
];
export const WEBMAIL_FIXTURES: WebmailFixture[] = [
{ file: 'vacation-include.sieve' },
{ file: 'folder-targets.sieve', extensions: ['fileinto', 'copy', 'imap4flags', 'mailbox', 'mailboxid'] },
{
file: 'spam-guard.sieve',
extensions: ['fileinto', 'copy', 'relational', 'spamtest', 'spamtestplus', 'comparator-i;ascii-numeric'],
},
// Everything at once, plus a hand-written rule the webmail kept.
{ file: 'stalwart-full.sieve', extensions: STALWART_EXTENSIONS },
];
export function readWebmailFixture(file: string): string {
// Git may check the fixture out with CRLF line ends on Windows.
return readFileSync(join(__dirname, file), 'utf-8').replace(/\r\n/g, '\n');
}
/**
* 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`).
*/
export function readWebmailResave(file: string): string {
const resaved = file.replace(/\.sieve$/, '.resaved.sieve');
return readWebmailFixture(existsSync(join(__dirname, resaved)) ? resaved : file);
}
@@ -0,0 +1,30 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"one","name":"Single condition","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"all","name":"All of two","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"a@example.com"},{"field":"subject","comparator":"contains","value":"report"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"any","name":"Any of two","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"contains","value":"a@example.com"},{"field":"subject","comparator":"contains","value":"report"}],"actions":[{"type":"copy","value":"Reports"}],"stopProcessing":false},{"id":"optin","name":"Spam too","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false,"includeSpam":true},{"id":"keep","name":"Allow list","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"keep"}],"stopProcessing":false},{"id":"off","name":"Disabled","enabled":false,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false}]}
@metadata:end */
require ["comparator-i;ascii-numeric", "copy", "fileinto", "relational", "spamtestplus"];
# Rule: Single condition
if allof(header :contains "From" "news@example.com", not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {
fileinto "Newsletters";
}
# Rule: All of two
if allof(header :contains "From" "a@example.com", header :contains "Subject" "report", not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {
fileinto "Newsletters";
}
# Rule: Any of two
if allof(anyof(header :contains "From" "a@example.com", header :contains "Subject" "report"), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {
fileinto :copy "Reports";
}
# Rule: Spam too
if header :contains "From" "news@example.com" {
fileinto "Newsletters";
}
# Rule: Allow list
if header :contains "From" "news@example.com" {
fileinto "INBOX";
}
@@ -0,0 +1,47 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"invoices","name":"Invoices","enabled":true,"matchType":"all","conditions":[{"field":"subject","comparator":"contains","value":"invoice"},{"field":"attachment","comparator":"has_type","value":["pdf","xml"]}],"actions":[{"type":"move","value":"Finance","mailboxId":"m3"},{"type":"mark_read"}],"stopProcessing":true},{"id":"vip","name":"VIP","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"is","value":"ceo@example.com"}],"actions":[{"type":"keep"},{"type":"star"}],"stopProcessing":false},{"id":"fwd","name":"Forward copy","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"forward","value":"assistant@example.com","keepCopy":true}],"stopProcessing":false},{"id":"spam","name":"Spam too","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"copy","value":"Everything","mailboxId":"m9"}],"stopProcessing":false,"includeSpam":true},{"id":"big","name":"Big mail","enabled":true,"matchType":"all","conditions":[{"field":"size","comparator":"greater_than","value":"10000000"}],"actions":[{"type":"reject","value":"Too \"big\""}],"stopProcessing":false}],"includeVacation":true}
@metadata:end */
require ["comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "include", "mailbox", "mailboxid", "mime", "reject", "relational", "spamtestplus"];
# Vacation auto-reply
include :personal :optional "vacation";
# Rule: Invoices
if allof(header :contains "Subject" "invoice", header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ["*.pdf*", "*.xml*"], not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {
addflag "\\Seen";
fileinto :mailboxid "m3" "Finance";
stop;
}
# Rule: VIP
if header :is "From" "ceo@example.com" {
addflag "\\Flagged";
fileinto "INBOX";
}
# Rule: Forward copy
if header :contains "From" "news@example.com" {
redirect :copy "assistant@example.com";
}
# Rule: Spam too
if header :contains "From" "news@example.com" {
fileinto :copy :mailboxid "m9" "Everything";
}
# Rule: Big mail
if size :over 10000000 {
reject "Too \"big\"";
}
# --- External rules (managed outside Bulwark) ---
# Hand-written rule
if header :contains "X-Spam-Flag" "YES" {
fileinto "Junk";
stop;
}
@@ -0,0 +1,45 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"invoices","name":"Invoices","enabled":true,"matchType":"all","conditions":[{"field":"subject","comparator":"contains","value":"invoice"},{"field":"attachment","comparator":"has_type","value":["pdf","xml"]}],"actions":[{"type":"move","value":"Finance","mailboxId":"m3"},{"type":"mark_read"}],"stopProcessing":true},{"id":"vip","name":"VIP","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"is","value":"ceo@example.com"}],"actions":[{"type":"keep"},{"type":"star"}],"stopProcessing":false},{"id":"fwd","name":"Forward copy","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"forward","value":"assistant@example.com","keepCopy":true}],"stopProcessing":false},{"id":"spam","name":"Spam too","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"copy","value":"Everything","mailboxId":"m9"}],"stopProcessing":false,"includeSpam":true},{"id":"big","name":"Big mail","enabled":true,"matchType":"all","conditions":[{"field":"size","comparator":"greater_than","value":"10000000"}],"actions":[{"type":"reject","value":"Too \"big\""}],"stopProcessing":false}],"includeVacation":true}
@metadata:end */
require ["comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "include", "mailbox", "mailboxid", "mime", "reject", "relational", "spamtestplus"];
# Vacation auto-reply
include :personal :optional "vacation";
# Rule: Invoices
if allof(header :contains "Subject" "invoice", header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ["*.pdf*", "*.xml*"], not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {
addflag "\\Seen";
fileinto :mailboxid "m3" "Finance";
stop;
}
# Rule: VIP
if header :is "From" "ceo@example.com" {
addflag "\\Flagged";
fileinto "INBOX";
}
# Rule: Forward copy
if header :contains "From" "news@example.com" {
redirect :copy "assistant@example.com";
}
# Rule: Spam too
if header :contains "From" "news@example.com" {
fileinto :copy :mailboxid "m9" "Everything";
}
# Rule: Big mail
if size :over 10000000 {
reject "Too \"big\"";
}
# --- External rules (managed outside Bulwark) ---
# Hand-written rule
if header :contains "X-Spam-Flag" "YES" {
fileinto "Junk";
stop;
}
+34
View File
@@ -306,6 +306,40 @@ describe('generateScript', () => {
});
});
describe('spam guard', () => {
const exts = ['fileinto', 'relational', 'spamtest', 'spamtestplus'];
const guard = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
it('keeps spam out of folder moves', () => {
const script = generateScript([makeRule()], undefined, { extensions: exts });
expect(script).toContain(`if allof(header :contains "From" "test@example.com", ${guard}) {`);
expect(script).toContain('require ["comparator-i;ascii-numeric", "fileinto", "relational", "spamtestplus"];');
});
it('extends an allof and wraps an anyof', () => {
const conditions = [
{ field: 'from' as const, comparator: 'contains' as const, value: 'a' },
{ field: 'subject' as const, comparator: 'contains' as const, value: 'b' },
];
const all = generateScript([makeRule({ conditions })], undefined, { extensions: exts });
expect(all).toContain(`if allof(header :contains "From" "a", header :contains "Subject" "b", ${guard}) {`);
const any = generateScript([makeRule({ conditions, matchType: 'any' })], undefined, { extensions: exts });
expect(any).toContain(`if allof(anyof(header :contains "From" "a", header :contains "Subject" "b"), ${guard}) {`);
});
it('leaves keep rules, opted-in rules and servers without spamtestplus alone', () => {
const keep = makeRule({ actions: [{ type: 'keep' }] });
const optIn = makeRule({ id: 'r2', name: 'Opt in', includeSpam: true });
expect(generateScript([keep, optIn], undefined, { extensions: exts })).not.toContain('spamtest');
expect(generateScript([makeRule()], undefined, { extensions: ['fileinto'] })).not.toContain('spamtest');
});
it('round-trips the opt-in', () => {
const parsed = parseScript(generateScript([makeRule({ includeSpam: true })], undefined, { extensions: exts }));
expect(parsed.rules[0].includeSpam).toBe(true);
});
});
describe('vacation include', () => {
it('includes the server vacation script before the rules', () => {
const script = generateScript([makeRule()], undefined, { includeVacation: true });
@@ -1,44 +1,34 @@
// Scripts written by the webmail must survive a save on the phone: parsing
// them into rules and generating the script again has to give back the same
// rules and the same Sieve, or saving filters on the phone would silently
// change what the server does (switch the auto-reply off, drop folder ids,
// let spam into folders...).
//
// The fixtures in fixtures/webmail/ were written by the webmail's own
// generator (jmap-webmail lib/sieve/generator.ts at e33ab899), run on the
// rules in each file's metadata for a server listing `extensions`.
// rules and the same Sieve the webmail would write, or saving filters on the
// phone would silently change what the server does (switch the auto-reply
// off, drop folder ids, let spam into folders...). See
// fixtures/webmail/index.ts for where the scripts come from.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { generateScript } from '../generator';
import { parseScript } from '../parser';
interface WebmailFixture {
file: string;
/** The server's sieveExtensions the webmail generated the script for. */
extensions?: string[];
}
const FIXTURES: WebmailFixture[] = [
{ file: 'vacation-include.sieve' },
{ file: 'folder-targets.sieve', extensions: ['fileinto', 'copy', 'imap4flags', 'mailbox', 'mailboxid'] },
];
function readFixture(file: string): string {
// Git may check the fixture out with CRLF line ends on Windows.
return readFileSync(join(__dirname, 'fixtures', 'webmail', file), 'utf-8').replace(/\r\n/g, '\n');
}
import { readWebmailFixture, readWebmailResave, WEBMAIL_FIXTURES } from './fixtures/webmail';
function metadataRules(script: string): unknown[] {
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
return JSON.parse(match![1]).rules;
}
// Blank lines carry no meaning in Sieve.
function withoutBlankLines(script: string): string {
return script.split('\n').filter((line) => line.trim() !== '').join('\n');
}
describe('scripts written by the webmail', () => {
for (const { file, extensions } of FIXTURES) {
for (const { file, extensions } of WEBMAIL_FIXTURES) {
describe(file, () => {
const script = readFixture(file);
const script = readWebmailFixture(file);
const parsed = parseScript(script);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
includeVacation: parsed.includeVacation,
extensions,
});
it('parses into the rules the webmail stored', () => {
expect(parsed.isOpaque).toBe(false);
@@ -46,13 +36,14 @@ describe('scripts written by the webmail', () => {
expect(bulwark).toEqual(metadataRules(script));
});
it('saves back unchanged', () => {
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
includeVacation: parsed.includeVacation,
extensions,
});
expect(regenerated).toBe(script);
it('saves back what the webmail would save', () => {
expect(regenerated).toBe(readWebmailResave(file));
});
it('saves back the same script', () => {
expect(withoutBlankLines(regenerated)).toBe(withoutBlankLines(script));
expect(parseScript(regenerated).rules).toEqual(parsed.rules.map((r) =>
r.rawBlock ? { ...r, rawBlock: expect.any(String) } : r));
});
});
}
+39 -3
View File
@@ -148,7 +148,8 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
// A bare `keep;` delivers to the "default place", which on Stalwart is
// Junk for a message its spam filter has already classified, so an
// allow-list rule made with "Keep" would change nothing. An explicit
// `fileinto "INBOX"` is honored over the spam verdict (#1027).
// `fileinto "INBOX"` is honored over the spam verdict (#1027), but
// only from Stalwart 0.16.22; older versions still move it to Junk.
return 'fileinto "INBOX";';
case 'stop':
return 'stop;';
@@ -156,7 +157,32 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
});
}
function computeRequires(rules: FilterRule[], vacation: VacationSieveConfig | undefined, useMailboxId: boolean): string[] {
// Stalwart maps its spam verdict to 100 % (0.16.0) or to a graded value where
// 50 % is the spam threshold (0.16.19+), so ">= 50" matches "is spam" on both.
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 {
return ['spamtestplus', 'relational'].every(e => extensions?.includes(e));
}
/**
* A folder move takes a message out of the Junk folder the server would have
* put it in, so move/copy rules skip spam unless the rule opts in. "Keep"
* rules are allow-lists and stay unguarded.
*/
function needsSpamGuard(rule: FilterRule, extensions: string[] | undefined): boolean {
return !rule.includeSpam &&
rule.actions.some(a => a.type === 'move' || a.type === 'copy') &&
supportsSpamGuard(extensions);
}
function computeRequires(
rules: FilterRule[],
vacation: VacationSieveConfig | undefined,
useMailboxId: boolean,
serverExtensions: string[] | undefined,
): string[] {
const extensions = new Set<string>();
const enabledRules = rules.filter(r => r.enabled);
@@ -165,6 +191,9 @@ function computeRequires(rules: FilterRule[], vacation: VacationSieveConfig | un
}
for (const rule of enabledRules) {
if (needsSpamGuard(rule, serverExtensions)) {
for (const e of SPAM_GUARD_REQUIRES) extensions.add(e);
}
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
if (condition.field === 'attachment') extensions.add('mime');
@@ -211,6 +240,7 @@ function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'origi
conditions: r.conditions,
actions: r.actions,
stopProcessing: r.stopProcessing,
...(r.includeSpam ? { includeSpam: true } : {}),
};
}
@@ -269,7 +299,7 @@ export function generateScript(
lines.push('');
const useMailboxId = options.extensions?.includes('mailboxid') ?? false;
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId);
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
if (options.includeVacation) bulwarkRequires.push('include');
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
@@ -320,6 +350,12 @@ export function generateScript(
conditionStr = `${wrapper}(${conditions.join(', ')})`;
}
if (needsSpamGuard(rule, options.extensions)) {
conditionStr = conditions.length > 1 && rule.matchType === 'all'
? `allof(${conditions.join(', ')}, ${SPAM_GUARD})`
: `allof(${conditionStr}, ${SPAM_GUARD})`;
}
const actionLines = generateActions(rule.actions, useMailboxId);
if (rule.stopProcessing) {
+5
View File
@@ -79,6 +79,11 @@ export interface FilterRule {
conditions: FilterCondition[];
actions: FilterAction[];
stopProcessing: boolean;
/**
* Also move/copy messages the server marked as spam. Off by default, so a
* folder rule does not pull spam out of Junk.
*/
includeSpam?: boolean;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
+36 -7
View File
@@ -11,11 +11,14 @@ vi.mock('../../api/sieve', () => ({
validateSieveScript: vi.fn(async () => ({ isValid: true })),
}));
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as sieve from '../../api/sieve';
import { generateScript } from '../../lib/sieve/generator';
import type { FilterRule } from '../../lib/sieve/types';
import {
readWebmailFixture as webmailFixture,
readWebmailResave as webmailResave,
STALWART_EXTENSIONS,
} from '../../lib/sieve/__tests__/fixtures/webmail';
import { isVacationIncludedInFilters, syncVacationWithFilters, useFilterStore } from '../filter-store';
const api = vi.mocked(sieve);
@@ -38,11 +41,6 @@ function serveScript(rules: FilterRule[]) {
api.getSieveScriptContent.mockResolvedValue(generateScript(rules));
}
function webmailFixture(file: string): string {
return readFileSync(join(__dirname, '../../lib/sieve/__tests__/fixtures/webmail', file), 'utf-8')
.replace(/\r\n/g, '\n');
}
const WITH_INCLUDE = {
implementation: 'test', maxSizeScript: 100000, sieveExtensions: ['fileinto', 'include'],
notificationMethods: [], externalLists: [],
@@ -229,4 +227,35 @@ describe('saving scripts the webmail wrote', () => {
await useFilterStore.getState().saveFilters();
expect(api.updateSieveScript).toHaveBeenCalledWith('s1', script, true, 'own');
});
it('keeps the spam guard, its opt-in, the vacation include and external rules', async () => {
const script = webmailFixture('stalwart-full.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);
expect(useFilterStore.getState().rules.find((r) => r.id === 'spam')?.includeSpam).toBe(true);
await useFilterStore.getState().saveFilters();
expect(api.updateSieveScript).toHaveBeenCalledWith('s1', webmailResave('stalwart-full.sieve'), true, 'own');
});
it('keeps the rule data through an edit on the phone', async () => {
const script = webmailFixture('stalwart-full.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);
useFilterStore.getState().toggleRule('big');
useFilterStore.getState().toggleRule('big');
useFilterStore.getState().reorderRules(['vip', 'invoices', 'fwd', 'spam', 'big']);
useFilterStore.getState().reorderRules(['invoices', 'vip', 'fwd', 'spam', 'big']);
await useFilterStore.getState().saveFilters();
expect(api.updateSieveScript.mock.calls[0][1]).toBe(webmailResave('stalwart-full.sieve'));
});
});