mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -2091,6 +2091,7 @@
|
||||
"validation_empty_name": "اسم القاعدة مطلوب",
|
||||
"validation_empty_conditions": "مطلوب شرط واحد على الأقل بقيمة",
|
||||
"validation_empty_actions": "مطلوب إجراء واحد على الأقل",
|
||||
"validation_header_name": "يمكن أن يحتوي اسم الترويسة على الأحرف A-Z والأرقام والشرطات والشرطات السفلية فقط",
|
||||
"templates_section": "البدء من قالب",
|
||||
"template_newsletters": "نقل النشرات البريدية إلى مجلد",
|
||||
"template_receipts": "أرشفة الإيصالات تلقائيًا",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2091,6 +2091,7 @@
|
||||
"validation_empty_name": "نام قانون الزامی است",
|
||||
"validation_empty_conditions": "حداقل یک شرط با مقدار الزامی است",
|
||||
"validation_empty_actions": "حداقل یک عملیات الزامی است",
|
||||
"validation_header_name": "نام هدر فقط میتواند شامل حروف A-Z، ارقام، خط تیره و زیرخط باشد",
|
||||
"templates_section": "شروع از قالب",
|
||||
"template_newsletters": "انتقال خبرنامهها به پوشه",
|
||||
"template_receipts": "بایگانی خودکار رسیدها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2004,6 +2004,7 @@
|
||||
"validation_empty_name": "נדרש שם כלל",
|
||||
"validation_empty_conditions": "נדרש לפחות תנאי אחד עם ערך",
|
||||
"validation_empty_actions": "נדרשת לפחות פעולה אחת",
|
||||
"validation_header_name": "שמות כותרות יכולים להכיל רק את האותיות A-Z, ספרות, מקפים וקווים תחתונים",
|
||||
"templates_section": "התחל מהתבנית",
|
||||
"template_newsletters": "העבר ניוזלטרים לתיקיה",
|
||||
"template_receipts": "העבר קבלות לארכיון אוטומטי",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "領収書を自動アーカイブ",
|
||||
|
||||
@@ -2084,6 +2084,7 @@
|
||||
"validation_empty_name": "규칙 이름을 입력해 주세요",
|
||||
"validation_empty_conditions": "값을 포함한 조건이 하나 이상 필요해요",
|
||||
"validation_empty_actions": "동작이 하나 이상 필요해요",
|
||||
"validation_header_name": "헤더 이름에는 영문자(A-Z), 숫자, 하이픈, 밑줄만 쓸 수 있어요",
|
||||
"templates_section": "템플릿으로 시작하기",
|
||||
"template_newsletters": "뉴스레터를 특정 폴더로 이동",
|
||||
"template_receipts": "영수증 자동 보관",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2091,6 +2091,7 @@
|
||||
"validation_empty_name": "Дүрмийн нэр шаардлагатай",
|
||||
"validation_empty_conditions": "Дор хаяж нэг утга бүхий нөхцөл шаардлагатай",
|
||||
"validation_empty_actions": "Дор хаяж нэг үйлдэл хийх шаардлагатай",
|
||||
"validation_header_name": "Толгойн нэрэнд зөвхөн A-Z үсэг, тоо, зураас, доогуур зураас ашиглаж болно",
|
||||
"templates_section": "Загвар",
|
||||
"template_newsletters": "Мэдээллийн хуудсыг хавтас руу зөөх",
|
||||
"template_receipts": "Автоматаар архивлах баримт",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2084,6 +2084,7 @@
|
||||
"validation_empty_name": "Требуется название правила",
|
||||
"validation_empty_conditions": "Требуется хотя бы одно условие со значением",
|
||||
"validation_empty_actions": "Требуется хотя бы одно действие",
|
||||
"validation_header_name": "Имя заголовка может содержать только буквы A-Z, цифры, дефисы и подчёркивания",
|
||||
"templates_section": "Начать с шаблона",
|
||||
"template_newsletters": "Перемещать рассылки в папку",
|
||||
"template_receipts": "Автоархивировать квитанции",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2084,6 +2084,7 @@
|
||||
"validation_empty_name": "Потрібно вказати назву правила",
|
||||
"validation_empty_conditions": "Потрібна принаймні одна умова зі значенням",
|
||||
"validation_empty_actions": "Необхідна принаймні одна дія",
|
||||
"validation_header_name": "Назва заголовка може містити лише літери A-Z, цифри, дефіси та підкреслення",
|
||||
"templates_section": "Почніть із шаблону",
|
||||
"template_newsletters": "Перемістити інформаційні бюлетені в папку",
|
||||
"template_receipts": "Автоматичне архівування квитанцій",
|
||||
|
||||
@@ -2091,6 +2091,7 @@
|
||||
"validation_empty_name": "規則名稱為必填欄位",
|
||||
"validation_empty_conditions": "至少需要一個具有值的條件",
|
||||
"validation_empty_actions": "至少需要一個動作",
|
||||
"validation_header_name": "標頭名稱只能包含字母 A-Z、數字、連字號和底線",
|
||||
"templates_section": "從範本開始",
|
||||
"template_newsletters": "將訂閱郵件移至信件匣",
|
||||
"template_receipts": "自動封存收據",
|
||||
|
||||
@@ -2084,6 +2084,7 @@
|
||||
"validation_empty_name": "规则名称为必填项",
|
||||
"validation_empty_conditions": "至少需要一个具有值的条件",
|
||||
"validation_empty_actions": "至少需要执行一项操作",
|
||||
"validation_header_name": "标头名称只能包含字母 A-Z、数字、连字符和下划线",
|
||||
"templates_section": "从模板开始",
|
||||
"template_newsletters": "将订阅邮件移至文件夹",
|
||||
"template_receipts": "自动归档收据",
|
||||
|
||||
Reference in New Issue
Block a user