Merge pull request #1154 from dealerweb/feat/filter-all-messages

feat: a filter condition that matches every message
This commit is contained in:
Linus Rath
2026-10-02 10:30:42 +02:00
35 changed files with 276 additions and 59 deletions
@@ -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);
});
});
+36 -24
View File
@@ -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');
});
});
+8 -4
View File
@@ -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);
+6 -2
View File
@@ -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'
+39
View File
@@ -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 })]);
+3
View File
@@ -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
+2 -1
View File
@@ -2124,7 +2124,8 @@
"subject": "الموضوع",
"header": "ترويسة مخصصة",
"size": "الحجم",
"body": "النص"
"body": "النص",
"all": "جميع الرسائل"
},
"comparators": {
"has_any": "موجود",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "Betreff",
"header": "Benutzerdefinierter Header",
"size": "Größe",
"body": "Nachrichtentext"
"body": "Nachrichtentext",
"all": "Alle Nachrichten"
},
"comparators": {
"has_any": "vorhanden",
+2 -1
View File
@@ -2124,7 +2124,8 @@
"subject": "Subject",
"header": "Custom Header",
"size": "Size",
"body": "Body"
"body": "Body",
"all": "All messages"
},
"comparators": {
"has_any": "is present",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -2124,7 +2124,8 @@
"subject": "موضوع",
"header": "هدر سفارشی",
"size": "حجم",
"body": "بدنه"
"body": "بدنه",
"all": "همه پیام‌ها"
},
"comparators": {
"has_any": "وجود دارد",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -2037,7 +2037,8 @@
"header": "כותרת מותאמת אישית",
"size": "גודל",
"body": "גוף",
"attachment": "קובץ מצורף"
"attachment": "קובץ מצורף",
"all": "כל ההודעות"
},
"comparators": {
"contains": "מכיל",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "Oggetto",
"header": "Intestazione personalizzata",
"size": "Dimensione",
"body": "Corpo"
"body": "Corpo",
"all": "Tutti i messaggi"
},
"comparators": {
"has_any": "è presente",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "件名",
"header": "カスタムヘッダー",
"size": "サイズ",
"body": "本文"
"body": "本文",
"all": "すべてのメール"
},
"comparators": {
"has_any": "あり",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "제목",
"header": "사용자 지정 헤더",
"size": "크기",
"body": "본문"
"body": "본문",
"all": "모든 메일"
},
"comparators": {
"has_any": "있음",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -2124,7 +2124,8 @@
"subject": "Сэдэв",
"header": "Захиалгат толгой хэсэг",
"size": "Хэмжээ",
"body": "Бие"
"body": "Бие",
"all": "Бүх захиа"
},
"comparators": {
"has_any": " байгаа",
+2 -1
View File
@@ -2124,7 +2124,8 @@
"subject": "Emne",
"header": "Egendefinert meldingshode",
"size": "Størrelse",
"body": "Meldingstekst"
"body": "Meldingstekst",
"all": "Alle meldinger"
},
"comparators": {
"has_any": "finnes",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "Onderwerp",
"header": "Aangepaste header",
"size": "Grootte",
"body": "Inhoud"
"body": "Inhoud",
"all": "Alle berichten"
},
"comparators": {
"has_any": "aanwezig",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "Тема",
"header": "Пользовательский заголовок",
"size": "Размер",
"body": "Тело"
"body": "Тело",
"all": "Все письма"
},
"comparators": {
"has_any": "присутствует",
+2 -1
View File
@@ -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á",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "Тема",
"header": "Спеціальний заголовок",
"size": "Розмір",
"body": "Тіло"
"body": "Тіло",
"all": "Усі листи"
},
"comparators": {
"has_any": "присутнє",
+2 -1
View File
@@ -2124,7 +2124,8 @@
"subject": "主題",
"header": "自訂標頭",
"size": "大小",
"body": "內文"
"body": "內文",
"all": "所有郵件"
},
"comparators": {
"has_any": "存在",
+2 -1
View File
@@ -2117,7 +2117,8 @@
"subject": "主题",
"header": "自定义标头",
"size": "大小",
"body": "正文"
"body": "正文",
"all": "所有邮件"
},
"comparators": {
"has_any": "存在",