fix: save filters whose rules contain */, line breaks or odd header names

The app stores its filter rules as JSON inside a `/* */` comment at the
top of the Sieve script. A `*/` anywhere in a rule name, a value or the
auto-reply text ended that comment early and turned the rest of the
rules into script, and a CR or LF in a rule name did the same to its
`# Rule:` comment. Stalwart (sieve-rs, on 0.16 and 1.0) also accepts
only the letters A-Z, digits, `-` and `_` in a header name. Any of these
made the server refuse the whole script, so saving any filter, and the
auto-reply with it, failed.

- `*/` in the embedded rules is written as `*\/`, which JSON reads back
  as `*/`, so every client parses the rules as before
- the `# Rule:` comment carries the rule name without line breaks, and
  the parser matches the comment back to its rule by that form
- the filter editor trims header names, turns autocorrect off for them
  and refuses to save one the server would reject, with a translated
  message; the generator leaves a rule with such a header name out of
  the script, since dropping only the condition could widen an "all of"
  rule, and keeps it in the stored rules for editing
This commit is contained in:
Linus Rath
2026-09-25 20:46:00 +02:00
parent e2c76d46c7
commit 1129ba339b
31 changed files with 192 additions and 34 deletions
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "إزالة الشرط",
"sieve_editor": {
"line_count": "{count, plural, zero {# سطر} one {سطر واحد} two {سطران} few {# أسطر} many {# سطرًا} other {# سطر}}"
}
},
"validation_header_name": "أدخل اسم ترويسة لا يحتوي إلا على الأحرف A-Z والأرقام والشرطات والشرطات السفلية، مثل X-Spam-Flag."
},
"folders": {
"account": "الحساب",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Elimina la condició",
"sieve_editor": {
"line_count": "{count, plural, one {# línia} other {# línies}}"
}
},
"validation_header_name": "Introduïu un nom de capçalera que només contingui les lletres A-Z, xifres, guions i guions baixos, com ara X-Spam-Flag."
},
"folders": {
"account": "Compte",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Odebrat podmínku",
"sieve_editor": {
"line_count": "{count, plural, one {# řádek} few {# řádky} many {# řádku} other {# řádků}}"
}
},
"validation_header_name": "Zadejte název hlavičky, který obsahuje jen písmena A–Z, číslice, pomlčky a podtržítka, například X-Spam-Flag."
},
"folders": {
"account": "Účet",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Fjern betingelse",
"sieve_editor": {
"line_count": "{count, plural, one {# linje} other {# linjer}}"
}
},
"validation_header_name": "Angiv et headernavn, der kun bruger bogstaverne A-Z, tal, bindestreger og understregninger, f.eks. X-Spam-Flag."
},
"folders": {
"account": "Konto",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Bedingung entfernen",
"sieve_editor": {
"line_count": "{count, plural, one {# Zeile} other {# Zeilen}}"
}
},
"validation_header_name": "Geben Sie einen Header-Namen ein, der nur die Buchstaben A–Z, Ziffern, Bindestriche und Unterstriche enthält, zum Beispiel X-Spam-Flag."
},
"folders": {
"account": "Konto",
+2 -1
View File
@@ -683,7 +683,8 @@
"remove_condition": "Remove condition",
"sieve_editor": {
"line_count": "{count, plural, one {# line} other {# lines}}"
}
},
"validation_header_name": "Enter a header name that uses only the letters A-Z, digits, hyphens and underscores, such as X-Spam-Flag."
},
"folders": {
"account": "Account",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Eliminar condición",
"sieve_editor": {
"line_count": "{count, plural, one {# línea} other {# líneas}}"
}
},
"validation_header_name": "Escribe un nombre de encabezado que solo use las letras A-Z, números, guiones y guiones bajos, por ejemplo X-Spam-Flag."
},
"folders": {
"account": "Cuenta",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "حذف شرط",
"sieve_editor": {
"line_count": "{count, plural, one {# خط} other {# خط}}"
}
},
"validation_header_name": "نام هدری وارد کنید که فقط از حروف A-Z، رقم، خط تیره و زیرخط تشکیل شده باشد، مانند X-Spam-Flag."
},
"folders": {
"account": "حساب",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Supprimer la condition",
"sieve_editor": {
"line_count": "{count, plural, one {# ligne} other {# lignes}}"
}
},
"validation_header_name": "Saisissez un nom d'en-tête composé uniquement des lettres A à Z, de chiffres, de tirets et de tirets bas, par exemple X-Spam-Flag."
},
"folders": {
"account": "Compte",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "הסר תנאי",
"sieve_editor": {
"line_count": "{count, plural, one {שורה אחת} two {שתי שורות} other {# שורות}}"
}
},
"validation_header_name": "הזן שם כותרת שכולל רק את האותיות A-Z, ספרות, מקפים וקווים תחתונים, למשל X-Spam-Flag."
},
"folders": {
"account": "חשבון",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Feltétel eltávolítása",
"sieve_editor": {
"line_count": "{count, plural, one {# sor} other {# sor}}"
}
},
"validation_header_name": "Adj meg egy fejlécnevet, amely csak az A–Z betűket, számjegyeket, kötőjeleket és aláhúzásjeleket tartalmazza, például X-Spam-Flag."
},
"folders": {
"account": "Fiók",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Rimuovi condizione",
"sieve_editor": {
"line_count": "{count, plural, one {# riga} other {# righe}}"
}
},
"validation_header_name": "Inserisci un nome di intestazione che usi solo le lettere A-Z, cifre, trattini e trattini bassi, ad esempio X-Spam-Flag."
},
"folders": {
"account": "Account",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "条件を削除",
"sieve_editor": {
"line_count": "{count, plural, other {#行}}"
}
},
"validation_header_name": "ヘッダー名には英字(A~Z)、数字、ハイフン、アンダースコアのみを使用してください(例: X-Spam-Flag)。"
},
"folders": {
"account": "アカウント",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "조건 제거",
"sieve_editor": {
"line_count": "{count, plural, other {#줄}}"
}
},
"validation_header_name": "헤더 이름은 영문자(A-Z), 숫자, 하이픈, 밑줄만 사용해 입력해 주세요(예: X-Spam-Flag)."
},
"folders": {
"account": "계정",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Noņemt nosacījumu",
"sieve_editor": {
"line_count": "{count, plural, zero {# rindas} one {# rinda} other {# rindas}}"
}
},
"validation_header_name": "Ievadiet galvenes nosaukumu, kurā ir tikai burti A–Z, cipari, defises un pasvītras, piemēram, X-Spam-Flag."
},
"folders": {
"account": "Konts",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Нөхцөлийг хасах",
"sieve_editor": {
"line_count": "{count, plural, one {# мөр} other {# мөр}}"
}
},
"validation_header_name": "Зөвхөн A-Z үсэг, тоо, зураас, доогуур зураас агуулсан толгойн нэр оруулна уу, жишээ нь X-Spam-Flag."
},
"folders": {
"account": "Бүртгэл",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Fjern vilkår",
"sieve_editor": {
"line_count": "{count, plural, one {# linje} other {# linjer}}"
}
},
"validation_header_name": "Skriv inn et navn på meldingshode som bare bruker bokstavene A–Z, sifre, bindestreker og understreker, for eksempel X-Spam-Flag."
},
"folders": {
"account": "Konto",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Voorwaarde verwijderen",
"sieve_editor": {
"line_count": "{count, plural, one {# regel} other {# regels}}"
}
},
"validation_header_name": "Voer een headernaam in met alleen de letters A-Z, cijfers, koppeltekens en underscores, zoals X-Spam-Flag."
},
"folders": {
"account": "Account",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Usuń warunek",
"sieve_editor": {
"line_count": "{count, plural, one {# wiersz} few {# wiersze} many {# wierszy} other {# wiersza}}"
}
},
"validation_header_name": "Wpisz nazwę nagłówka, która zawiera tylko litery A–Z, cyfry, łączniki i podkreślenia, na przykład X-Spam-Flag."
},
"folders": {
"account": "Konto",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Remover condição",
"sieve_editor": {
"line_count": "{count, plural, one {# linha} other {# linhas}}"
}
},
"validation_header_name": "Insira um nome de cabeçalho que use apenas as letras A-Z, algarismos, hífens e sublinhados, como X-Spam-Flag."
},
"folders": {
"account": "Conta",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Eliminați condiția",
"sieve_editor": {
"line_count": "{count, plural, one {# linie} few {# linii} other {# de linii}}"
}
},
"validation_header_name": "Introduceți un nume de antet care folosește doar literele A–Z, cifre, cratime și liniuțe de subliniere, de exemplu X-Spam-Flag."
},
"folders": {
"account": "Cont",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Удалить условие",
"sieve_editor": {
"line_count": "{count, plural, one {# строка} few {# строки} many {# строк} other {# строки}}"
}
},
"validation_header_name": "Введите имя заголовка, состоящее только из латинских букв A–Z, цифр, дефисов и подчёркиваний, например X-Spam-Flag."
},
"folders": {
"account": "Аккаунт",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Odstrániť podmienku",
"sieve_editor": {
"line_count": "{count, plural, one {# riadok} few {# riadky} many {# riadka} other {# riadkov}}"
}
},
"validation_header_name": "Zadajte názov hlavičky, ktorý obsahuje iba písmená A – Z, číslice, spojovníky a podčiarkovníky, napríklad X-Spam-Flag."
},
"folders": {
"account": "Účet",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Koşulu kaldır",
"sieve_editor": {
"line_count": "{count, plural, one {# satır} other {# satır}}"
}
},
"validation_header_name": "Yalnızca A-Z harfleri, rakamlar, kısa çizgiler ve alt çizgiler içeren bir başlık adı girin; örneğin X-Spam-Flag."
},
"folders": {
"account": "Hesap",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "Видалити умову",
"sieve_editor": {
"line_count": "{count, plural, one {# рядок} few {# рядки} many {# рядків} other {# рядка}}"
}
},
"validation_header_name": "Введіть назву заголовка, що містить лише латинські літери A–Z, цифри, дефіси та підкреслення, наприклад X-Spam-Flag."
},
"folders": {
"account": "Обліковий запис",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "移除條件",
"sieve_editor": {
"line_count": "{count, plural, other {# 行}}"
}
},
"validation_header_name": "請輸入僅包含字母 A-Z、數字、連字號和底線的標頭名稱,例如 X-Spam-Flag。"
},
"folders": {
"account": "帳號",
+2 -1
View File
@@ -670,7 +670,8 @@
"remove_condition": "移除条件",
"sieve_editor": {
"line_count": "{count, plural, other {# 行}}"
}
},
"validation_header_name": "请输入仅包含字母 A-Z、数字、连字符和下划线的标头名称,例如 X-Spam-Flag。"
},
"folders": {
"account": "账户",
+16 -2
View File
@@ -28,6 +28,7 @@ import {
updateFilterAction,
withMailboxTarget,
} from '../../lib/sieve/rule-actions';
import { isValidSieveHeaderName } from '../../lib/sieve/generator';
import type { Mailbox } from '../../api/types';
import type {
FilterRule,
@@ -197,13 +198,24 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
.map((cond) => {
if (isHasAny(cond)) return { ...cond, value: '' };
if (cond.field === 'size') return cond; // numeric, single-value only
if (typeof cond.value !== 'string') return cond; // already structured
return { ...cond, value: inputStringToValue(cond.value) };
// A keyboard's trailing space is never part of a header name.
const named = cond.field === 'header' ? { ...cond, headerName: cond.headerName?.trim() } : cond;
if (typeof named.value !== 'string') return named; // already structured
return { ...named, value: inputStringToValue(named.value) };
});
if (validConditions.length === 0) {
Alert.alert(t('settings.filters.validation_empty_conditions', 'At least one condition with a value is required'));
return;
}
// The server refuses the whole filter script over one header name it
// can't read (a space, a dot or a colon in it), so say so here.
if (validConditions.some((cond) => cond.field === 'header' && !isValidSieveHeaderName(cond.headerName ?? ''))) {
Alert.alert(t(
'settings.filters.validation_header_name',
'Enter a header name that uses only the letters A-Z, digits, hyphens and underscores, such as X-Spam-Flag.',
));
return;
}
const validActions = actions
.map((a) => withMailboxTarget(a, mailboxTargets))
.filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim());
@@ -313,6 +325,8 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
value={condition.headerName || ''}
onChangeText={(v) => updateCondition(index, { headerName: v })}
placeholder={t('settings.filters.header_name', 'Header name')}
autoCapitalize="none"
autoCorrect={false}
/>
)}
+83 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { generateScript } from '../generator';
import { generateScript, isValidSieveHeaderName } from '../generator';
import { parseScript } from '../parser';
import type { FilterRule } from '../types';
@@ -471,6 +471,88 @@ describe('generateScript', () => {
});
});
// The rules ride in a `/* */` comment, which the first `*/` ends, and each
// rule's code starts with a `# Rule:` comment, which a line break ends.
describe('text that could break out of its comment', () => {
const tricky = makeRule({
id: 'rule-*/',
name: 'Newsletters */ and more',
conditions: [
{ field: 'subject', comparator: 'contains', value: ['*/ spam', 'end */ here'] },
{ field: 'header', comparator: 'is', value: 'a*/b', headerName: 'X-Spam-Flag' },
],
actions: [{ type: 'move', value: 'News */ letters' }, { type: 'mark_read' }],
});
const vacation = { isEnabled: true, subject: 'Away */', textBody: 'Back soon */ really' };
it('keeps */ inside the metadata comment and reads every value back', () => {
const script = generateScript([tricky], vacation);
// The comment's first */ is its own end marker.
const begin = script.indexOf('/* @metadata:begin');
expect(script.indexOf('*/', begin)).toBe(script.indexOf('@metadata:end */') + '@metadata:end '.length);
const parsed = parseScript(script);
expect(parsed.isOpaque).toBe(false);
expect(parsed.rules).toEqual([tricky]);
expect(parsed.vacation).toEqual(vacation);
});
it('writes an escape that JSON, and so every older client, reads back as */', () => {
const script = generateScript([makeRule({ name: 'a */ b' })]);
const json = script.match(/@metadata:begin\n(.*)\n@metadata:end/)![1];
expect(json).toContain('"name":"a *\\/ b"');
expect(JSON.parse(json).rules[0].name).toBe('a */ b');
});
it('keeps a line break in a rule name out of the script and still recognises the block', () => {
const rule = makeRule({ name: 'Line one\r\nline two\nthree' });
const script = generateScript([rule]);
expect(script).toContain('\n# Rule: Line one line two three\nif ');
const parsed = parseScript(script);
// Not kept a second time as an external rule.
expect(parsed.rules).toEqual([rule]);
expect(parseScript(generateScript(parsed.rules, undefined, { externalRequires: parsed.externalRequires })).rules)
.toEqual([rule]);
});
});
// sieve-rs (Stalwart 0.16 and 1.0) refuses the whole script over one of
// these, the auto-reply and every other rule with it.
describe('header names the server would refuse', () => {
it.each(['X-Spam Flag', 'X-Foo.Bar', 'X-Foo:', 'X-"Quoted"', 'Grüße'])(
'skips a rule that tests %j and keeps it in the metadata',
(headerName) => {
const bad = makeRule({
id: 'bad',
name: 'Bad',
conditions: [{ field: 'header', comparator: 'contains', value: 'yes', headerName }],
});
const good = makeRule({ id: 'good', name: 'Good' });
const script = generateScript([bad, good]);
const code = script.slice(script.indexOf('@metadata:end */'));
expect(code).not.toContain('# Rule: Bad');
expect(code).toContain('# Rule: Good');
expect(parseScript(script).rules).toEqual([bad, good]);
},
);
it('accepts letters, digits, hyphens and underscores, blanks around them aside', () => {
expect(isValidSieveHeaderName('X-Spam_Flag2')).toBe(true);
expect(isValidSieveHeaderName(' X-Spam-Flag ')).toBe(true);
expect(isValidSieveHeaderName('')).toBe(false);
expect(isValidSieveHeaderName('X Spam')).toBe(false);
const script = generateScript([makeRule({
conditions: [{ field: 'header', comparator: 'contains', value: 'yes', headerName: ' X-Spam-Flag ' }],
})]);
expect(script).toContain('if header :contains "X-Spam-Flag" "yes" {');
});
});
describe('round-trip', () => {
it('preserves rules through generate → parse cycle', () => {
const rules: FilterRule[] = [
+36 -3
View File
@@ -22,6 +22,29 @@ function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// What sieve-rs (Stalwart 0.16 and 1.0) accepts as a header name. Anything
// else - a space, a dot, a colon - makes the server refuse the whole script.
const HEADER_NAME = /^[A-Za-z0-9_-]+$/;
/** Whether `name` can go into a Sieve `header` test (surrounding blanks aside). */
export function isValidSieveHeaderName(name: string): boolean {
return HEADER_NAME.test(name.trim());
}
/**
* A rule's name as it appears in its `# Rule:` comment, which ends at the
* line break: a CR or LF in the name would have turned the rest of it into
* script. The parser matches the comment back to the rule by this form.
*/
export function ruleCommentName(name: string): string {
return name.replace(/[\r\n]+/g, ' ').trim();
}
/** A `header` condition whose name the server would refuse. */
function hasInvalidHeaderName(condition: FilterCondition): boolean {
return condition.field === 'header' && !!condition.headerName?.trim() && !isValidSieveHeaderName(condition.headerName);
}
// Normalise the condition value to a non-empty string array. Single-value
// conditions stay one-element; arrays are filtered for empty strings.
function toValueList(value: string | string[]): string[] {
@@ -78,7 +101,7 @@ function generateCondition(condition: FilterCondition): string {
}
const headerName = field === 'header'
? (condition.headerName || 'X-Unknown')
? (condition.headerName?.trim() || 'X-Unknown')
: HEADER_MAP[field];
// A field this client does not know (a rule authored by a newer webmail)
@@ -290,7 +313,10 @@ export function generateScript(
if (options.includeVacation) {
metadata.includeVacation = true;
}
const metadataJson = JSON.stringify(metadata);
// The rules ride in a `/* */` comment, which the first `*/` ends: one in a
// rule name or value cut the metadata short and turned the rest into
// script. JSON reads `\/` back as `/`, so every client parses it as before.
const metadataJson = JSON.stringify(metadata).replace(/\*\//g, '*\\/');
const lines: string[] = [];
lines.push('/* @metadata:begin');
@@ -334,9 +360,16 @@ export function generateScript(
console.warn(`[filters] Skipping rule "${rule.name}": empty conditions or actions`);
continue;
}
// The server would refuse the whole script, every other rule and the
// auto-reply with it. Dropping just the condition could widen an "all
// of" rule, so the rule sits out; the metadata keeps it for editing.
if (rule.conditions.some(hasInvalidHeaderName)) {
console.warn(`[filters] Skipping rule "${rule.name}": a header name the server would refuse`);
continue;
}
lines.push('');
lines.push(`# Rule: ${rule.name}`);
lines.push(`# Rule: ${ruleCommentName(rule.name)}`);
const conditions = rule.conditions.map(generateCondition);
let conditionStr: string;
+3 -1
View File
@@ -7,6 +7,7 @@ import type {
FilterRule,
VacationSieveConfig,
} from './types';
import { ruleCommentName } from './generator';
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
@@ -838,7 +839,8 @@ export function parseScript(content: string): ParseResult {
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
if (match) {
const name = match[1].trim();
if (bulwarkRules.some(b => b.name === name)) return false;
// The comment carries the name without line breaks (ruleCommentName).
if (bulwarkRules.some(b => ruleCommentName(b.name) === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
return true;