fix: file "Keep" filter rules into the inbox so allow-lists beat the spam filter #1027

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 in inbox" changed nothing. The webmail now writes
`fileinto "INBOX";` for it (62465e1e), but saving filters on the phone
wrote `keep;` again and undid that.

The action stays `keep` in the metadata, so scripts written by either
client read back as "Keep in inbox" and save unchanged.
This commit is contained in:
Linus Rath
2026-09-24 19:01:23 +02:00
parent 72f48db4e5
commit f63d739fb3
3 changed files with 47 additions and 5 deletions
+9 -4
View File
@@ -209,9 +209,14 @@ describe('generateScript', () => {
expect(script).toContain('reject "Go away";');
});
it('generates keep', () => {
it('generates keep as an explicit fileinto "INBOX" (#1027)', () => {
// A bare `keep;` lets Stalwart's spam verdict decide the folder, so a
// "Keep in inbox" allow-list rule must file into INBOX explicitly.
const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]);
expect(script).toContain('keep;');
expect(script).toContain('fileinto "INBOX";');
expect(script).not.toMatch(/^\s*keep;/m);
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
expect(requireLine).toContain('"fileinto"');
});
it('generates stop', () => {
@@ -434,9 +439,9 @@ describe('generateScript', () => {
expect(requireLine).toContain('"imap4flags"');
});
it('generates no require line when only keep/discard/stop/forward actions', () => {
it('generates no require line when only discard/stop/forward actions', () => {
const script = generateScript([makeRule({
actions: [{ type: 'keep' }, { type: 'forward', value: 'a@b.com' }],
actions: [{ type: 'forward', value: 'a@b.com' }, { type: 'stop' }],
})]);
expect(script).not.toContain('require');
});
+32
View File
@@ -148,6 +148,38 @@ describe('parseScript', () => {
const result = parseScript(script);
expect(result.rules).toEqual(rules);
});
it('reads a webmail "Keep in inbox" rule back as keep and saves it unchanged (#1027)', () => {
// What the webmail writes since 62465e1e: the action stays `keep` in the
// metadata, but the Sieve block files into INBOX explicitly.
const rule = makeRule({
id: 'allow',
name: 'Allow boss',
conditions: [{ field: 'from', comparator: 'is', value: 'boss@example.com' }],
actions: [{ type: 'keep' }],
stopProcessing: true,
});
const webScript = [
'/* @metadata:begin',
JSON.stringify({ version: 1, rules: [rule] }),
'@metadata:end */',
'',
'require ["fileinto"];',
'',
'# Rule: Allow boss',
'if header :is "From" "boss@example.com" {',
' fileinto "INBOX";',
' stop;',
'}',
'',
].join('\n');
const result = parseScript(webScript);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([rule]);
expect(generateScript(result.rules, result.vacation, { externalRequires: result.externalRequires }))
.toBe(webScript);
});
});
describe('validation edge cases', () => {
+6 -1
View File
@@ -129,7 +129,11 @@ function generateActions(actions: FilterAction[]): string[] {
case 'reject':
return `reject "${escapeString(action.value || '')}";`;
case 'keep':
return 'keep;';
// 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).
return 'fileinto "INBOX";';
case 'stop':
return 'stop;';
}
@@ -152,6 +156,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
for (const action of rule.actions) {
switch (action.type) {
case 'move':
case 'keep':
extensions.add('fileinto');
break;
case 'copy':