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:
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_FIELDS: FilterConditionField[] = [
|
const ALL_FIELDS: FilterConditionField[] = [
|
||||||
"from", "to", "cc", "subject", "header", "size", "body",
|
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEXT_COMPARATORS: FilterComparator[] = [
|
const TEXT_COMPARATORS: FilterComparator[] = [
|
||||||
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
|
|||||||
|
|
||||||
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
|
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[] = [
|
const ALL_ACTION_TYPES: FilterActionType[] = [
|
||||||
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
|
"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: "" };
|
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 {
|
function makeEmptyAction(): FilterAction {
|
||||||
return { type: "move", value: "" };
|
return { type: "move", value: "" };
|
||||||
}
|
}
|
||||||
@@ -97,9 +126,23 @@ export function FilterRuleModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validConditions = conditions.filter(
|
// While editing, condition.value is always the raw string typed into the
|
||||||
(c) => c.value.trim()
|
// 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) {
|
if (validConditions.length === 0) {
|
||||||
toast.error(t("validation_empty_conditions"));
|
toast.error(t("validation_empty_conditions"));
|
||||||
return;
|
return;
|
||||||
@@ -129,15 +172,27 @@ export function FilterRuleModal({
|
|||||||
prev.map((c, i) => {
|
prev.map((c, i) => {
|
||||||
if (i !== index) return c;
|
if (i !== index) return c;
|
||||||
const updated = { ...c, ...updates };
|
const updated = { ...c, ...updates };
|
||||||
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
|
// Reconcile the comparator when the field changes so we never end up
|
||||||
updated.comparator = "greater_than";
|
// 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 !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
|
|
||||||
updated.comparator = "contains";
|
|
||||||
}
|
}
|
||||||
if (updates.field && updates.field !== "header") {
|
if (updates.field && updates.field !== "header") {
|
||||||
delete updated.headerName;
|
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;
|
return updated;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -281,24 +336,58 @@ export function FilterRuleModal({
|
|||||||
className={selectClass}
|
className={selectClass}
|
||||||
aria-label={t("comparators.contains")}
|
aria-label={t("comparators.contains")}
|
||||||
>
|
>
|
||||||
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
|
{comparatorsFor(condition.field).map((c) => (
|
||||||
(c) => (
|
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
{t(`comparators.${c}`)}
|
{t(`comparators.${c}`)}
|
||||||
</option>
|
</option>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* 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
|
<Input
|
||||||
value={condition.value}
|
value={valueToInputString(condition.value)}
|
||||||
onChange={(e) => updateCondition(index, { value: e.target.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={
|
placeholder={
|
||||||
condition.field === "size" ? t("size_placeholder") : t("header_placeholder")
|
condition.field === "size"
|
||||||
|
? t("size_placeholder")
|
||||||
|
: condition.field === "attachment"
|
||||||
|
? t("attachment_type_placeholder")
|
||||||
|
: t("value_placeholder_multi")
|
||||||
}
|
}
|
||||||
className="flex-1 min-w-[120px]"
|
className="flex-1 min-w-[120px]"
|
||||||
type={condition.field === "size" ? "number" : "text"}
|
type={condition.field === "size" ? "number" : "text"}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -36,7 +36,17 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
|
|||||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||||
const field = t(`condition_fields.${c.field}`);
|
const field = t(`condition_fields.${c.field}`);
|
||||||
const comparator = t(`comparators.${c.comparator}`);
|
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");
|
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="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="font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||||
<span className="text-muted-foreground">{comparator}</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>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-3
@@ -13,14 +13,21 @@ export interface SieveCapabilities {
|
|||||||
externalLists: string[];
|
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 =
|
export type FilterComparator =
|
||||||
| 'contains' | 'not_contains'
|
| 'contains' | 'not_contains'
|
||||||
| 'is' | 'not_is'
|
| 'is' | 'not_is'
|
||||||
| 'starts_with' | 'ends_with'
|
| 'starts_with' | 'ends_with'
|
||||||
| 'matches'
|
| '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 =
|
export type FilterActionType =
|
||||||
| 'move' | 'copy' | 'forward'
|
| 'move' | 'copy' | 'forward'
|
||||||
@@ -30,7 +37,16 @@ export type FilterActionType =
|
|||||||
export interface FilterCondition {
|
export interface FilterCondition {
|
||||||
field: FilterConditionField;
|
field: FilterConditionField;
|
||||||
comparator: FilterComparator;
|
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;
|
headerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-12
@@ -12,42 +12,82 @@ function escapeString(value: string): string {
|
|||||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
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 {
|
function generateCondition(condition: FilterCondition): string {
|
||||||
const { field, comparator, value } = condition;
|
const { field, comparator, value } = condition;
|
||||||
|
|
||||||
if (field === 'size') {
|
if (field === 'size') {
|
||||||
|
// Size is numeric, single value only.
|
||||||
|
const sizeValue = Array.isArray(value) ? value[0] : value;
|
||||||
const op = comparator === 'greater_than' ? ':over' : ':under';
|
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||||
return `size ${op} ${value}`;
|
return `size ${op} ${sizeValue}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const values = toValueList(value);
|
||||||
|
|
||||||
if (field === 'body') {
|
if (field === 'body') {
|
||||||
const matchType = comparator === 'is' ? ':is' : ':contains';
|
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'
|
const headerName = field === 'header'
|
||||||
? (condition.headerName || 'X-Unknown')
|
? (condition.headerName || 'X-Unknown')
|
||||||
: HEADER_MAP[field];
|
: HEADER_MAP[field];
|
||||||
|
|
||||||
const escaped = escapeString(value);
|
|
||||||
|
|
||||||
switch (comparator) {
|
switch (comparator) {
|
||||||
case 'contains':
|
case 'contains':
|
||||||
return `header :contains "${headerName}" "${escaped}"`;
|
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||||
case 'not_contains':
|
case 'not_contains':
|
||||||
return `not header :contains "${headerName}" "${escaped}"`;
|
return `not header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||||
case 'is':
|
case 'is':
|
||||||
return `header :is "${headerName}" "${escaped}"`;
|
return `header :is "${headerName}" ${formatStringArg(values)}`;
|
||||||
case 'not_is':
|
case 'not_is':
|
||||||
return `not header :is "${headerName}" "${escaped}"`;
|
return `not header :is "${headerName}" ${formatStringArg(values)}`;
|
||||||
case 'starts_with':
|
case 'starts_with':
|
||||||
return `header :matches "${headerName}" "${escaped}*"`;
|
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
|
||||||
case 'ends_with':
|
case 'ends_with':
|
||||||
return `header :matches "${headerName}" "*${escaped}"`;
|
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
|
||||||
case 'matches':
|
case 'matches':
|
||||||
return `header :matches "${headerName}" "${escaped}"`;
|
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
||||||
default:
|
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 rule of enabledRules) {
|
||||||
for (const condition of rule.conditions) {
|
for (const condition of rule.conditions) {
|
||||||
if (condition.field === 'body') extensions.add('body');
|
if (condition.field === 'body') extensions.add('body');
|
||||||
|
if (condition.field === 'attachment') extensions.add('mime');
|
||||||
}
|
}
|
||||||
for (const action of rule.actions) {
|
for (const action of rule.actions) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
|||||||
+133
-22
@@ -36,7 +36,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
|||||||
function isValidCondition(c: unknown): boolean {
|
function isValidCondition(c: unknown): boolean {
|
||||||
if (!c || typeof c !== 'object') return false;
|
if (!c || typeof c !== 'object') return false;
|
||||||
const cond = c as Record<string, unknown>;
|
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 {
|
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) {
|
if (m) {
|
||||||
const [, tag, headerName, rawValue] = m;
|
return { field: 'attachment', comparator: 'has_any', value: '' };
|
||||||
const value = unescapeSieveString(rawValue);
|
}
|
||||||
|
// 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));
|
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||||
|
|
||||||
let comparator: FilterComparator;
|
let comparator: FilterComparator;
|
||||||
|
let finalValue: string | string[];
|
||||||
if (tag === 'contains') {
|
if (tag === 'contains') {
|
||||||
comparator = negated ? 'not_contains' : 'contains';
|
comparator = negated ? 'not_contains' : 'contains';
|
||||||
|
finalValue = value;
|
||||||
} else if (tag === 'is') {
|
} else if (tag === 'is') {
|
||||||
comparator = negated ? 'not_is' : 'is';
|
comparator = negated ? 'not_is' : 'is';
|
||||||
|
finalValue = value;
|
||||||
} else {
|
} else {
|
||||||
// :matches - distinguish starts_with / ends_with / matches
|
const classified = classifyMatches(value);
|
||||||
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
comparator = classified.comparator;
|
||||||
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
finalValue = classified.stripped;
|
||||||
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 cond: FilterCondition = { field, comparator, value };
|
const cond: FilterCondition = { field, comparator, value: finalValue };
|
||||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||||
return cond;
|
return cond;
|
||||||
}
|
}
|
||||||
|
|
||||||
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
|
||||||
if (m) {
|
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);
|
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Akce",
|
"actions": "Akce",
|
||||||
"add_action": "Přidat akci",
|
"add_action": "Přidat akci",
|
||||||
"stop_processing": "Zastavit zpracování dalších pravidel",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Příloha",
|
||||||
"from": "Od",
|
"from": "Od",
|
||||||
"to": "Komu",
|
"to": "Komu",
|
||||||
"cc": "Kopie",
|
"cc": "Kopie",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Tělo zprávy"
|
"body": "Tělo zprávy"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "je přítomna",
|
||||||
|
"has_type": "typu",
|
||||||
"contains": "obsahuje",
|
"contains": "obsahuje",
|
||||||
"not_contains": "neobsahuje",
|
"not_contains": "neobsahuje",
|
||||||
"is": "je přesně",
|
"is": "je přesně",
|
||||||
|
|||||||
@@ -1633,7 +1633,10 @@
|
|||||||
"actions": "Handlinger",
|
"actions": "Handlinger",
|
||||||
"add_action": "Tilføj handling",
|
"add_action": "Tilføj handling",
|
||||||
"stop_processing": "Stop behandling af efterfølgende regler",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Vedhæftet fil",
|
||||||
"from": "Fra",
|
"from": "Fra",
|
||||||
"to": "Til",
|
"to": "Til",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1643,6 +1646,8 @@
|
|||||||
"body": "Brødtekst"
|
"body": "Brødtekst"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "er til stede",
|
||||||
|
"has_type": "af typen",
|
||||||
"contains": "indeholder",
|
"contains": "indeholder",
|
||||||
"not_contains": "indeholder ikke",
|
"not_contains": "indeholder ikke",
|
||||||
"is": "er præcis",
|
"is": "er præcis",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"add_action": "Aktion hinzufügen",
|
"add_action": "Aktion hinzufügen",
|
||||||
"stop_processing": "Verarbeitung nachfolgender Regeln stoppen",
|
"stop_processing": "Verarbeitung nachfolgender Regeln stoppen",
|
||||||
|
"attachment_type_placeholder": "z.B. pdf, doc, jpg",
|
||||||
|
"value_placeholder_multi": "Wert (mehrere mit Komma trennen)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "Anhang",
|
||||||
"from": "Von",
|
"from": "Von",
|
||||||
"to": "An",
|
"to": "An",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Nachrichtentext"
|
"body": "Nachrichtentext"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "vorhanden",
|
||||||
|
"has_type": "vom Typ",
|
||||||
"contains": "enthält",
|
"contains": "enthält",
|
||||||
"not_contains": "enthält nicht",
|
"not_contains": "enthält nicht",
|
||||||
"is": "ist genau",
|
"is": "ist genau",
|
||||||
|
|||||||
@@ -1633,7 +1633,10 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"add_action": "Add Action",
|
"add_action": "Add Action",
|
||||||
"stop_processing": "Stop processing subsequent rules",
|
"stop_processing": "Stop processing subsequent rules",
|
||||||
|
"attachment_type_placeholder": "e.g. pdf, doc, jpg",
|
||||||
|
"value_placeholder_multi": "Value (multiple separated by commas)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "Attachment",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1643,6 +1646,8 @@
|
|||||||
"body": "Body"
|
"body": "Body"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "is present",
|
||||||
|
"has_type": "of type",
|
||||||
"contains": "contains",
|
"contains": "contains",
|
||||||
"not_contains": "does not contain",
|
"not_contains": "does not contain",
|
||||||
"is": "is exactly",
|
"is": "is exactly",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"add_action": "Agregar acción",
|
"add_action": "Agregar acción",
|
||||||
"stop_processing": "Detener el procesamiento de reglas posteriores",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Adjunto",
|
||||||
"from": "De",
|
"from": "De",
|
||||||
"to": "Para",
|
"to": "Para",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Cuerpo"
|
"body": "Cuerpo"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "está presente",
|
||||||
|
"has_type": "del tipo",
|
||||||
"contains": "contiene",
|
"contains": "contiene",
|
||||||
"not_contains": "no contiene",
|
"not_contains": "no contiene",
|
||||||
"is": "es exactamente",
|
"is": "es exactamente",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"add_action": "Ajouter une action",
|
"add_action": "Ajouter une action",
|
||||||
"stop_processing": "Arrêter le traitement des règles suivantes",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Pièce jointe",
|
||||||
"from": "De",
|
"from": "De",
|
||||||
"to": "À",
|
"to": "À",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Corps"
|
"body": "Corps"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "est présent",
|
||||||
|
"has_type": "de type",
|
||||||
"contains": "contient",
|
"contains": "contient",
|
||||||
"not_contains": "ne contient pas",
|
"not_contains": "ne contient pas",
|
||||||
"is": "est exactement",
|
"is": "est exactement",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Azioni",
|
"actions": "Azioni",
|
||||||
"add_action": "Aggiungi azione",
|
"add_action": "Aggiungi azione",
|
||||||
"stop_processing": "Interrompere l'elaborazione delle regole successive",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Allegato",
|
||||||
"from": "Da",
|
"from": "Da",
|
||||||
"to": "A",
|
"to": "A",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Corpo"
|
"body": "Corpo"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "è presente",
|
||||||
|
"has_type": "di tipo",
|
||||||
"contains": "contiene",
|
"contains": "contiene",
|
||||||
"not_contains": "non contiene",
|
"not_contains": "non contiene",
|
||||||
"is": "è esattamente",
|
"is": "è esattamente",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "アクション",
|
"actions": "アクション",
|
||||||
"add_action": "アクションを追加",
|
"add_action": "アクションを追加",
|
||||||
"stop_processing": "以降のルールの処理を停止",
|
"stop_processing": "以降のルールの処理を停止",
|
||||||
|
"attachment_type_placeholder": "例: pdf, doc, jpg",
|
||||||
|
"value_placeholder_multi": "値(カンマで複数指定可)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "添付ファイル",
|
||||||
"from": "差出人",
|
"from": "差出人",
|
||||||
"to": "宛先",
|
"to": "宛先",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "本文"
|
"body": "本文"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "あり",
|
||||||
|
"has_type": "タイプ",
|
||||||
"contains": "を含む",
|
"contains": "を含む",
|
||||||
"not_contains": "を含まない",
|
"not_contains": "を含まない",
|
||||||
"is": "と完全一致",
|
"is": "と完全一致",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "동작",
|
"actions": "동작",
|
||||||
"add_action": "동작 추가",
|
"add_action": "동작 추가",
|
||||||
"stop_processing": "이후 규칙 무시하기",
|
"stop_processing": "이후 규칙 무시하기",
|
||||||
|
"attachment_type_placeholder": "예: pdf, doc, jpg",
|
||||||
|
"value_placeholder_multi": "값 (쉼표로 여러 개 구분)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "첨부 파일",
|
||||||
"from": "보낸 사람",
|
"from": "보낸 사람",
|
||||||
"to": "받는 사람",
|
"to": "받는 사람",
|
||||||
"cc": "참조",
|
"cc": "참조",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "본문"
|
"body": "본문"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "있음",
|
||||||
|
"has_type": "유형",
|
||||||
"contains": "포함함",
|
"contains": "포함함",
|
||||||
"not_contains": "포함하지 않음",
|
"not_contains": "포함하지 않음",
|
||||||
"is": "정확히 일치",
|
"is": "정확히 일치",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Darbības",
|
"actions": "Darbības",
|
||||||
"add_action": "Pievienot darbību",
|
"add_action": "Pievienot darbību",
|
||||||
"stop_processing": "Pārtraukt nākamo noteikumu apstrādi",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Pielikums",
|
||||||
"from": "No",
|
"from": "No",
|
||||||
"to": "Kam",
|
"to": "Kam",
|
||||||
"cc": "Kopija",
|
"cc": "Kopija",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Teksts"
|
"body": "Teksts"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "ir klāt",
|
||||||
|
"has_type": "tipa",
|
||||||
"contains": "satur",
|
"contains": "satur",
|
||||||
"not_contains": "nesatur",
|
"not_contains": "nesatur",
|
||||||
"is": "precīzi sakrīt",
|
"is": "precīzi sakrīt",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Acties",
|
"actions": "Acties",
|
||||||
"add_action": "Actie toevoegen",
|
"add_action": "Actie toevoegen",
|
||||||
"stop_processing": "Verwerking van volgende regels stoppen",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Bijlage",
|
||||||
"from": "Van",
|
"from": "Van",
|
||||||
"to": "Aan",
|
"to": "Aan",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Inhoud"
|
"body": "Inhoud"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "aanwezig",
|
||||||
|
"has_type": "van type",
|
||||||
"contains": "bevat",
|
"contains": "bevat",
|
||||||
"not_contains": "bevat niet",
|
"not_contains": "bevat niet",
|
||||||
"is": "is precies",
|
"is": "is precies",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Akcje",
|
"actions": "Akcje",
|
||||||
"add_action": "Dodaj akcję",
|
"add_action": "Dodaj akcję",
|
||||||
"stop_processing": "Zatrzymaj przetwarzanie kolejnych reguł",
|
"stop_processing": "Zatrzymaj przetwarzanie kolejnych reguł",
|
||||||
|
"attachment_type_placeholder": "np. pdf, doc, jpg",
|
||||||
|
"value_placeholder_multi": "Wartość (kilka oddzielonych przecinkami)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "Załącznik",
|
||||||
"from": "Od",
|
"from": "Od",
|
||||||
"to": "Do",
|
"to": "Do",
|
||||||
"cc": "DW",
|
"cc": "DW",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Treść"
|
"body": "Treść"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "jest obecny",
|
||||||
|
"has_type": "typu",
|
||||||
"contains": "zawiera",
|
"contains": "zawiera",
|
||||||
"not_contains": "nie zawiera",
|
"not_contains": "nie zawiera",
|
||||||
"is": "jest dokładnie",
|
"is": "jest dokładnie",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Ações",
|
"actions": "Ações",
|
||||||
"add_action": "Adicionar ação",
|
"add_action": "Adicionar ação",
|
||||||
"stop_processing": "Parar o processamento das regras seguintes",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Anexo",
|
||||||
"from": "De",
|
"from": "De",
|
||||||
"to": "Para",
|
"to": "Para",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Corpo"
|
"body": "Corpo"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "está presente",
|
||||||
|
"has_type": "do tipo",
|
||||||
"contains": "contém",
|
"contains": "contém",
|
||||||
"not_contains": "não contém",
|
"not_contains": "não contém",
|
||||||
"is": "é exatamente",
|
"is": "é exatamente",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Действия",
|
"actions": "Действия",
|
||||||
"add_action": "Добавить действие",
|
"add_action": "Добавить действие",
|
||||||
"stop_processing": "Прекратить обработку последующих правил",
|
"stop_processing": "Прекратить обработку последующих правил",
|
||||||
|
"attachment_type_placeholder": "напр. pdf, doc, jpg",
|
||||||
|
"value_placeholder_multi": "Значение (несколько через запятую)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "Вложение",
|
||||||
"from": "От",
|
"from": "От",
|
||||||
"to": "Кому",
|
"to": "Кому",
|
||||||
"cc": "Копия",
|
"cc": "Копия",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Тело"
|
"body": "Тело"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "присутствует",
|
||||||
|
"has_type": "типа",
|
||||||
"contains": "содержит",
|
"contains": "содержит",
|
||||||
"not_contains": "не содержит",
|
"not_contains": "не содержит",
|
||||||
"is": "точно совпадает",
|
"is": "точно совпадает",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "İşlemler",
|
"actions": "İşlemler",
|
||||||
"add_action": "İşlem Ekle",
|
"add_action": "İşlem Ekle",
|
||||||
"stop_processing": "Sonraki kuralları işlemeyi durdur",
|
"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": {
|
"condition_fields": {
|
||||||
|
"attachment": "Ek",
|
||||||
"from": "Kimden",
|
"from": "Kimden",
|
||||||
"to": "Kime",
|
"to": "Kime",
|
||||||
"cc": "Bilgi",
|
"cc": "Bilgi",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Gövde"
|
"body": "Gövde"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "mevcut",
|
||||||
|
"has_type": "türünde",
|
||||||
"contains": "içerir",
|
"contains": "içerir",
|
||||||
"not_contains": "içermez",
|
"not_contains": "içermez",
|
||||||
"is": "tam olarak",
|
"is": "tam olarak",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "Дії",
|
"actions": "Дії",
|
||||||
"add_action": "Додати дію",
|
"add_action": "Додати дію",
|
||||||
"stop_processing": "Зупинити обробку наступних правил",
|
"stop_processing": "Зупинити обробку наступних правил",
|
||||||
|
"attachment_type_placeholder": "напр. pdf, doc, jpg",
|
||||||
|
"value_placeholder_multi": "Значення (декілька через кому)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "Вкладення",
|
||||||
"from": "Від",
|
"from": "Від",
|
||||||
"to": "до",
|
"to": "до",
|
||||||
"cc": "Cc",
|
"cc": "Cc",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "Тіло"
|
"body": "Тіло"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "присутнє",
|
||||||
|
"has_type": "типу",
|
||||||
"contains": "містить",
|
"contains": "містить",
|
||||||
"not_contains": "не містить",
|
"not_contains": "не містить",
|
||||||
"is": "точно",
|
"is": "точно",
|
||||||
|
|||||||
@@ -1632,7 +1632,10 @@
|
|||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"add_action": "添加操作",
|
"add_action": "添加操作",
|
||||||
"stop_processing": "停止处理后续规则",
|
"stop_processing": "停止处理后续规则",
|
||||||
|
"attachment_type_placeholder": "例如:pdf、doc、jpg",
|
||||||
|
"value_placeholder_multi": "值(多个用逗号分隔)",
|
||||||
"condition_fields": {
|
"condition_fields": {
|
||||||
|
"attachment": "附件",
|
||||||
"from": "发件人",
|
"from": "发件人",
|
||||||
"to": "收件人",
|
"to": "收件人",
|
||||||
"cc": "抄送",
|
"cc": "抄送",
|
||||||
@@ -1642,6 +1645,8 @@
|
|||||||
"body": "正文"
|
"body": "正文"
|
||||||
},
|
},
|
||||||
"comparators": {
|
"comparators": {
|
||||||
|
"has_any": "存在",
|
||||||
|
"has_type": "类型为",
|
||||||
"contains": "包含",
|
"contains": "包含",
|
||||||
"not_contains": "不包含",
|
"not_contains": "不包含",
|
||||||
"is": "等于",
|
"is": "等于",
|
||||||
|
|||||||
Reference in New Issue
Block a user