fix: keep filter scripts valid when a rule contains */ or a bad header name

The rule metadata is JSON inside a Sieve bracket comment, so a `*/` in a
rule name, a value or the vacation text ended the comment early and
broke the script. A line break in a rule name escaped the `# Rule:`
comment line. And a header name outside `[A-Za-z0-9_-]` made Stalwart
(0.16 and 1.0) refuse the whole script, every other rule with it.

- `*/` in the metadata JSON is written as `*\/`, which `JSON.parse`
  reads back as `/`, so older versions still parse it.
- Line breaks in a rule name are flattened on the `# Rule:` line, and
  the parser matches its own blocks the same way.
- The rule editor refuses to save an invalid header name, with a
  translated message. The generator skips such a rule (it stays in the
  metadata, so it can be fixed) instead of writing a script the server
  refuses.
This commit is contained in:
Linus Rath
2026-09-25 19:29:30 +02:00
parent 44c77b268d
commit 4f5e667a53
33 changed files with 254 additions and 8 deletions
@@ -0,0 +1,64 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { toast } from '@/stores/toast-store';
import { FilterRuleModal } from '../filter-rule-modal';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
function headerRule(headerName: string): FilterRule {
return {
id: 'rule-1',
name: 'Spam flag',
enabled: true,
matchType: 'all',
conditions: [{ field: 'header', comparator: 'contains', value: 'yes', headerName }],
actions: [{ type: 'discard' }],
stopProcessing: false,
};
}
// Stalwart's Sieve compiler (sieve-rs, on 0.16 and 1.0) refuses a header name
// outside [A-Za-z0-9_-], and with it the whole script, so every other rule
// would stop running too. The modal keeps such a rule from being saved.
describe('FilterRuleModal header names', () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear();
});
it.each(['X-Spam Flag', 'X-Foo.Bar', ''])('blocks saving the header name %j', (headerName) => {
const onSave = vi.fn();
render(<FilterRuleModal rule={headerRule(headerName)} mailboxes={[]} onSave={onSave} onClose={() => {}} />);
fireEvent.click(screen.getByText('save'));
expect(onSave).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith('validation_header_name');
});
it('saves a valid header name, trimmed', () => {
const onSave = vi.fn();
render(<FilterRuleModal rule={headerRule(' X-Spam_Flag-2 ')} mailboxes={[]} onSave={onSave} onClose={() => {}} />);
fireEvent.click(screen.getByText('save'));
expect(toast.error).not.toHaveBeenCalled();
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave.mock.calls[0][0].conditions).toEqual([
{ field: 'header', comparator: 'contains', value: 'yes', headerName: 'X-Spam_Flag-2' },
]);
});
it('blocks a name typed into the header field', () => {
const onSave = vi.fn();
render(<FilterRuleModal rule={headerRule('X-Ok')} mailboxes={[]} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByPlaceholderText('header_name'), { target: { value: 'X-Mailer: Foo' } });
fireEvent.click(screen.getByText('save'));
expect(onSave).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith('validation_header_name');
});
});
+9 -1
View File
@@ -16,6 +16,7 @@ import type {
FilterActionType,
} from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { isValidSieveHeaderName } from "@/lib/sieve/generator";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
@@ -165,11 +166,18 @@ export function FilterRuleModal({
if (typeof c.value !== "string") return c; // already structured
const parsed = inputStringToValue(c.value);
return { ...c, value: parsed };
});
})
.map((c) => (c.field === "header" ? { ...c, headerName: (c.headerName ?? "").trim() } : c));
if (validConditions.length === 0) {
toast.error(t("validation_empty_conditions"));
return;
}
// Stalwart's Sieve compiler refuses any other header name, and with it
// the whole script, so every other rule would stop working too.
if (validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? ""))) {
toast.error(t("validation_header_name"));
return;
}
const validActions = actions
.map((a) => {
+68 -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 '@/lib/jmap/sieve-types';
@@ -42,6 +42,40 @@ describe('generateScript', () => {
expect(script).toContain('fileinto "Archive";');
});
describe('text that could break out of a comment', () => {
it('escapes */ inside the metadata comment so the JSON cannot end it early', () => {
const script = generateScript(
[makeRule({
name: 'Invoices */ keep',
conditions: [{ field: 'subject', comparator: 'contains', value: ['a*/b', '*/'] }],
actions: [{ type: 'move', value: 'Bills*/2026' }],
})],
{ isEnabled: true, subject: 'Away */', textBody: 'Back soon */ promise' },
);
const comment = script.slice(script.indexOf('/* @metadata:begin'), script.indexOf('@metadata:end */'));
expect(comment).not.toContain('*/');
expect(comment).toContain('Invoices *\\/ keep');
// `\/` is a JSON escape, so the JSON reads back exactly as written.
const json = script.match(/@metadata:begin\n(.*)\n@metadata:end/)![1];
const metadata = JSON.parse(json);
expect(metadata.rules[0].name).toBe('Invoices */ keep');
expect(metadata.rules[0].conditions[0].value).toEqual(['a*/b', '*/']);
expect(metadata.vacation.textBody).toBe('Back soon */ promise');
});
it('keeps the metadata byte-identical when nothing needs escaping', () => {
const rules = [makeRule({ name: 'Plain / slash and * star' })];
const json = generateScript(rules).match(/@metadata:begin\n(.*)\n@metadata:end/)![1];
expect(json).toBe(JSON.stringify({ version: 1, rules }));
});
it('keeps a line break in the rule name out of the # Rule: line', () => {
const script = generateScript([makeRule({ name: 'Line one\r\ndiscard;\nstop' })]);
expect(script).toContain('# Rule: Line one discard; stop\n');
expect(script).not.toMatch(/^discard;$/m);
});
});
describe('condition fields', () => {
it('maps to field to "To" header', () => {
const script = generateScript([makeRule({
@@ -71,6 +105,39 @@ describe('generateScript', () => {
expect(script).toContain('header :contains "X-Custom" "test"');
});
// sieve-rs (Stalwart 0.16 and 1.0) accepts only [A-Za-z0-9_-]+ and refuses
// the whole script otherwise, taking every other rule down with it.
it.each(['X-Spam Flag', 'X-Foo.Bar', 'X-Foo:', 'X-"Quoted"', 'Überschrift'])(
'skips a rule whose header name %j Stalwart would refuse, keeping the others',
(headerName) => {
const script = generateScript([
makeRule({ id: 'bad', name: 'Bad header', conditions: [{ field: 'header', comparator: 'contains', value: 'yes', headerName }] }),
makeRule({ id: 'good', name: 'Good rule' }),
]);
const code = script.slice(script.indexOf('@metadata:end */'));
expect(code).not.toContain('Bad header');
expect(code).not.toContain('"yes"');
expect(code).toContain('# Rule: Good rule');
// Kept in the metadata, so the rule can still be corrected in the UI.
expect(parseScript(script).rules.map((r) => r.id)).toEqual(['bad', 'good']);
},
);
it('accepts letters, digits, hyphens and underscores, trimming stray spaces', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'header', comparator: 'is', value: 'yes', headerName: ' X-Spam_Flag-2 ' }],
})]);
expect(script).toContain('header :is "X-Spam_Flag-2" "yes"');
});
it('validates header names with the same rule the server applies', () => {
expect(isValidSieveHeaderName('X-Spam-Flag')).toBe(true);
expect(isValidSieveHeaderName('x_custom_2')).toBe(true);
expect(isValidSieveHeaderName('')).toBe(false);
expect(isValidSieveHeaderName('X-Spam Flag')).toBe(false);
expect(isValidSieveHeaderName('X-Foo.Bar')).toBe(false);
});
it('handles size greater_than', () => {
const script = generateScript([makeRule({
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
+39
View File
@@ -25,6 +25,45 @@ describe('parseScript', () => {
expect(result.rules).toEqual(rules);
});
it('round-trips names and values containing */, which would end the metadata comment', () => {
const rules = [
makeRule({
id: 'r1',
name: 'Receipts */ and more',
conditions: [
{ field: 'subject', comparator: 'contains', value: ['*/', 'a */ b'] },
{ field: 'body', comparator: 'contains', value: 'end */ of comment' },
],
actions: [{ type: 'add_label', value: 'bills' }],
}),
makeRule({ id: 'r2', name: 'Second' }),
];
const vacation = { isEnabled: true, subject: 'Out */', textBody: 'Reply */ later' };
const result = parseScript(generateScript(rules, vacation));
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual(rules);
expect(result.vacation).toEqual(vacation);
});
it('reads back metadata a newer Bulwark wrote with */ escaped (older clients parse it too)', () => {
// JSON.parse turns `\/` back into `/`, so no reader needs to know.
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x","name":"a *\\/ b","enabled":true,"matchType":"all",'
+ '"conditions":[{"field":"from","comparator":"contains","value":"c"}],"actions":[{"type":"discard"}],"stopProcessing":false}]}\n@metadata:end */\n';
expect(parseScript(script).rules[0].name).toBe('a */ b');
});
it('recognises its own rule block when the rule name had a line break', () => {
// The # Rule: line flattens the break; the block must still be matched to
// the rule, or the next save would copy it as an "external" rule.
const rules = [makeRule({ name: 'Two\nlines' })];
const result = parseScript(generateScript(rules));
expect(result.rules).toEqual(rules);
expect(result.rules.some((r) => r.origin === 'external')).toBe(false);
});
it('parses external rules when no Bulwark metadata is present', () => {
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
expect(result.isOpaque).toBe(false);
+44 -5
View File
@@ -12,6 +12,33 @@ function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* Header names Stalwart's Sieve compiler accepts (sieve-rs, 0.16 and 1.0).
* Anything else - a space, a dot, a colon - fails the whole script with
* "Invalid header name".
*/
const SIEVE_HEADER_NAME_RE = /^[A-Za-z0-9_-]+$/;
export function isValidSieveHeaderName(name: string): boolean {
return SIEVE_HEADER_NAME_RE.test(name);
}
/** The header a condition tests; a header condition without a name tests X-Unknown. */
function conditionHeaderName(condition: FilterCondition): string {
return condition.field === 'header'
? (condition.headerName?.trim() || 'X-Unknown')
: HEADER_MAP[condition.field];
}
/**
* A rule name as it appears on the `# Rule:` comment line. A hash comment
* ends at the line break, so one inside the name would turn the rest into
* script code.
*/
export function ruleCommentName(name: string): string {
return name.replace(/[\r\n]+/g, ' ');
}
// 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[] {
@@ -67,9 +94,7 @@ function generateCondition(condition: FilterCondition): string {
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
}
const headerName = field === 'header'
? (condition.headerName || 'X-Unknown')
: HEADER_MAP[field];
const headerName = conditionHeaderName(condition);
switch (comparator) {
case 'contains':
@@ -272,7 +297,11 @@ export function generateScript(
if (options.includeVacation) {
metadata.includeVacation = true;
}
const metadataJson = JSON.stringify(metadata);
// The JSON sits in a bracket comment, which the first `*/` ends - one in a
// rule name, a value or the vacation text would leak the rest of the JSON
// into the script. `\/` is a valid JSON escape for `/`, so every JSON
// parser, older Bulwark versions included, reads it back unchanged.
const metadataJson = JSON.stringify(metadata).replace(/\*\//g, '*\\/');
const lines: string[] = [];
lines.push('/* @metadata:begin');
@@ -316,9 +345,19 @@ export function generateScript(
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
continue;
}
// The server would refuse the whole script, every other rule with it.
// The rule stays in the metadata, so it can still be corrected.
const badHeader = rule.conditions
.filter(c => c.field === 'header')
.map(conditionHeaderName)
.find(name => !isValidSieveHeaderName(name));
if (badHeader !== undefined) {
debug.warn('filters', `Skipping rule "${rule.name}": invalid header name "${badHeader}"`);
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
@@ -8,6 +8,7 @@ import type {
VacationSieveConfig,
} from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
import { ruleCommentName } from '@/lib/sieve/generator';
export interface ParseResult {
rules: FilterRule[];
@@ -834,7 +835,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 line carries the name with its line breaks flattened.
if (bulwarkRules.some(b => ruleCommentName(b.name).trim() === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
return true;
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "اسم القاعدة مطلوب",
"validation_empty_conditions": "مطلوب شرط واحد على الأقل بقيمة",
"validation_empty_actions": "مطلوب إجراء واحد على الأقل",
"validation_header_name": "يمكن أن يحتوي اسم الترويسة على الأحرف A-Z والأرقام والشرطات والشرطات السفلية فقط",
"templates_section": "البدء من قالب",
"template_newsletters": "نقل النشرات البريدية إلى مجلد",
"template_receipts": "أرشفة الإيصالات تلقائيًا",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "El nom de la regla és obligatori",
"validation_empty_conditions": "Cal com a mínim una condició amb un valor",
"validation_empty_actions": "Cal com a mínim una acció",
"validation_header_name": "Els noms de capçalera només poden contenir les lletres A-Z, xifres, guionets i guions baixos",
"templates_section": "Comença des d'una plantilla",
"template_newsletters": "Mou els butlletins a una carpeta",
"template_receipts": "Arxiva automàticament els rebuts",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Název pravidla je vyžadován",
"validation_empty_conditions": "Je vyžadována alespoň jedna podmínka s hodnotou",
"validation_empty_actions": "Je vyžadována alespoň jedna akce",
"validation_header_name": "Názvy hlaviček smí obsahovat jen písmena A-Z, číslice, pomlčky a podtržítka",
"templates_section": "Začít ze šablony",
"template_newsletters": "Přesunout newslettery do složky",
"template_receipts": "Automaticky archivovat účtenky",
+1
View File
@@ -2087,6 +2087,7 @@
"validation_empty_name": "Regelnavn er påkrævet",
"validation_empty_conditions": "Mindst én betingelse med en værdi er påkrævet",
"validation_empty_actions": "Mindst én handling er påkrævet",
"validation_header_name": "Headernavne må kun indeholde bogstaverne A-Z, cifre, bindestreger og understregninger",
"templates_section": "Start fra skabelon",
"template_newsletters": "Flyt nyhedsbreve til mappe",
"template_receipts": "Auto-arkiver kvitteringer",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Regelname ist erforderlich",
"validation_empty_conditions": "Mindestens eine Bedingung mit einem Wert ist erforderlich",
"validation_empty_actions": "Mindestens eine Aktion ist erforderlich",
"validation_header_name": "Header-Namen dürfen nur die Buchstaben A-Z, Ziffern, Bindestriche und Unterstriche enthalten",
"templates_section": "Von Vorlage starten",
"template_newsletters": "Newsletter in Ordner verschieben",
"template_receipts": "Quittungen automatisch archivieren",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "Rule name is required",
"validation_empty_conditions": "At least one condition with a value is required",
"validation_empty_actions": "At least one action is required",
"validation_header_name": "Header names may only contain the letters A-Z, digits, hyphens and underscores",
"templates_section": "Start from template",
"template_newsletters": "Move newsletters to folder",
"template_receipts": "Auto-archive receipts",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "El nombre de la regla es obligatorio",
"validation_empty_conditions": "Se requiere al menos una condición con un valor",
"validation_empty_actions": "Se requiere al menos una acción",
"validation_header_name": "Los nombres de encabezado solo pueden contener las letras A-Z, dígitos, guiones y guiones bajos",
"templates_section": "Usar una plantilla",
"template_newsletters": "Mover newsletters a carpeta",
"template_receipts": "Archivar recibos automáticamente",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "نام قانون الزامی است",
"validation_empty_conditions": "حداقل یک شرط با مقدار الزامی است",
"validation_empty_actions": "حداقل یک عملیات الزامی است",
"validation_header_name": "نام هدر فقط می‌تواند شامل حروف A-Z، ارقام، خط تیره و زیرخط باشد",
"templates_section": "شروع از قالب",
"template_newsletters": "انتقال خبرنامه‌ها به پوشه",
"template_receipts": "بایگانی خودکار رسیدها",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Le nom de la règle est requis",
"validation_empty_conditions": "Au moins une condition avec une valeur est requise",
"validation_empty_actions": "Au moins une action est requise",
"validation_header_name": "Les noms d'en-tête ne peuvent contenir que les lettres A-Z, des chiffres, des traits d'union et des tirets bas",
"templates_section": "Partir d'un modèle",
"template_newsletters": "Déplacer les newsletters dans un dossier",
"template_receipts": "Archiver les reçus automatiquement",
+1
View File
@@ -2004,6 +2004,7 @@
"validation_empty_name": "נדרש שם כלל",
"validation_empty_conditions": "נדרש לפחות תנאי אחד עם ערך",
"validation_empty_actions": "נדרשת לפחות פעולה אחת",
"validation_header_name": "שמות כותרות יכולים להכיל רק את האותיות A-Z, ספרות, מקפים וקווים תחתונים",
"templates_section": "התחל מהתבנית",
"template_newsletters": "העבר ניוזלטרים לתיקיה",
"template_receipts": "העבר קבלות לארכיון אוטומטי",
+1
View File
@@ -2087,6 +2087,7 @@
"validation_empty_name": "A szabály neve kötelező",
"validation_empty_conditions": "Legalább egy feltétel szükséges értékkel",
"validation_empty_actions": "Legalább egy művelet szükséges",
"validation_header_name": "A fejlécnevek csak az A-Z betűket, számjegyeket, kötőjeleket és aláhúzásjeleket tartalmazhatják",
"templates_section": "Kezdés sablonból",
"template_newsletters": "Hírlevelek áthelyezése mappába",
"template_receipts": "Nyugták automatikus archiválása",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Il nome della regola è obbligatorio",
"validation_empty_conditions": "È necessaria almeno una condizione con un valore",
"validation_empty_actions": "È necessaria almeno un'azione",
"validation_header_name": "I nomi delle intestazioni possono contenere solo le lettere A-Z, cifre, trattini e trattini bassi",
"templates_section": "Parti da un modello",
"template_newsletters": "Sposta newsletter in cartella",
"template_receipts": "Archivia ricevute automaticamente",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "ルール名は必須です",
"validation_empty_conditions": "値を持つ条件が少なくとも1つ必要です",
"validation_empty_actions": "少なくとも1つのアクションが必要です",
"validation_header_name": "ヘッダー名に使用できるのは英字(A-Z)、数字、ハイフン、アンダースコアのみです",
"templates_section": "テンプレートから作成",
"template_newsletters": "ニュースレターをフォルダに移動",
"template_receipts": "領収書を自動アーカイブ",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "규칙 이름을 입력해 주세요",
"validation_empty_conditions": "값을 포함한 조건이 하나 이상 필요해요",
"validation_empty_actions": "동작이 하나 이상 필요해요",
"validation_header_name": "헤더 이름에는 영문자(A-Z), 숫자, 하이픈, 밑줄만 쓸 수 있어요",
"templates_section": "템플릿으로 시작하기",
"template_newsletters": "뉴스레터를 특정 폴더로 이동",
"template_receipts": "영수증 자동 보관",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Nepieciešams noteikuma nosaukums",
"validation_empty_conditions": "Nepieciešams vismaz viens nosacījums ar vērtību",
"validation_empty_actions": "Nepieciešama vismaz viena darbība",
"validation_header_name": "Galvenes nosaukumā drīkst būt tikai burti A-Z, cipari, defises un pasvītras",
"templates_section": "Sākt no veidnes",
"template_newsletters": "Pārvietot ziņu izdevumus uz mapi",
"template_receipts": "Automātiski arhivēt rēķinus",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "Дүрмийн нэр шаардлагатай",
"validation_empty_conditions": "Дор хаяж нэг утга бүхий нөхцөл шаардлагатай",
"validation_empty_actions": "Дор хаяж нэг үйлдэл хийх шаардлагатай",
"validation_header_name": "Толгойн нэрэнд зөвхөн A-Z үсэг, тоо, зураас, доогуур зураас ашиглаж болно",
"templates_section": "Загвар",
"template_newsletters": "Мэдээллийн хуудсыг хавтас руу зөөх",
"template_receipts": "Автоматаар архивлах баримт",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "Regelnavn må fylles ut",
"validation_empty_conditions": "Minst ett vilkår må ha en verdi",
"validation_empty_actions": "Du må legge til minst én handling",
"validation_header_name": "Navn på meldingshoder kan bare inneholde bokstavene A-Z, sifre, bindestreker og understreker",
"templates_section": "Start med en mal",
"template_newsletters": "Flytt nyhetsbrev til mappe",
"template_receipts": "Arkiver kvitteringer automatisk",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Regelnaam is verplicht",
"validation_empty_conditions": "Minstens één voorwaarde met een waarde is vereist",
"validation_empty_actions": "Minstens één actie is vereist",
"validation_header_name": "Headernamen mogen alleen de letters A-Z, cijfers, koppeltekens en underscores bevatten",
"templates_section": "Starten vanuit sjabloon",
"template_newsletters": "Nieuwsbrieven naar map verplaatsen",
"template_receipts": "Bonnen automatisch archiveren",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Nazwa reguły jest wymagana",
"validation_empty_conditions": "Wymagany jest co najmniej jeden warunek z wartością",
"validation_empty_actions": "Wymagana jest co najmniej jedna akcja",
"validation_header_name": "Nazwy nagłówków mogą zawierać tylko litery A-Z, cyfry, łączniki i podkreślenia",
"templates_section": "Rozpocznij od szablonu",
"template_newsletters": "Przenieś newslettery do folderu",
"template_receipts": "Automatycznie archiwizuj rachunki",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "O nome da regra é obrigatório",
"validation_empty_conditions": "É necessária pelo menos uma condição com um valor",
"validation_empty_actions": "É necessária pelo menos uma ação",
"validation_header_name": "Os nomes de cabeçalho só podem conter as letras A-Z, algarismos, hífenes e sublinhados",
"templates_section": "Começar com modelo",
"template_newsletters": "Mover newsletters para pasta",
"template_receipts": "Arquivar recibos automaticamente",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "Numele regulii este obligatoriu",
"validation_empty_conditions": "Este necesară cel puțin o condiție cu o valoare",
"validation_empty_actions": "Este necesară cel puțin o acțiune",
"validation_header_name": "Numele antetelor pot conține doar literele A-Z, cifre, cratime și liniuțe de subliniere",
"templates_section": "Începeți de la șablon",
"template_newsletters": "Mutați buletinele informative în dosar",
"template_receipts": "Arhivarea automată a confirmărilor de primire",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Требуется название правила",
"validation_empty_conditions": "Требуется хотя бы одно условие со значением",
"validation_empty_actions": "Требуется хотя бы одно действие",
"validation_header_name": "Имя заголовка может содержать только буквы A-Z, цифры, дефисы и подчёркивания",
"templates_section": "Начать с шаблона",
"template_newsletters": "Перемещать рассылки в папку",
"template_receipts": "Автоархивировать квитанции",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "Názov pravidla je povinný",
"validation_empty_conditions": "Je potrebná aspoň jedna podmienka s hodnotou",
"validation_empty_actions": "Je potrebná aspoň jedna akcia",
"validation_header_name": "Názvy hlavičiek môžu obsahovať iba písmená A-Z, číslice, spojovníky a podčiarkovníky",
"templates_section": "Začať zo šablóny",
"template_newsletters": "Presunúť newslettery do priečinka",
"template_receipts": "Automaticky archivovať účtenky",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Kural adı gerekli",
"validation_empty_conditions": "En az bir değeri olan koşul gerekli",
"validation_empty_actions": "En az bir işlem gerekli",
"validation_header_name": "Başlık adları yalnızca A-Z harflerini, rakamları, kısa çizgileri ve alt çizgileri içerebilir",
"templates_section": "Şablondan başla",
"template_newsletters": "Bültenleri klasöre taşı",
"template_receipts": "Makbuzları otomatik arşivle",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "Потрібно вказати назву правила",
"validation_empty_conditions": "Потрібна принаймні одна умова зі значенням",
"validation_empty_actions": "Необхідна принаймні одна дія",
"validation_header_name": "Назва заголовка може містити лише літери A-Z, цифри, дефіси та підкреслення",
"templates_section": "Почніть із шаблону",
"template_newsletters": "Перемістити інформаційні бюлетені в папку",
"template_receipts": "Автоматичне архівування квитанцій",
+1
View File
@@ -2091,6 +2091,7 @@
"validation_empty_name": "規則名稱為必填欄位",
"validation_empty_conditions": "至少需要一個具有值的條件",
"validation_empty_actions": "至少需要一個動作",
"validation_header_name": "標頭名稱只能包含字母 A-Z、數字、連字號和底線",
"templates_section": "從範本開始",
"template_newsletters": "將訂閱郵件移至信件匣",
"template_receipts": "自動封存收據",
+1
View File
@@ -2084,6 +2084,7 @@
"validation_empty_name": "规则名称为必填项",
"validation_empty_conditions": "至少需要一个具有值的条件",
"validation_empty_actions": "至少需要执行一项操作",
"validation_header_name": "标头名称只能包含字母 A-Z、数字、连字符和下划线",
"templates_section": "从模板开始",
"template_newsletters": "将订阅邮件移至文件夹",
"template_receipts": "自动归档收据",