mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
Merge pull request #1154 from dealerweb/feat/filter-all-messages
feat: a filter condition that matches every message
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { FilterRuleModal } from '../filter-rule-modal';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
const rule: FilterRule = {
|
||||
id: 'r1',
|
||||
name: 'Alles markieren',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
stopProcessing: false,
|
||||
};
|
||||
|
||||
function openModal(props: Partial<React.ComponentProps<typeof FilterRuleModal>> = {}) {
|
||||
const onSave = vi.fn();
|
||||
render(<FilterRuleModal mailboxes={[]} initialRule={rule} onSave={onSave} onClose={vi.fn()} {...props} />);
|
||||
return { onSave, saved: () => onSave.mock.calls[0][0] as FilterRule };
|
||||
}
|
||||
|
||||
const fieldSelect = () => screen.getByLabelText('conditions') as HTMLSelectElement;
|
||||
const comparatorSelect = () => screen.queryByLabelText('comparators.contains') as HTMLSelectElement | null;
|
||||
const valueInput = () => screen.queryByPlaceholderText('value_placeholder_multi') as HTMLInputElement | null;
|
||||
|
||||
describe('the "all messages" condition', () => {
|
||||
it('is offered, and asks for neither a comparator nor a value', () => {
|
||||
const { onSave, saved } = openModal();
|
||||
expect([...fieldSelect().options].map((o) => o.value)).toContain('all');
|
||||
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
|
||||
expect(comparatorSelect()).toBeNull();
|
||||
expect(valueInput()).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(saved().conditions).toEqual([{ field: 'all', comparator: 'any', value: '' }]);
|
||||
});
|
||||
|
||||
it('turns back into a regular condition when another field is chosen', () => {
|
||||
openModal();
|
||||
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
|
||||
fireEvent.change(fieldSelect(), { target: { value: 'subject' } });
|
||||
expect(comparatorSelect()!.value).toBe('contains');
|
||||
expect(valueInput()!.value).toBe('');
|
||||
});
|
||||
|
||||
it('opens as it was saved, and keeps itself next to a blank row', () => {
|
||||
const { saved } = openModal({ initialRule: { ...rule, conditions: [{ field: 'all', comparator: 'any', value: '' }] } });
|
||||
expect(fieldSelect().value).toBe('all');
|
||||
expect(comparatorSelect()).toBeNull();
|
||||
expect(valueInput()).toBeNull();
|
||||
// A second row left blank is dropped on save; this one is not.
|
||||
fireEvent.click(screen.getByText('add_condition'));
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(saved().conditions).toEqual([{ field: 'all', comparator: 'any', value: '' }]);
|
||||
});
|
||||
|
||||
it('drops the header name of a custom header condition it replaces', () => {
|
||||
const { saved } = openModal({
|
||||
initialRule: { ...rule, conditions: [{ field: 'header', comparator: 'contains', value: 'bar', headerName: 'X-Foo' }] },
|
||||
});
|
||||
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(saved().conditions).toEqual([{ field: 'all', comparator: 'any', value: '' }]);
|
||||
expect(saved().conditions[0]).not.toHaveProperty('headerName');
|
||||
});
|
||||
|
||||
it('stays when a suggestion is added next to it', () => {
|
||||
const { saved } = openModal({
|
||||
initialRule: { ...rule, conditions: [{ field: 'all', comparator: 'any', value: '' }] },
|
||||
suggestions: [{ id: 'subject', label: 'Betreff Rechnung', condition: { field: 'subject', comparator: 'contains', value: 'Rechnung' } }],
|
||||
});
|
||||
// Unlike a blank row, it takes no value and is not made way for.
|
||||
fireEvent.click(screen.getByText('Betreff Rechnung'));
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(saved().conditions).toEqual([
|
||||
{ field: 'all', comparator: 'any', value: '' },
|
||||
{ field: 'subject', comparator: 'contains', value: 'Rechnung' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('is never run on the messages already in the folder', () => {
|
||||
openModal({ offerApplyToExisting: true });
|
||||
const applyExisting = screen.getByLabelText('apply_existing') as HTMLInputElement;
|
||||
expect(applyExisting.disabled).toBe(false);
|
||||
fireEvent.change(fieldSelect(), { target: { value: 'all' } });
|
||||
expect(fieldSelect().value).toBe('all');
|
||||
expect(applyExisting.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ interface FilterRuleModalProps {
|
||||
}
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = [
|
||||
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
|
||||
"from", "to", "cc", "subject", "header", "size", "body", "attachment", "all",
|
||||
];
|
||||
|
||||
const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
@@ -63,9 +63,13 @@ const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
|
||||
|
||||
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
|
||||
|
||||
// "All messages" has nothing to choose: its one comparator is not shown.
|
||||
const ALL_MESSAGES_COMPARATORS: FilterComparator[] = ["any"];
|
||||
|
||||
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === "size") return SIZE_COMPARATORS;
|
||||
if (field === "attachment") return ATTACHMENT_COMPARATORS;
|
||||
if (field === "all") return ALL_MESSAGES_COMPARATORS;
|
||||
if (field === "from" || field === "to" || field === "cc") return ADDRESS_COMPARATORS;
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
@@ -102,6 +106,11 @@ function isConditionValueEmpty(v: string | string[]): boolean {
|
||||
return !v.trim();
|
||||
}
|
||||
|
||||
// Conditions that test without a value: "has an attachment" and "all messages".
|
||||
function takesNoValue(c: Pick<FilterCondition, "field" | "comparator">): boolean {
|
||||
return c.field === "all" || (c.field === "attachment" && c.comparator === "has_any");
|
||||
}
|
||||
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: "move", value: "" };
|
||||
}
|
||||
@@ -181,11 +190,11 @@ export function FilterRuleModal({
|
||||
// comma the user just typed mid-edit.
|
||||
const validConditions = useMemo(() => conditions
|
||||
.filter((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return true;
|
||||
if (takesNoValue(c)) return true;
|
||||
return !isConditionValueEmpty(c.value);
|
||||
})
|
||||
.map((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return c;
|
||||
if (takesNoValue(c)) 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);
|
||||
@@ -246,7 +255,7 @@ export function FilterRuleModal({
|
||||
const applySuggestion = (suggestion: RuleSuggestion) => {
|
||||
setConditions((prev) => {
|
||||
// The blank row a rule starts with makes way for the suggestion.
|
||||
const kept = prev.filter((c) => !(c.field !== "attachment" && isConditionValueEmpty(c.value)));
|
||||
const kept = prev.filter((c) => !(c.field !== "attachment" && c.field !== "all" && isConditionValueEmpty(c.value)));
|
||||
const replaceAt = suggestion.replaces ? kept.findIndex(suggestion.replaces) : -1;
|
||||
if (replaceAt === -1) return [...kept, suggestion.condition];
|
||||
return kept.map((c, i) => (i === replaceAt ? suggestion.condition : c));
|
||||
@@ -271,9 +280,9 @@ export function FilterRuleModal({
|
||||
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") {
|
||||
// has_any and "all messages" take no value; clear it so we don't
|
||||
// leak old text into the generated Sieve.
|
||||
if (takesNoValue(updated)) {
|
||||
updated.value = "";
|
||||
}
|
||||
// Size is numeric, single value only - collapse any list to scalar.
|
||||
@@ -437,24 +446,27 @@ export function FilterRuleModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={condition.comparator}
|
||||
onChange={(e) =>
|
||||
updateCondition(index, { comparator: e.target.value as FilterComparator })
|
||||
}
|
||||
className={selectClass}
|
||||
aria-label={t("comparators.contains")}
|
||||
>
|
||||
{comparatorsFor(condition.field).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{condition.field !== "all" && (
|
||||
<select
|
||||
value={condition.comparator}
|
||||
onChange={(e) =>
|
||||
updateCondition(index, { comparator: e.target.value as FilterComparator })
|
||||
}
|
||||
className={selectClass}
|
||||
aria-label={t("comparators.contains")}
|
||||
>
|
||||
{comparatorsFor(condition.field).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</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" ? (
|
||||
{/* has_any and "all messages" take no value; render a stub
|
||||
so the row layout stays consistent but no input is
|
||||
editable. */}
|
||||
{takesNoValue(condition) ? (
|
||||
<div className="flex-1 min-w-[120px]" />
|
||||
) : (
|
||||
<Input
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { VisualRuleSummary } from '../filter-settings';
|
||||
import { RuleSummary, VisualRuleSummary } from '../filter-settings';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
|
||||
// Keys as text, except one a locale leaves empty: Mongolian has no words of
|
||||
// its own for "starts with" (nor for "ends with").
|
||||
vi.mock('next-intl', () => {
|
||||
const t = (key: string) => (key === 'comparators.starts_with' ? '' : key);
|
||||
return { useTranslations: () => t, useLocale: () => 'en' };
|
||||
});
|
||||
|
||||
const rule = (overrides: Partial<FilterRule>): FilterRule => ({
|
||||
id: 'r1',
|
||||
name: 'Rule',
|
||||
@@ -67,4 +74,25 @@ describe('filter rule summary (expanded view)', () => {
|
||||
'comparators.has_any',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the value where a locale leaves the comparator empty', () => {
|
||||
render(<VisualRuleSummary rule={rule({
|
||||
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Rechnung' }],
|
||||
})} />);
|
||||
expect(valuesOf('subject').values).toEqual(['“Rechnung”']);
|
||||
});
|
||||
|
||||
it('shows "all messages" on its own, without a comparator or a value', () => {
|
||||
const all = rule({
|
||||
conditions: [{ field: 'all', comparator: 'any', value: '' }],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
});
|
||||
const { unmount } = render(<VisualRuleSummary rule={all} />);
|
||||
const chip = screen.getByText('condition_fields.all').parentElement!;
|
||||
expect([...chip.children].map((el) => el.textContent)).toEqual(['condition_fields.all']);
|
||||
unmount();
|
||||
|
||||
const { container } = render(<RuleSummary rule={all} />);
|
||||
expect(container.textContent).toBe('condition_fields.all→action_types.mark_read');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,11 +33,13 @@ function isReadonlyRule(r: FilterRule): boolean {
|
||||
return r.origin === "external" || r.origin === "opaque";
|
||||
}
|
||||
|
||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
export function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
|
||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
// "All messages" says it all: no comparator, no value.
|
||||
if (c.field === "all") return field;
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
// has_any is a no-value test ("attachment is present"); appending
|
||||
// `""` would look broken in the summary line.
|
||||
@@ -101,7 +103,9 @@ export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
</span>
|
||||
{rule.conditions.map((c, i) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
// "All messages" says it all: no comparator, no value.
|
||||
const everyMessage = c.field === "all";
|
||||
const comparator = everyMessage ? null : t(`comparators.${c.comparator}`);
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && (
|
||||
@@ -109,8 +113,8 @@ export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
)}
|
||||
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="shrink-0 font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{comparator}</span>
|
||||
{!(c.field === "attachment" && c.comparator === "has_any") && (
|
||||
{!everyMessage && <span className="shrink-0 text-muted-foreground">{comparator}</span>}
|
||||
{!everyMessage && !(c.field === "attachment" && c.comparator === "has_any") && (
|
||||
// The values of one condition stay in its chip, where "or"
|
||||
// cannot be mistaken for the joiner between conditions. They
|
||||
// wrap between each other under the first value, the "or"
|
||||
|
||||
@@ -155,6 +155,13 @@ describe('retroactiveSupport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('never runs a rule for all messages on old mail', () => {
|
||||
// It would act on everything in the folder, a "delete" on the whole inbox.
|
||||
const all = rule([{ field: 'all', comparator: 'any', value: '' }], [{ type: 'mark_read' }]);
|
||||
expect(retroactiveSupport(all)).toEqual({ ok: false, reason: 'condition' });
|
||||
expect(planRetroactive(all, [message()])).toEqual({ ids: [], steps: [] });
|
||||
});
|
||||
|
||||
it('refuses a folder known only by its path, and keep', () => {
|
||||
expect(retroactiveSupport(rule([{ field: 'from', comparator: 'address_is', value: 'a@b.c' }], [{ type: 'move', value: 'News' }])).ok).toBe(false);
|
||||
expect(retroactiveSupport(rule([{ field: 'from', comparator: 'address_is', value: 'a@b.c' }], [{ type: 'keep' }])).ok).toBe(false);
|
||||
|
||||
@@ -17,7 +17,9 @@ export interface SieveCapabilities {
|
||||
|
||||
export type FilterConditionField =
|
||||
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
|
||||
| 'attachment';
|
||||
| 'attachment'
|
||||
// Every message (Sieve `true`): no value, and always the comparator 'any'.
|
||||
| 'all';
|
||||
|
||||
export type FilterComparator =
|
||||
| 'contains' | 'not_contains'
|
||||
@@ -38,7 +40,9 @@ export type FilterComparator =
|
||||
// sub.acme.com nor acme.com.evil)
|
||||
// Older Bulwark versions read these as `header :contains`, so a script
|
||||
// they save keeps working, only less strictly.
|
||||
| 'address_is' | 'domain_is';
|
||||
| 'address_is' | 'domain_is'
|
||||
// For field === 'all': any message at all.
|
||||
| 'any';
|
||||
|
||||
export type FilterActionType =
|
||||
| 'move' | 'copy' | 'forward'
|
||||
|
||||
@@ -225,6 +225,45 @@ describe('generateScript', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('all messages', () => {
|
||||
const ALL = { field: 'all', comparator: 'any', value: '' } as const;
|
||||
|
||||
it('matches every message', () => {
|
||||
const script = generateScript([makeRule({ conditions: [ALL], actions: [{ type: 'mark_read' }] })]);
|
||||
expect(script).toContain('if true {\n addflag "\\\\Seen";\n}');
|
||||
// Nothing to require for it.
|
||||
expect(script).toMatch(/^require \["imap4flags"\];$/m);
|
||||
});
|
||||
|
||||
it('still leaves spam out of a move to a folder', () => {
|
||||
const script = generateScript(
|
||||
[makeRule({ conditions: [ALL], actions: [{ type: 'move', value: 'Archive' }] })],
|
||||
undefined,
|
||||
{ extensions: ['fileinto', 'spamtestplus', 'relational'] },
|
||||
);
|
||||
expect(script).toContain('if allof(true, not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {');
|
||||
});
|
||||
|
||||
it('stands next to other conditions', () => {
|
||||
const subject = { field: 'subject', comparator: 'contains', value: 'Rechnung' } as const;
|
||||
expect(generateScript([makeRule({ conditions: [ALL, subject] })]))
|
||||
.toContain('if allof(true, header :contains "Subject" "Rechnung") {');
|
||||
expect(generateScript([makeRule({ matchType: 'any', conditions: [subject, ALL] })]))
|
||||
.toContain('if anyof(header :contains "Subject" "Rechnung", true) {');
|
||||
// Any of them, and still no spam into a folder.
|
||||
expect(generateScript(
|
||||
[makeRule({ matchType: 'any', conditions: [subject, ALL] })],
|
||||
undefined,
|
||||
{ extensions: ['fileinto', 'spamtestplus', 'relational'] },
|
||||
)).toContain('if allof(anyof(header :contains "Subject" "Rechnung", true), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {');
|
||||
});
|
||||
|
||||
it('reads back as written', () => {
|
||||
const rules = [makeRule({ conditions: [ALL], actions: [{ type: 'mark_read' }] })];
|
||||
expect(parseScript(generateScript(rules)).rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopProcessing', () => {
|
||||
it('appends stop when stopProcessing is true', () => {
|
||||
const script = generateScript([makeRule({ stopProcessing: true })]);
|
||||
|
||||
@@ -36,6 +36,9 @@ function formatStringArg(values: string[], transform: (s: string) => string = (s
|
||||
function generateCondition(condition: FilterCondition): string {
|
||||
const { field, comparator, value } = condition;
|
||||
|
||||
// Every message: there is nothing to compare.
|
||||
if (field === 'all') return 'true';
|
||||
|
||||
if (field === 'size') {
|
||||
// Size is numeric, single value only. It is written unquoted, so
|
||||
// anything but a number (with an optional K/M/G quantifier) would be
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "الموضوع",
|
||||
"header": "ترويسة مخصصة",
|
||||
"size": "الحجم",
|
||||
"body": "النص"
|
||||
"body": "النص",
|
||||
"all": "جميع الرسائل"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "موجود",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "Assumpte",
|
||||
"header": "Capçalera personalitzada",
|
||||
"size": "Mida",
|
||||
"body": "Cos"
|
||||
"body": "Cos",
|
||||
"all": "Tots els missatges"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "és present",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Předmět",
|
||||
"header": "Vlastní hlavička",
|
||||
"size": "Velikost",
|
||||
"body": "Tělo zprávy"
|
||||
"body": "Tělo zprávy",
|
||||
"all": "Všechny zprávy"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "je přítomna",
|
||||
|
||||
@@ -2120,7 +2120,8 @@
|
||||
"subject": "Emne",
|
||||
"header": "Brugerdefineret header",
|
||||
"size": "Størrelse",
|
||||
"body": "Brødtekst"
|
||||
"body": "Brødtekst",
|
||||
"all": "Alle beskeder"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "er til stede",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Betreff",
|
||||
"header": "Benutzerdefinierter Header",
|
||||
"size": "Größe",
|
||||
"body": "Nachrichtentext"
|
||||
"body": "Nachrichtentext",
|
||||
"all": "Alle Nachrichten"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "vorhanden",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "Subject",
|
||||
"header": "Custom Header",
|
||||
"size": "Size",
|
||||
"body": "Body"
|
||||
"body": "Body",
|
||||
"all": "All messages"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "is present",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Asunto",
|
||||
"header": "Encabezado personalizado",
|
||||
"size": "Tamaño",
|
||||
"body": "Cuerpo"
|
||||
"body": "Cuerpo",
|
||||
"all": "Todos los mensajes"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "está presente",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "موضوع",
|
||||
"header": "هدر سفارشی",
|
||||
"size": "حجم",
|
||||
"body": "بدنه"
|
||||
"body": "بدنه",
|
||||
"all": "همه پیامها"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "وجود دارد",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Objet",
|
||||
"header": "En-tête personnalisé",
|
||||
"size": "Taille",
|
||||
"body": "Corps"
|
||||
"body": "Corps",
|
||||
"all": "Tous les messages"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "est présent",
|
||||
|
||||
@@ -2037,7 +2037,8 @@
|
||||
"header": "כותרת מותאמת אישית",
|
||||
"size": "גודל",
|
||||
"body": "גוף",
|
||||
"attachment": "קובץ מצורף"
|
||||
"attachment": "קובץ מצורף",
|
||||
"all": "כל ההודעות"
|
||||
},
|
||||
"comparators": {
|
||||
"contains": "מכיל",
|
||||
|
||||
@@ -2120,7 +2120,8 @@
|
||||
"subject": "Tárgy",
|
||||
"header": "Egyéni fejléc",
|
||||
"size": "Méret",
|
||||
"body": "Törzs"
|
||||
"body": "Törzs",
|
||||
"all": "Minden üzenet"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "jelen van",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Oggetto",
|
||||
"header": "Intestazione personalizzata",
|
||||
"size": "Dimensione",
|
||||
"body": "Corpo"
|
||||
"body": "Corpo",
|
||||
"all": "Tutti i messaggi"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "è presente",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "件名",
|
||||
"header": "カスタムヘッダー",
|
||||
"size": "サイズ",
|
||||
"body": "本文"
|
||||
"body": "本文",
|
||||
"all": "すべてのメール"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "あり",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "제목",
|
||||
"header": "사용자 지정 헤더",
|
||||
"size": "크기",
|
||||
"body": "본문"
|
||||
"body": "본문",
|
||||
"all": "모든 메일"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "있음",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Temats",
|
||||
"header": "Pielāgota galvene",
|
||||
"size": "Izmērs",
|
||||
"body": "Teksts"
|
||||
"body": "Teksts",
|
||||
"all": "Visas vēstules"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "ir klāt",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "Сэдэв",
|
||||
"header": "Захиалгат толгой хэсэг",
|
||||
"size": "Хэмжээ",
|
||||
"body": "Бие"
|
||||
"body": "Бие",
|
||||
"all": "Бүх захиа"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": " байгаа",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "Emne",
|
||||
"header": "Egendefinert meldingshode",
|
||||
"size": "Størrelse",
|
||||
"body": "Meldingstekst"
|
||||
"body": "Meldingstekst",
|
||||
"all": "Alle meldinger"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "finnes",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Onderwerp",
|
||||
"header": "Aangepaste header",
|
||||
"size": "Grootte",
|
||||
"body": "Inhoud"
|
||||
"body": "Inhoud",
|
||||
"all": "Alle berichten"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "aanwezig",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Temat",
|
||||
"header": "Niestandardowy nagłówek",
|
||||
"size": "Rozmiar",
|
||||
"body": "Treść"
|
||||
"body": "Treść",
|
||||
"all": "Wszystkie wiadomości"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "jest obecny",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Assunto",
|
||||
"header": "Cabeçalho personalizado",
|
||||
"size": "Tamanho",
|
||||
"body": "Corpo"
|
||||
"body": "Corpo",
|
||||
"all": "Todas as mensagens"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "está presente",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "Subiect",
|
||||
"header": "Antet personalizat",
|
||||
"size": "Dimensiune",
|
||||
"body": "Corpul mesajului"
|
||||
"body": "Corpul mesajului",
|
||||
"all": "Toate mesajele"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "este prezent",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Тема",
|
||||
"header": "Пользовательский заголовок",
|
||||
"size": "Размер",
|
||||
"body": "Тело"
|
||||
"body": "Тело",
|
||||
"all": "Все письма"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "присутствует",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "Predmet",
|
||||
"header": "Vlastná hlavička",
|
||||
"size": "Veľkosť",
|
||||
"body": "Telo správy"
|
||||
"body": "Telo správy",
|
||||
"all": "Všetky správy"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "je prítomná",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Konu",
|
||||
"header": "Özel Başlık",
|
||||
"size": "Boyut",
|
||||
"body": "Gövde"
|
||||
"body": "Gövde",
|
||||
"all": "Tüm iletiler"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "mevcut",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "Тема",
|
||||
"header": "Спеціальний заголовок",
|
||||
"size": "Розмір",
|
||||
"body": "Тіло"
|
||||
"body": "Тіло",
|
||||
"all": "Усі листи"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "присутнє",
|
||||
|
||||
@@ -2124,7 +2124,8 @@
|
||||
"subject": "主題",
|
||||
"header": "自訂標頭",
|
||||
"size": "大小",
|
||||
"body": "內文"
|
||||
"body": "內文",
|
||||
"all": "所有郵件"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "存在",
|
||||
|
||||
@@ -2117,7 +2117,8 @@
|
||||
"subject": "主题",
|
||||
"header": "自定义标头",
|
||||
"size": "大小",
|
||||
"body": "正文"
|
||||
"body": "正文",
|
||||
"all": "所有邮件"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "存在",
|
||||
|
||||
Reference in New Issue
Block a user