Feature: extended filter rules — attachment field + multi-value conditions

Adds an "Attachment" condition field (is present / of type <ext>) backed by
the RFC 5703 Sieve mime extension, matching the filename in both
Content-Disposition and Content-Type headers so real-world senders that only
put the name in Content-Type (Microsoft SMTPSVC, etc.) are caught. Users type
extensions (pdf, doc) not MIME types.

Also makes each text condition accept comma-separated multiple values emitted
as a Sieve string list (OR within the condition), so "(domain1 OR domain2)
AND attachment pdf/xml" is expressible in one rule. value is now string |
string[] (single-value rules stay strings -> backward compatible). New filter
locale keys in all 17 locales.
This commit is contained in:
dealerweb
2026-05-30 15:45:33 +02:00
committed by Linus Rath
parent 229992853b
commit 8353b28b33
22 changed files with 431 additions and 64 deletions
+114 -25
View File
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
}
const ALL_FIELDS: FilterConditionField[] = [
"from", "to", "cc", "subject", "header", "size", "body",
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
];
const TEXT_COMPARATORS: FilterComparator[] = [
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
if (field === "size") return SIZE_COMPARATORS;
if (field === "attachment") return ATTACHMENT_COMPARATORS;
return TEXT_COMPARATORS;
}
const ALL_ACTION_TYPES: FilterActionType[] = [
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
];
@@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition {
return { field: "from", comparator: "contains", value: "" };
}
// Multi-value handling: conditions are stored as string | string[]. The UI
// presents them as a single comma-separated text input — the user types
// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay
// strings so existing single-value rules don't change shape.
function valueToInputString(v: string | string[]): string {
if (Array.isArray(v)) return v.join(", ");
return v;
}
function inputStringToValue(s: string): string | string[] {
const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0];
return parts;
}
function isConditionValueEmpty(v: string | string[]): boolean {
if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim());
return !v.trim();
}
function makeEmptyAction(): FilterAction {
return { type: "move", value: "" };
}
@@ -97,9 +126,23 @@ export function FilterRuleModal({
return;
}
const validConditions = conditions.filter(
(c) => c.value.trim()
);
// While editing, condition.value is always the raw string typed into the
// input (commas not yet split). Convert to array form here on save so a
// user typing "a, b, c" actually persists as ["a","b","c"]. This is the
// moment we know editing is finished - splitting earlier would eat any
// comma the user just typed mid-edit.
const validConditions = conditions
.filter((c) => {
if (c.field === "attachment" && c.comparator === "has_any") return true;
return !isConditionValueEmpty(c.value);
})
.map((c) => {
if (c.field === "attachment" && c.comparator === "has_any") return c;
if (c.field === "size") return c; // numeric, single-value only
if (typeof c.value !== "string") return c; // already structured
const parsed = inputStringToValue(c.value);
return { ...c, value: parsed };
});
if (validConditions.length === 0) {
toast.error(t("validation_empty_conditions"));
return;
@@ -129,15 +172,27 @@ export function FilterRuleModal({
prev.map((c, i) => {
if (i !== index) return c;
const updated = { ...c, ...updates };
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
updated.comparator = "greater_than";
}
if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
updated.comparator = "contains";
// Reconcile the comparator when the field changes so we never end up
// with e.g. (field=attachment, comparator=contains) — invalid for the
// Sieve generator. Each field has its own valid comparator set.
if (updates.field && updates.field !== c.field) {
const allowed = comparatorsFor(updates.field);
if (!allowed.includes(c.comparator)) {
updated.comparator = allowed[0];
}
}
if (updates.field && updates.field !== "header") {
delete updated.headerName;
}
// has_any takes no value; clear it so we don't leak old text into
// the generated Sieve.
if (updated.field === "attachment" && updated.comparator === "has_any") {
updated.value = "";
}
// Size is numeric, single value only - collapse any list to scalar.
if (updated.field === "size" && Array.isArray(updated.value)) {
updated.value = updated.value[0] ?? "";
}
return updated;
})
);
@@ -281,24 +336,58 @@ export function FilterRuleModal({
className={selectClass}
aria-label={t("comparators.contains")}
>
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
(c) => (
<option key={c} value={c}>
{t(`comparators.${c}`)}
</option>
)
)}
{comparatorsFor(condition.field).map((c) => (
<option key={c} value={c}>
{t(`comparators.${c}`)}
</option>
))}
</select>
<Input
value={condition.value}
onChange={(e) => updateCondition(index, { value: e.target.value })}
placeholder={
condition.field === "size" ? t("size_placeholder") : t("header_placeholder")
}
className="flex-1 min-w-[120px]"
type={condition.field === "size" ? "number" : "text"}
/>
{/* has_any takes no value; render a stub so the row layout
stays consistent but no input is editable. */}
{condition.field === "attachment" && condition.comparator === "has_any" ? (
<div className="flex-1 min-w-[120px]" />
) : (
<Input
value={valueToInputString(condition.value)}
onChange={(e) =>
// Store the raw input string while typing. Splitting
// commas into an array on every keystroke would eat
// the comma the moment it's typed.
updateCondition(index, { value: e.target.value })
}
onBlur={(e) => {
// On blur: normalise comma-separated input into an
// array (or single string when only one item). Size
// stays numeric/single-value; attachment-has_any has
// no value at all.
if (condition.field === "size") return;
if (
condition.field === "attachment" &&
condition.comparator === "has_any"
)
return;
const parsed = inputStringToValue(e.target.value);
// Only update if the normalised shape actually
// differs - avoids triggering a no-op re-render and
// resetting the user's cursor on every blur.
if (
JSON.stringify(parsed) !== JSON.stringify(condition.value)
) {
updateCondition(index, { value: parsed });
}
}}
placeholder={
condition.field === "size"
? t("size_placeholder")
: condition.field === "attachment"
? t("attachment_type_placeholder")
: t("value_placeholder_multi")
}
className="flex-1 min-w-[120px]"
type={condition.field === "size" ? "number" : "text"}
/>
)}
<button
type="button"
+27 -2
View File
@@ -36,7 +36,17 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
const conditions = rule.conditions.slice(0, 2).map((c) => {
const field = t(`condition_fields.${c.field}`);
const comparator = t(`comparators.${c.comparator}`);
return `${field} ${comparator} "${c.value}"`;
// has_any is a no-value test ("attachment is present"); appending
// `""` would look broken in the summary line.
if (c.field === "attachment" && c.comparator === "has_any") {
return `${field} ${comparator}`;
}
// Multi-value conditions render as "a" / "b" / "c" with the locale's
// OR-glue between items so the line still reads as natural language.
const valueStr = Array.isArray(c.value)
? c.value.map((v) => `"${v}"`).join(` ${t("or")} `)
: `"${c.value}"`;
return `${field} ${comparator} ${valueStr}`;
});
const joiner = rule.matchType === "all" ? t("and") : t("or");
@@ -97,7 +107,22 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
<span className="text-muted-foreground">{comparator}</span>
<span className="text-foreground">{c.value}</span>
{!(c.field === "attachment" && c.comparator === "has_any") && (
<span className="text-foreground">
{Array.isArray(c.value)
? c.value.map((v, k) => (
<span key={k}>
{k > 0 && (
<span className="text-muted-foreground/70 italic mx-0.5">
{t("or")}
</span>
)}
{v}
</span>
))
: <>{c.value}</>}
</span>
)}
</span>
</span>
);
+19 -3
View File
@@ -13,14 +13,21 @@ export interface SieveCapabilities {
externalLists: string[];
}
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
export type FilterConditionField =
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
| 'attachment';
export type FilterComparator =
| 'contains' | 'not_contains'
| 'is' | 'not_is'
| 'starts_with' | 'ends_with'
| 'matches'
| 'greater_than' | 'less_than';
| 'greater_than' | 'less_than'
// For field === 'attachment':
// has_any → message has any attachment (Content-Disposition: attachment)
// has_type → message has an attachment whose Content-Type matches `value`
// (substring match, e.g. "application/pdf" or "image/")
| 'has_any' | 'has_type';
export type FilterActionType =
| 'move' | 'copy' | 'forward'
@@ -30,7 +37,16 @@ export type FilterActionType =
export interface FilterCondition {
field: FilterConditionField;
comparator: FilterComparator;
value: string;
/**
* Match value. Use a string array for OR-within-condition semantics
* (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either).
* Sieve emits the array as a list literal which the implementation
* treats as "matches any item". Use a plain string for single-value
* conditions; existing single-value rules continue to work unchanged.
*
* Not supported for: size (numeric), has_any (no value).
*/
value: string | string[];
headerName?: string;
}
+53 -12
View File
@@ -12,42 +12,82 @@ function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/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[] {
const arr = Array.isArray(value) ? value : [value];
return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0);
}
// Render one or many strings as a Sieve string-literal-or-list. Sieve treats
// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within
// the condition); the single-string form is emitted unchanged when len === 1
// so existing scripts and tests stay byte-identical.
function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string {
if (values.length === 1) {
return `"${escapeString(transform(values[0]))}"`;
}
return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`;
}
function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition;
if (field === 'size') {
// Size is numeric, single value only.
const sizeValue = Array.isArray(value) ? value[0] : value;
const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${value}`;
return `size ${op} ${sizeValue}`;
}
const values = toValueList(value);
if (field === 'body') {
const matchType = comparator === 'is' ? ':is' : ':contains';
return `body ${matchType} "${escapeString(value)}"`;
return `body ${matchType} ${formatStringArg(values)}`;
}
if (field === 'attachment') {
// RFC 5703: :mime :anychild matches against headers of any MIME part.
// has_any tests Content-Disposition for "attachment"; has_type matches
// the file extension against the filename across BOTH Content-Disposition
// (filename= parameter) and Content-Type (name= parameter) - many older
// senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only
// in Content-Type and leave Content-Disposition without a filename.
// RFC 5228 §5.7 allows a string-list for header names; the test passes
// if any listed header matches. Wildcard "*.<ext>*" catches quoted,
// unquoted, and RFC-2231-encoded forms alike since ".<ext>" appears as
// a literal substring in all of them.
// Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"]
// = OR within the condition (any item matches → test passes).
if (comparator === 'has_any') {
return `header :mime :anychild :contains "Content-Disposition" "attachment"`;
}
const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean);
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 escaped = escapeString(value);
switch (comparator) {
case 'contains':
return `header :contains "${headerName}" "${escaped}"`;
return `header :contains "${headerName}" ${formatStringArg(values)}`;
case 'not_contains':
return `not header :contains "${headerName}" "${escaped}"`;
return `not header :contains "${headerName}" ${formatStringArg(values)}`;
case 'is':
return `header :is "${headerName}" "${escaped}"`;
return `header :is "${headerName}" ${formatStringArg(values)}`;
case 'not_is':
return `not header :is "${headerName}" "${escaped}"`;
return `not header :is "${headerName}" ${formatStringArg(values)}`;
case 'starts_with':
return `header :matches "${headerName}" "${escaped}*"`;
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
case 'ends_with':
return `header :matches "${headerName}" "*${escaped}"`;
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
case 'matches':
return `header :matches "${headerName}" "${escaped}"`;
return `header :matches "${headerName}" ${formatStringArg(values)}`;
default:
return `header :contains "${headerName}" "${escaped}"`;
return `header :contains "${headerName}" ${formatStringArg(values)}`;
}
}
@@ -89,6 +129,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
for (const rule of enabledRules) {
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
if (condition.field === 'attachment') extensions.add('mime');
}
for (const action of rule.actions) {
switch (action.type) {
+133 -22
View File
@@ -36,7 +36,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>;
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
if (typeof cond.field !== 'string' || typeof cond.comparator !== 'string') return false;
// value may be a string OR a non-empty array of strings (Patch 11
// multi-value semantics). Accept both.
if (typeof cond.value === 'string') return true;
return Array.isArray(cond.value) && cond.value.every((v) => typeof v === 'string');
}
function isValidAction(a: unknown): boolean {
@@ -334,43 +338,150 @@ function parseAtom(raw: string): FilterCondition | null {
}
}
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
// Parse the value-tail of a header/body test: either a single quoted
// string or a Sieve list literal ["a", "b", ...]. Returns the unwrapped
// value(s), preserving the array shape when present so the caller can
// detect multi-value conditions.
const parseValueTail = (raw: string): string | string[] | null => {
const trimmed = raw.trim();
// List form
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
const inner = trimmed.slice(1, -1);
const items: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let mm: RegExpExecArray | null;
let cursor = 0;
while ((mm = re.exec(inner)) !== null) {
// Ensure only whitespace and commas appear between items
if (inner.slice(cursor, mm.index).replace(/[\s,]/g, '') !== '') return null;
items.push(unescapeSieveString(mm[1]));
cursor = mm.index + mm[0].length;
}
if (inner.slice(cursor).replace(/[\s,]/g, '') !== '') return null;
if (items.length === 0) return null;
return items.length === 1 ? items[0] : items;
}
// Single string form
const single = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed);
if (single) return unescapeSieveString(single[1]);
return null;
};
// Classify a :matches value (or values) into starts_with / ends_with /
// matches by inspecting wildcard positions. For multi-value, all items
// must share the same shape; otherwise we fall back to 'matches' and
// keep the wildcards verbatim.
const classifyMatches = (
values: string | string[],
): { comparator: 'starts_with' | 'ends_with' | 'matches'; stripped: string | string[] } => {
const arr = Array.isArray(values) ? values : [values];
const isTrailing = (v: string) => {
const stars = [...v].filter((c) => c === '*').length;
return stars === 1 && v.endsWith('*');
};
const isLeading = (v: string) => {
const stars = [...v].filter((c) => c === '*').length;
return stars === 1 && v.startsWith('*');
};
if (arr.every(isTrailing)) {
const stripped = arr.map((v) => v.slice(0, -1));
return { comparator: 'starts_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
}
if (arr.every(isLeading)) {
const stripped = arr.map((v) => v.slice(1));
return { comparator: 'ends_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
}
return { comparator: 'matches', stripped: values };
};
// Match attachment-aware :mime :anychild tests before the generic header
// pattern - emitted by our own generator for field === 'attachment'.
// has_any: ":contains Content-Disposition attachment"
let m = /^header\s+:mime\s+:anychild\s+:contains\s+"Content-Disposition"\s+"attachment"$/.exec(s);
if (m) {
const [, tag, headerName, rawValue] = m;
const value = unescapeSieveString(rawValue);
return { field: 'attachment', comparator: 'has_any', value: '' };
}
// has_type: ":matches <headers> <value-tail>"
// - Current emit form uses a header-list ["Content-Disposition", "Content-Type"]
// to catch senders who put the filename only in Content-Type's name= param
// (Microsoft SMTPSVC, PrintToMail.net, etc.).
// - Legacy emit form used a single "Content-Disposition" header - still
// recognised here so rules saved before the fix remain editable.
// Each value item must be a "*.<ext>*" wildcard pattern.
const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => {
// Header part: accept either a single quoted string or a 2-element list
// containing exactly Content-Disposition + Content-Type (in any order).
const single = /^"Content-Disposition"$/.exec(rawHeaders.trim());
const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim());
let headersOk = false;
if (single) {
headersOk = true;
} else if (listForm) {
const inner = listForm[1];
const items: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let mm: RegExpExecArray | null;
while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1]));
const expected = new Set(['Content-Disposition', 'Content-Type']);
const got = new Set(items);
headersOk =
items.length === expected.size &&
[...expected].every((h) => got.has(h));
}
if (!headersOk) return null;
const tail = parseValueTail(rawValue);
if (tail === null) return null;
const arr = Array.isArray(tail) ? tail : [tail];
const exts: string[] = [];
for (const item of arr) {
const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item);
if (!em) return null;
exts.push(unescapeSieveString(em[1]));
}
return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts };
};
m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s);
if (m) {
const result = tryHasType(m[1], m[2]);
if (result) return result;
}
// Unknown :mime :anychild pattern (e.g. from external scripts) - bail to
// opaque rendering so we don't silently misrepresent the script.
if (/^header\s+:mime\s+:anychild\b/.test(s)) {
return null;
}
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
if (m) {
const [, tag, headerName, rawTail] = m;
const value = parseValueTail(rawTail);
if (value === null) return null;
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator;
let finalValue: string | string[];
if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains';
finalValue = value;
} else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is';
finalValue = value;
} else {
// :matches - distinguish starts_with / ends_with / matches
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
const classified = classifyMatches(value);
comparator = classified.comparator;
finalValue = classified.stripped;
}
const cond: FilterCondition = { field, comparator, value };
const cond: FilterCondition = { field, comparator, value: finalValue };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
const value = parseValueTail(m[2]);
if (value === null) return null;
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
}
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Akce",
"add_action": "Přidat akci",
"stop_processing": "Zastavit zpracování dalších pravidel",
"attachment_type_placeholder": "např. pdf, doc, jpg",
"value_placeholder_multi": "Hodnota (více oddělených čárkami)",
"condition_fields": {
"attachment": "Příloha",
"from": "Od",
"to": "Komu",
"cc": "Kopie",
@@ -1642,6 +1645,8 @@
"body": "Tělo zprávy"
},
"comparators": {
"has_any": "je přítomna",
"has_type": "typu",
"contains": "obsahuje",
"not_contains": "neobsahuje",
"is": "je přesně",
+5
View File
@@ -1633,7 +1633,10 @@
"actions": "Handlinger",
"add_action": "Tilføj handling",
"stop_processing": "Stop behandling af efterfølgende regler",
"attachment_type_placeholder": "f.eks. pdf, doc, jpg",
"value_placeholder_multi": "Værdi (flere adskilt med komma)",
"condition_fields": {
"attachment": "Vedhæftet fil",
"from": "Fra",
"to": "Til",
"cc": "Cc",
@@ -1643,6 +1646,8 @@
"body": "Brødtekst"
},
"comparators": {
"has_any": "er til stede",
"has_type": "af typen",
"contains": "indeholder",
"not_contains": "indeholder ikke",
"is": "er præcis",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Aktionen",
"add_action": "Aktion hinzufügen",
"stop_processing": "Verarbeitung nachfolgender Regeln stoppen",
"attachment_type_placeholder": "z.B. pdf, doc, jpg",
"value_placeholder_multi": "Wert (mehrere mit Komma trennen)",
"condition_fields": {
"attachment": "Anhang",
"from": "Von",
"to": "An",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "Nachrichtentext"
},
"comparators": {
"has_any": "vorhanden",
"has_type": "vom Typ",
"contains": "enthält",
"not_contains": "enthält nicht",
"is": "ist genau",
+5
View File
@@ -1633,7 +1633,10 @@
"actions": "Actions",
"add_action": "Add Action",
"stop_processing": "Stop processing subsequent rules",
"attachment_type_placeholder": "e.g. pdf, doc, jpg",
"value_placeholder_multi": "Value (multiple separated by commas)",
"condition_fields": {
"attachment": "Attachment",
"from": "From",
"to": "To",
"cc": "Cc",
@@ -1643,6 +1646,8 @@
"body": "Body"
},
"comparators": {
"has_any": "is present",
"has_type": "of type",
"contains": "contains",
"not_contains": "does not contain",
"is": "is exactly",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Acciones",
"add_action": "Agregar acción",
"stop_processing": "Detener el procesamiento de reglas posteriores",
"attachment_type_placeholder": "p. ej. pdf, doc, jpg",
"value_placeholder_multi": "Valor (varios separados por comas)",
"condition_fields": {
"attachment": "Adjunto",
"from": "De",
"to": "Para",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "Cuerpo"
},
"comparators": {
"has_any": "está presente",
"has_type": "del tipo",
"contains": "contiene",
"not_contains": "no contiene",
"is": "es exactamente",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Actions",
"add_action": "Ajouter une action",
"stop_processing": "Arrêter le traitement des règles suivantes",
"attachment_type_placeholder": "p. ex. pdf, doc, jpg",
"value_placeholder_multi": "Valeur (plusieurs séparées par des virgules)",
"condition_fields": {
"attachment": "Pièce jointe",
"from": "De",
"to": "À",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "Corps"
},
"comparators": {
"has_any": "est présent",
"has_type": "de type",
"contains": "contient",
"not_contains": "ne contient pas",
"is": "est exactement",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Azioni",
"add_action": "Aggiungi azione",
"stop_processing": "Interrompere l'elaborazione delle regole successive",
"attachment_type_placeholder": "es. pdf, doc, jpg",
"value_placeholder_multi": "Valore (più valori separati da virgole)",
"condition_fields": {
"attachment": "Allegato",
"from": "Da",
"to": "A",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "Corpo"
},
"comparators": {
"has_any": "è presente",
"has_type": "di tipo",
"contains": "contiene",
"not_contains": "non contiene",
"is": "è esattamente",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "アクション",
"add_action": "アクションを追加",
"stop_processing": "以降のルールの処理を停止",
"attachment_type_placeholder": "例: pdf, doc, jpg",
"value_placeholder_multi": "値(カンマで複数指定可)",
"condition_fields": {
"attachment": "添付ファイル",
"from": "差出人",
"to": "宛先",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "本文"
},
"comparators": {
"has_any": "あり",
"has_type": "タイプ",
"contains": "を含む",
"not_contains": "を含まない",
"is": "と完全一致",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "동작",
"add_action": "동작 추가",
"stop_processing": "이후 규칙 무시하기",
"attachment_type_placeholder": "예: pdf, doc, jpg",
"value_placeholder_multi": "값 (쉼표로 여러 개 구분)",
"condition_fields": {
"attachment": "첨부 파일",
"from": "보낸 사람",
"to": "받는 사람",
"cc": "참조",
@@ -1642,6 +1645,8 @@
"body": "본문"
},
"comparators": {
"has_any": "있음",
"has_type": "유형",
"contains": "포함함",
"not_contains": "포함하지 않음",
"is": "정확히 일치",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Darbības",
"add_action": "Pievienot darbību",
"stop_processing": "Pārtraukt nākamo noteikumu apstrādi",
"attachment_type_placeholder": "piem. pdf, doc, jpg",
"value_placeholder_multi": "Vērtība (vairākas atdalītas ar komatu)",
"condition_fields": {
"attachment": "Pielikums",
"from": "No",
"to": "Kam",
"cc": "Kopija",
@@ -1642,6 +1645,8 @@
"body": "Teksts"
},
"comparators": {
"has_any": "ir klāt",
"has_type": "tipa",
"contains": "satur",
"not_contains": "nesatur",
"is": "precīzi sakrīt",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Acties",
"add_action": "Actie toevoegen",
"stop_processing": "Verwerking van volgende regels stoppen",
"attachment_type_placeholder": "bijv. pdf, doc, jpg",
"value_placeholder_multi": "Waarde (meerdere met komma's gescheiden)",
"condition_fields": {
"attachment": "Bijlage",
"from": "Van",
"to": "Aan",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "Inhoud"
},
"comparators": {
"has_any": "aanwezig",
"has_type": "van type",
"contains": "bevat",
"not_contains": "bevat niet",
"is": "is precies",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Akcje",
"add_action": "Dodaj akcję",
"stop_processing": "Zatrzymaj przetwarzanie kolejnych reguł",
"attachment_type_placeholder": "np. pdf, doc, jpg",
"value_placeholder_multi": "Wartość (kilka oddzielonych przecinkami)",
"condition_fields": {
"attachment": "Załącznik",
"from": "Od",
"to": "Do",
"cc": "DW",
@@ -1642,6 +1645,8 @@
"body": "Treść"
},
"comparators": {
"has_any": "jest obecny",
"has_type": "typu",
"contains": "zawiera",
"not_contains": "nie zawiera",
"is": "jest dokładnie",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Ações",
"add_action": "Adicionar ação",
"stop_processing": "Parar o processamento das regras seguintes",
"attachment_type_placeholder": "ex.: pdf, doc, jpg",
"value_placeholder_multi": "Valor (vários separados por vírgulas)",
"condition_fields": {
"attachment": "Anexo",
"from": "De",
"to": "Para",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "Corpo"
},
"comparators": {
"has_any": "está presente",
"has_type": "do tipo",
"contains": "contém",
"not_contains": "não contém",
"is": "é exatamente",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Действия",
"add_action": "Добавить действие",
"stop_processing": "Прекратить обработку последующих правил",
"attachment_type_placeholder": "напр. pdf, doc, jpg",
"value_placeholder_multi": "Значение (несколько через запятую)",
"condition_fields": {
"attachment": "Вложение",
"from": "От",
"to": "Кому",
"cc": "Копия",
@@ -1642,6 +1645,8 @@
"body": "Тело"
},
"comparators": {
"has_any": "присутствует",
"has_type": "типа",
"contains": "содержит",
"not_contains": "не содержит",
"is": "точно совпадает",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "İşlemler",
"add_action": "İşlem Ekle",
"stop_processing": "Sonraki kuralları işlemeyi durdur",
"attachment_type_placeholder": "örn. pdf, doc, jpg",
"value_placeholder_multi": "Değer (birden fazla virgülle ayrılır)",
"condition_fields": {
"attachment": "Ek",
"from": "Kimden",
"to": "Kime",
"cc": "Bilgi",
@@ -1642,6 +1645,8 @@
"body": "Gövde"
},
"comparators": {
"has_any": "mevcut",
"has_type": "türünde",
"contains": "içerir",
"not_contains": "içermez",
"is": "tam olarak",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "Дії",
"add_action": "Додати дію",
"stop_processing": "Зупинити обробку наступних правил",
"attachment_type_placeholder": "напр. pdf, doc, jpg",
"value_placeholder_multi": "Значення (декілька через кому)",
"condition_fields": {
"attachment": "Вкладення",
"from": "Від",
"to": "до",
"cc": "Cc",
@@ -1642,6 +1645,8 @@
"body": "Тіло"
},
"comparators": {
"has_any": "присутнє",
"has_type": "типу",
"contains": "містить",
"not_contains": "не містить",
"is": "точно",
+5
View File
@@ -1632,7 +1632,10 @@
"actions": "操作",
"add_action": "添加操作",
"stop_processing": "停止处理后续规则",
"attachment_type_placeholder": "例如:pdf、doc、jpg",
"value_placeholder_multi": "值(多个用逗号分隔)",
"condition_fields": {
"attachment": "附件",
"from": "发件人",
"to": "收件人",
"cc": "抄送",
@@ -1642,6 +1645,8 @@
"body": "正文"
},
"comparators": {
"has_any": "存在",
"has_type": "类型为",
"contains": "包含",
"not_contains": "不包含",
"is": "等于",