From 1c6aa680c5221ba9c2bf142ec636cebe4884e656 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:39:43 +0200 Subject: [PATCH] feat: port the extended Sieve rules (attachment field, multi-value conditions) from the webmail --- locales/cs/common.json | 11 +- locales/de/common.json | 11 +- locales/en/common.json | 12 +- locales/es/common.json | 11 +- locales/fr/common.json | 11 +- locales/it/common.json | 11 +- locales/ja/common.json | 11 +- locales/ko/common.json | 11 +- locales/lv/common.json | 11 +- locales/nl/common.json | 11 +- locales/pl/common.json | 11 +- locales/pt/common.json | 11 +- locales/ru/common.json | 11 +- locales/uk/common.json | 11 +- locales/zh/common.json | 11 +- src/components/filters/FilterRuleModal.tsx | 101 ++++++-- src/components/settings/FilterSettings.tsx | 18 +- .../sieve/__tests__/condition-value.test.ts | 61 +++++ .../sieve/__tests__/extended-rules.test.ts | 244 ++++++++++++++++++ .../sieve/__tests__/external-rules.test.ts | 6 +- src/lib/sieve/__tests__/generator.test.ts | 4 +- src/lib/sieve/__tests__/parser.test.ts | 4 +- src/lib/sieve/condition-value.ts | 60 +++++ src/lib/sieve/generator.ts | 74 +++++- src/lib/sieve/parser.ts | 155 +++++++++-- src/lib/sieve/types.ts | 22 +- 26 files changed, 782 insertions(+), 133 deletions(-) create mode 100644 src/lib/sieve/__tests__/condition-value.test.ts create mode 100644 src/lib/sieve/__tests__/extended-rules.test.ts create mode 100644 src/lib/sieve/condition-value.ts diff --git a/locales/cs/common.json b/locales/cs/common.json index cca4485c..54ef55ef 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1382,7 +1382,8 @@ "subject": "Předmět", "header": "Vlastní hlavička", "size": "Velikost", - "body": "Tělo zprávy" + "body": "Tělo zprávy", + "attachment": "Příloha" }, "comparators": { "contains": "obsahuje", @@ -1393,7 +1394,9 @@ "ends_with": "končí na", "matches": "odpovídá vzoru", "greater_than": "je větší než", - "less_than": "je menší než" + "less_than": "je menší než", + "has_any": "je přítomna", + "has_type": "typu" }, "action_types": { "move": "Přesunout do složky", @@ -1466,7 +1469,9 @@ "actions_count": "{count, plural, one {1 akce} few {# akce} other {# akcí}}" }, "origin_external": "Externí", - "managed_by_tooltip": "Spravováno aplikací {source}. Úpravy provádějte v příslušné aplikaci nebo použijte editor zdrojového kódu Sieve." + "managed_by_tooltip": "Spravováno aplikací {source}. Úpravy provádějte v příslušné aplikaci nebo použijte editor zdrojového kódu Sieve.", + "value_placeholder_multi": "Hodnota (více oddělených čárkami)", + "attachment_type_placeholder": "např. pdf, doc, jpg" }, "templates": { "title": "E-mailové šablony", diff --git a/locales/de/common.json b/locales/de/common.json index 97228c21..f7579670 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1377,7 +1377,8 @@ "subject": "Betreff", "header": "Benutzerdefinierter Header", "size": "Größe", - "body": "Nachrichtentext" + "body": "Nachrichtentext", + "attachment": "Anhang" }, "comparators": { "contains": "enthält", @@ -1388,7 +1389,9 @@ "ends_with": "endet mit", "matches": "entspricht dem Muster", "greater_than": "ist größer als", - "less_than": "ist kleiner als" + "less_than": "ist kleiner als", + "has_any": "vorhanden", + "has_type": "vom Typ" }, "action_types": { "move": "In Ordner verschieben", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}" }, "origin_external": "Extern", - "managed_by_tooltip": "Verwaltet von {source}. Bearbeiten Sie sie in dieser App oder verwenden Sie den rohen Sieve-Editor." + "managed_by_tooltip": "Verwaltet von {source}. Bearbeiten Sie sie in dieser App oder verwenden Sie den rohen Sieve-Editor.", + "value_placeholder_multi": "Wert (mehrere mit Komma trennen)", + "attachment_type_placeholder": "z.B. pdf, doc, jpg" }, "templates": { "title": "E-Mail-Vorlagen", diff --git a/locales/en/common.json b/locales/en/common.json index df031736..1f7cbf85 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1390,7 +1390,8 @@ "subject": "Subject", "header": "Custom Header", "size": "Size", - "body": "Body" + "body": "Body", + "attachment": "Attachment" }, "comparators": { "contains": "contains", @@ -1401,7 +1402,9 @@ "ends_with": "ends with", "matches": "matches pattern", "greater_than": "is greater than", - "less_than": "is less than" + "less_than": "is less than", + "has_any": "is present", + "has_type": "of type" }, "action_types": { "move": "Move to folder", @@ -1474,8 +1477,9 @@ "conditions_count": "{count, plural, one {# condition} other {# conditions}}", "actions_count": "{count, plural, one {# action} other {# actions}}" }, - "origin_external": "External", - "managed_by_tooltip": "Managed by {source}. Edit it in that app, or use the raw Sieve editor." + "managed_by_tooltip": "Managed by {source}. Edit it in that app, or use the raw Sieve editor.", + "value_placeholder_multi": "Value (multiple separated by commas)", + "attachment_type_placeholder": "e.g. pdf, doc, jpg" }, "templates": { "title": "Email Templates", diff --git a/locales/es/common.json b/locales/es/common.json index 50d099d9..351e7595 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1377,7 +1377,8 @@ "subject": "Asunto", "header": "Encabezado personalizado", "size": "Tamaño", - "body": "Cuerpo" + "body": "Cuerpo", + "attachment": "Adjunto" }, "comparators": { "contains": "contiene", @@ -1388,7 +1389,9 @@ "ends_with": "termina con", "matches": "coincide con el patrón", "greater_than": "es mayor que", - "less_than": "es menor que" + "less_than": "es menor que", + "has_any": "está presente", + "has_type": "del tipo" }, "action_types": { "move": "Mover a carpeta", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# acción} other {# acciones}}" }, "origin_external": "Externo", - "managed_by_tooltip": "Gestionado por {source}. Edítalo en esa aplicación o usa el editor Sieve sin procesar." + "managed_by_tooltip": "Gestionado por {source}. Edítalo en esa aplicación o usa el editor Sieve sin procesar.", + "value_placeholder_multi": "Valor (varios separados por comas)", + "attachment_type_placeholder": "p. ej. pdf, doc, jpg" }, "templates": { "title": "Plantillas de correo", diff --git a/locales/fr/common.json b/locales/fr/common.json index b37618cd..45f4cde5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1377,7 +1377,8 @@ "subject": "Objet", "header": "En-tête personnalisé", "size": "Taille", - "body": "Corps" + "body": "Corps", + "attachment": "Pièce jointe" }, "comparators": { "contains": "contient", @@ -1388,7 +1389,9 @@ "ends_with": "se termine par", "matches": "correspond au motif", "greater_than": "est supérieur à", - "less_than": "est inférieur à" + "less_than": "est inférieur à", + "has_any": "est présent", + "has_type": "de type" }, "action_types": { "move": "Déplacer vers le dossier", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# action} other {# actions}}" }, "origin_external": "Externe", - "managed_by_tooltip": "Géré par {source}. Modifiez-le dans cette application, ou utilisez l'éditeur Sieve brut." + "managed_by_tooltip": "Géré par {source}. Modifiez-le dans cette application, ou utilisez l'éditeur Sieve brut.", + "value_placeholder_multi": "Valeur (plusieurs séparées par des virgules)", + "attachment_type_placeholder": "p. ex. pdf, doc, jpg" }, "templates": { "title": "Modèles d'e-mails", diff --git a/locales/it/common.json b/locales/it/common.json index 050fd9f4..2b9e5f1d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1377,7 +1377,8 @@ "subject": "Oggetto", "header": "Intestazione personalizzata", "size": "Dimensione", - "body": "Corpo" + "body": "Corpo", + "attachment": "Allegato" }, "comparators": { "contains": "contiene", @@ -1388,7 +1389,9 @@ "ends_with": "termina con", "matches": "corrisponde al modello", "greater_than": "è maggiore di", - "less_than": "è minore di" + "less_than": "è minore di", + "has_any": "è presente", + "has_type": "di tipo" }, "action_types": { "move": "Sposta nella cartella", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# azione} other {# azioni}}" }, "origin_external": "Esterno", - "managed_by_tooltip": "Gestito da {source}. Modificalo in quell'app o usa l'editor Sieve grezzo." + "managed_by_tooltip": "Gestito da {source}. Modificalo in quell'app o usa l'editor Sieve grezzo.", + "value_placeholder_multi": "Valore (più valori separati da virgole)", + "attachment_type_placeholder": "es. pdf, doc, jpg" }, "templates": { "title": "Modelli email", diff --git a/locales/ja/common.json b/locales/ja/common.json index 00bc89ff..30e79f8e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1377,7 +1377,8 @@ "subject": "件名", "header": "カスタムヘッダー", "size": "サイズ", - "body": "本文" + "body": "本文", + "attachment": "添付ファイル" }, "comparators": { "contains": "を含む", @@ -1388,7 +1389,9 @@ "ends_with": "で終わる", "matches": "パターンに一致", "greater_than": "より大きい", - "less_than": "より小さい" + "less_than": "より小さい", + "has_any": "あり", + "has_type": "タイプ" }, "action_types": { "move": "フォルダーに移動", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, other {#個のアクション}}" }, "origin_external": "外部", - "managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。" + "managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。", + "value_placeholder_multi": "値(カンマで複数指定可)", + "attachment_type_placeholder": "例: pdf, doc, jpg" }, "templates": { "title": "メールテンプレート", diff --git a/locales/ko/common.json b/locales/ko/common.json index 0ae89054..4e40678c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1377,7 +1377,8 @@ "subject": "제목", "header": "사용자 지정 헤더", "size": "크기", - "body": "본문" + "body": "본문", + "attachment": "첨부 파일" }, "comparators": { "contains": "포함함", @@ -1388,7 +1389,9 @@ "ends_with": "다음으로 끝남", "matches": "패턴 일치", "greater_than": "다음보다 큼", - "less_than": "다음보다 작음" + "less_than": "다음보다 작음", + "has_any": "있음", + "has_type": "유형" }, "action_types": { "move": "폴더로 이동", @@ -1461,7 +1464,9 @@ "actions_count": "동작 {count}개" }, "origin_external": "외부", - "managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요." + "managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요.", + "value_placeholder_multi": "값 (쉼표로 여러 개 구분)", + "attachment_type_placeholder": "예: pdf, doc, jpg" }, "templates": { "title": "이메일 템플릿", diff --git a/locales/lv/common.json b/locales/lv/common.json index a8830bb1..534db7d6 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1377,7 +1377,8 @@ "subject": "Temats", "header": "Pielāgota galvene", "size": "Izmērs", - "body": "Teksts" + "body": "Teksts", + "attachment": "Pielikums" }, "comparators": { "contains": "satur", @@ -1388,7 +1389,9 @@ "ends_with": "beidzas ar", "matches": "atbilst paraugam", "greater_than": "lielāks nekā", - "less_than": "mazāks nekā" + "less_than": "mazāks nekā", + "has_any": "ir klāt", + "has_type": "tipa" }, "action_types": { "move": "Pārvietot uz mapi", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# darbība} other {# darbības}}" }, "origin_external": "Ārējs", - "managed_by_tooltip": "Pārvalda {source}. Rediģējiet to šajā lietotnē vai izmantojiet neapstrādāto Sieve redaktoru." + "managed_by_tooltip": "Pārvalda {source}. Rediģējiet to šajā lietotnē vai izmantojiet neapstrādāto Sieve redaktoru.", + "value_placeholder_multi": "Vērtība (vairākas atdalītas ar komatu)", + "attachment_type_placeholder": "piem. pdf, doc, jpg" }, "templates": { "title": "Vēstuļu veidnes", diff --git a/locales/nl/common.json b/locales/nl/common.json index 42b6e9ce..a4a16de6 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1377,7 +1377,8 @@ "subject": "Onderwerp", "header": "Aangepaste header", "size": "Grootte", - "body": "Inhoud" + "body": "Inhoud", + "attachment": "Bijlage" }, "comparators": { "contains": "bevat", @@ -1388,7 +1389,9 @@ "ends_with": "eindigt met", "matches": "komt overeen met patroon", "greater_than": "is groter dan", - "less_than": "is kleiner dan" + "less_than": "is kleiner dan", + "has_any": "aanwezig", + "has_type": "van type" }, "action_types": { "move": "Verplaatsen naar map", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# actie} other {# acties}}" }, "origin_external": "Extern", - "managed_by_tooltip": "Beheerd door {source}. Bewerk het in die app of gebruik de ruwe Sieve-editor." + "managed_by_tooltip": "Beheerd door {source}. Bewerk het in die app of gebruik de ruwe Sieve-editor.", + "value_placeholder_multi": "Waarde (meerdere met komma's gescheiden)", + "attachment_type_placeholder": "bijv. pdf, doc, jpg" }, "templates": { "title": "E-mailsjablonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index e6778fcf..e139a5e8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1377,7 +1377,8 @@ "subject": "Temat", "header": "Niestandardowy nagłówek", "size": "Rozmiar", - "body": "Treść" + "body": "Treść", + "attachment": "Załącznik" }, "comparators": { "contains": "zawiera", @@ -1388,7 +1389,9 @@ "ends_with": "kończy się na", "matches": "pasuje do wzorca", "greater_than": "jest większe niż", - "less_than": "jest mniejsze niż" + "less_than": "jest mniejsze niż", + "has_any": "jest obecny", + "has_type": "typu" }, "action_types": { "move": "Przenieś do folderu", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# akcja} other {# akcji}}" }, "origin_external": "Zewnętrzny", - "managed_by_tooltip": "Zarządzane przez {source}. Edytuj w tej aplikacji lub użyj surowego edytora Sieve." + "managed_by_tooltip": "Zarządzane przez {source}. Edytuj w tej aplikacji lub użyj surowego edytora Sieve.", + "value_placeholder_multi": "Wartość (kilka oddzielonych przecinkami)", + "attachment_type_placeholder": "np. pdf, doc, jpg" }, "templates": { "title": "Szablony wiadomości e-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4ffc6c03..6f7cade8 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1377,7 +1377,8 @@ "subject": "Assunto", "header": "Cabeçalho personalizado", "size": "Tamanho", - "body": "Corpo" + "body": "Corpo", + "attachment": "Anexo" }, "comparators": { "contains": "contém", @@ -1388,7 +1389,9 @@ "ends_with": "termina com", "matches": "corresponde ao padrão", "greater_than": "é maior que", - "less_than": "é menor que" + "less_than": "é menor que", + "has_any": "está presente", + "has_type": "do tipo" }, "action_types": { "move": "Mover para pasta", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# ação} other {# ações}}" }, "origin_external": "Externo", - "managed_by_tooltip": "Gerenciado por {source}. Edite nesse aplicativo ou use o editor Sieve bruto." + "managed_by_tooltip": "Gerenciado por {source}. Edite nesse aplicativo ou use o editor Sieve bruto.", + "value_placeholder_multi": "Valor (vários separados por vírgulas)", + "attachment_type_placeholder": "ex.: pdf, doc, jpg" }, "templates": { "title": "Modelos de e-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index d6fcebe0..96db6869 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1377,7 +1377,8 @@ "subject": "Тема", "header": "Пользовательский заголовок", "size": "Размер", - "body": "Тело" + "body": "Тело", + "attachment": "Вложение" }, "comparators": { "contains": "содержит", @@ -1388,7 +1389,9 @@ "ends_with": "заканчивается на", "matches": "соответствует шаблону", "greater_than": "больше чем", - "less_than": "меньше чем" + "less_than": "меньше чем", + "has_any": "присутствует", + "has_type": "типа" }, "action_types": { "move": "Переместить в папку", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# действие} other {# действий}}" }, "origin_external": "Внешнее", - "managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve." + "managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve.", + "value_placeholder_multi": "Значение (несколько через запятую)", + "attachment_type_placeholder": "напр. pdf, doc, jpg" }, "templates": { "title": "Шаблоны писем", diff --git a/locales/uk/common.json b/locales/uk/common.json index e026fcb2..99970820 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1377,7 +1377,8 @@ "subject": "Тема", "header": "Спеціальний заголовок", "size": "Розмір", - "body": "Тіло" + "body": "Тіло", + "attachment": "Вкладення" }, "comparators": { "contains": "містить", @@ -1388,7 +1389,9 @@ "ends_with": "закінчується на", "matches": "відповідає шаблону", "greater_than": "більше ніж", - "less_than": "менше ніж" + "less_than": "менше ніж", + "has_any": "присутнє", + "has_type": "типу" }, "action_types": { "move": "Перемістити в папку", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# дія} few {# дії} many {# дій} other {# дій}}" }, "origin_external": "Зовнішнє", - "managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve." + "managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve.", + "value_placeholder_multi": "Значення (декілька через кому)", + "attachment_type_placeholder": "напр. pdf, doc, jpg" }, "templates": { "title": "Шаблони електронної пошти", diff --git a/locales/zh/common.json b/locales/zh/common.json index ce2b639f..48be21c2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1377,7 +1377,8 @@ "subject": "主题", "header": "自定义标头", "size": "大小", - "body": "正文" + "body": "正文", + "attachment": "附件" }, "comparators": { "contains": "包含", @@ -1388,7 +1389,9 @@ "ends_with": "以…结尾", "matches": "匹配模式", "greater_than": "大于", - "less_than": "小于" + "less_than": "小于", + "has_any": "存在", + "has_type": "类型为" }, "action_types": { "move": "移至文件夹", @@ -1461,7 +1464,9 @@ "actions_count": "{count, plural, one {# 个操作} other {# 个操作}}" }, "origin_external": "外部", - "managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。" + "managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。", + "value_placeholder_multi": "值(多个用逗号分隔)", + "attachment_type_placeholder": "例如:pdf、doc、jpg" }, "templates": { "title": "邮件模板", diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index ee0057ed..7402234c 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert, KeyboardAvoidingView, Platform, @@ -14,6 +14,12 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useKeywordsStore } from '../../stores/keywords-store'; import { buildMailboxTree, type MailboxNode } from '../../lib/mailbox-tree'; import { generateUUID } from '../../lib/uuid'; +import { + inputStringToValue, + isConditionValueEmpty, + isHasAnyCondition, + valueToInputString, +} from '../../lib/sieve/condition-value'; import type { Mailbox } from '../../api/types'; import type { FilterRule, @@ -24,9 +30,25 @@ import type { FilterActionType, } from '../../lib/sieve/types'; -const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body']; +const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment']; const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches']; 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 isHasAny = isHasAnyCondition; + +function seedConditions(rule?: FilterRule): FilterCondition[] { + return rule?.conditions.length ? rule.conditions.map((cond) => ({ ...cond })) : [makeEmptyCondition()]; +} +function seedActions(rule?: FilterRule): FilterAction[] { + return rule?.actions.length ? rule.actions.map((a) => ({ ...a })) : [makeEmptyAction()]; +} const ALL_ACTION_TYPES: FilterActionType[] = ['move', 'copy', 'forward', 'mark_read', 'star', 'add_label', 'discard', 'reject', 'keep', 'stop']; const ACTIONS_WITH_VALUE = new Set(['move', 'copy', 'forward', 'reject', 'add_label']); @@ -73,14 +95,23 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F const [name, setName] = useState(rule?.name || ''); const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all'); - const [conditions, setConditions] = useState( - rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()], - ); - const [actions, setActions] = useState( - rule?.actions.length ? [...rule.actions] : [makeEmptyAction()], - ); + const [conditions, setConditions] = useState(() => seedConditions(rule)); + const [actions, setActions] = useState(() => seedActions(rule)); const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false); + // The Modal stays mounted between opens, so re-seed every field whenever it + // is (re)opened for a different rule - otherwise "Add Rule" after editing + // shows the previous rule and editing rule B saves rule A's fields under + // B's id. Mirrors SieveEditorSheet, which re-seeds on `visible`. + useEffect(() => { + if (!visible) return; + setName(rule?.name || ''); + setMatchType(rule?.matchType || 'all'); + setConditions(seedConditions(rule)); + setActions(seedActions(rule)); + setStopProcessing(rule?.stopProcessing ?? false); + }, [visible, rule]); + const mailboxOptions = useMemo(() => buildMailboxOptions(mailboxes), [mailboxes]); const fieldOptions = useMemo( @@ -98,7 +129,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F const comparatorOptions = useCallback( (field: FilterConditionField) => - (field === 'size' ? SIZE_COMPARATORS : TEXT_COMPARATORS).map((cmp) => ({ + comparatorsFor(field).map((cmp) => ({ value: cmp, label: t(`settings.filters.comparators.${cmp}`, cmp), })), @@ -110,15 +141,18 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F prev.map((cond, i) => { if (i !== index) return cond; const updated = { ...cond, ...updates }; - if (updates.field === 'size' && !SIZE_COMPARATORS.includes(cond.comparator)) { - updated.comparator = 'greater_than'; - } - if (updates.field && updates.field !== 'size' && SIZE_COMPARATORS.includes(cond.comparator)) { - updated.comparator = 'contains'; + // Reconcile the comparator when the field family changes so a size + // rule never keeps "contains" and an attachment rule never keeps + // "greater than". + if (updates.field && !comparatorsFor(updates.field).includes(updated.comparator)) { + updated.comparator = comparatorsFor(updates.field)[0]; } if (updates.field && updates.field !== 'header') { delete updated.headerName; } + if (isHasAny(updated)) { + updated.value = ''; + } return updated; }), ); @@ -156,7 +190,18 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F Alert.alert(t('settings.filters.validation_empty_name', 'Rule name is required')); return; } - const validConditions = conditions.filter((cond) => cond.value.trim()); + // While editing, condition.value is the raw string typed into the input + // (commas not yet split). Convert to array form here on save so a user + // typing "a, b, c" persists ["a","b","c"]; splitting on every keystroke + // would eat the comma the moment it is typed. + const validConditions = conditions + .filter((cond) => isHasAny(cond) || !isConditionValueEmpty(cond.value)) + .map((cond) => { + if (isHasAny(cond)) return { ...cond, value: '' }; + if (cond.field === 'size') return cond; // numeric, single-value only + if (typeof cond.value !== 'string') return cond; // already structured + return { ...cond, value: inputStringToValue(cond.value) }; + }); if (validConditions.length === 0) { Alert.alert(t('settings.filters.validation_empty_conditions', 'At least one condition with a value is required')); return; @@ -267,16 +312,22 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F style={{ alignSelf: 'flex-start' }} /> - updateCondition(index, { value: v })} - placeholder={ - condition.field === 'size' - ? t('settings.filters.size_placeholder', 'e.g., 1000000') - : t('settings.filters.header_placeholder', 'Value') - } - keyboardType={condition.field === 'size' ? 'numeric' : 'default'} - /> + {/* has_any takes no value ("an attachment is present"). */} + {!isHasAny(condition) && ( + updateCondition(index, { value: v })} + placeholder={ + condition.field === 'size' + ? t('settings.filters.size_placeholder', 'e.g., 1000000') + : condition.field === 'attachment' + ? t('settings.filters.attachment_type_placeholder', 'e.g. pdf, doc, jpg') + : t('settings.filters.value_placeholder_multi', 'Value (multiple separated by commas)') + } + keyboardType={condition.field === 'size' ? 'numeric' : 'default'} + autoCapitalize="none" + /> + )} ))} diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index a583ede0..f9c2c92f 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -17,28 +17,12 @@ import { useLocaleStore } from '../../stores/locale-store'; import { FilterRuleModal } from '../filters/FilterRuleModal'; import { SieveEditorSheet } from '../filters/SieveEditorSheet'; import type { FilterRule } from '../../lib/sieve/types'; - -type Translate = (key: string, fallback?: string) => string; +import { summarizeRule } from '../../lib/sieve/condition-value'; function isReadonlyRule(r: FilterRule): boolean { return r.origin === 'external' || r.origin === 'opaque'; } -function summarizeRule(rule: FilterRule, t: Translate): string { - const joiner = rule.matchType === 'all' ? t('settings.filters.and', 'and') : t('settings.filters.or', 'or'); - const conditions = rule.conditions.slice(0, 2).map((cond) => { - const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field); - const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator); - return `${field} ${comparator} "${cond.value}"`; - }); - const extra = rule.conditions.length > 2 ? ` (+${rule.conditions.length - 2})` : ''; - const actions = rule.actions.slice(0, 2).map((a) => { - const action = t(`settings.filters.action_types.${a.type}`, a.type); - return a.value ? `${action} "${a.value}"` : action; - }); - return `${conditions.join(` ${joiner} `)}${extra} → ${actions.join(', ')}`; -} - export function FilterSettings() { const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); diff --git a/src/lib/sieve/__tests__/condition-value.test.ts b/src/lib/sieve/__tests__/condition-value.test.ts new file mode 100644 index 00000000..f59fec6c --- /dev/null +++ b/src/lib/sieve/__tests__/condition-value.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { + valueToInputString, + inputStringToValue, + isConditionValueEmpty, + describeCondition, + summarizeRule, +} from '../condition-value'; +import type { FilterRule } from '../types'; + +const t = (key: string, fallback?: string) => fallback ?? key; + +describe('condition value helpers', () => { + it('joins arrays for the input and keeps strings as-is', () => { + expect(valueToInputString(['a', 'b'])).toBe('a, b'); + expect(valueToInputString('a')).toBe('a'); + }); + + it('splits comma input into an array, single item stays a string', () => { + expect(inputStringToValue('a, b ,c')).toEqual(['a', 'b', 'c']); + expect(inputStringToValue(' only ')).toBe('only'); + expect(inputStringToValue(' , ')).toBe(''); + }); + + it('detects empty values in both shapes', () => { + expect(isConditionValueEmpty('')).toBe(true); + expect(isConditionValueEmpty(' ')).toBe(true); + expect(isConditionValueEmpty([])).toBe(true); + expect(isConditionValueEmpty([' '])).toBe(true); + expect(isConditionValueEmpty(['x'])).toBe(false); + expect(isConditionValueEmpty('x')).toBe(false); + }); +}); + +describe('summaries', () => { + const rule: FilterRule = { + id: 'r', + name: 'R', + enabled: true, + matchType: 'any', + conditions: [ + { field: 'from', comparator: 'contains', value: ['@a.com', '@b.com'] }, + { field: 'attachment', comparator: 'has_any', value: '' }, + { field: 'attachment', comparator: 'has_type', value: 'pdf' }, + ], + actions: [{ type: 'move', value: 'Archive' }, { type: 'stop' }], + stopProcessing: false, + }; + + it('describes list values with the OR glue and omits the has_any value', () => { + expect(describeCondition(rule.conditions[0], t)).toBe('from contains "@a.com" or "@b.com"'); + expect(describeCondition(rule.conditions[1], t)).toBe('attachment has_any'); + expect(describeCondition(rule.conditions[2], t)).toBe('attachment has_type "pdf"'); + }); + + it('summarises the first two conditions and actions', () => { + expect(summarizeRule(rule, t)).toBe( + 'from contains "@a.com" or "@b.com" or attachment has_any (+1) → move "Archive", stop', + ); + }); +}); diff --git a/src/lib/sieve/__tests__/extended-rules.test.ts b/src/lib/sieve/__tests__/extended-rules.test.ts new file mode 100644 index 00000000..29231145 --- /dev/null +++ b/src/lib/sieve/__tests__/extended-rules.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from 'vitest'; +import { generateScript } from '../generator'; +import { parseScript } from '../parser'; +import type { FilterRule } from '../types'; + +// Coverage for the 1.7.3 "extended rules" format shared with the webmail: +// multi-value conditions (Sieve string lists) and the attachment field +// (`header :mime :anychild`). Both clients write the same active script, so +// the mobile port must read and emit these byte-for-byte like the web does. + +function makeRule(overrides: Partial = {}): FilterRule { + return { + id: 'rule-1', + name: 'Test Rule', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }], + actions: [{ type: 'move', value: 'Archive' }], + stopProcessing: false, + ...overrides, + }; +} + +function stripMetadata(script: string): string { + return script.replace(/\/\* @metadata:begin[\s\S]*?@metadata:end \*\/\n?/, ''); +} + +describe('multi-value conditions', () => { + it('emits a Sieve string list for array values', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'from', comparator: 'contains', value: ['@a.com', '@b.com'] }], + })]); + expect(script).toContain('if header :contains "From" ["@a.com", "@b.com"]'); + }); + + it('keeps the single-string form for one-element arrays', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'from', comparator: 'contains', value: ['only@a.com'] }], + })]); + expect(script).toContain('if header :contains "From" "only@a.com"'); + }); + + it('drops empty items from the list', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'is', value: ['Hi', '', 'Hello'] }], + })]); + expect(script).toContain('header :is "Subject" ["Hi", "Hello"]'); + }); + + it('applies the wildcard transform to every list item', () => { + const starts = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'starts_with', value: ['[list]', 'RE:'] }], + })]); + expect(starts).toContain('header :matches "Subject" ["[list]*", "RE:*"]'); + + const ends = generateScript([makeRule({ + conditions: [{ field: 'from', comparator: 'ends_with', value: ['@a.com', '@b.com'] }], + })]); + expect(ends).toContain('header :matches "From" ["*@a.com", "*@b.com"]'); + }); + + it('escapes quotes and backslashes inside list items', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'contains', value: ['say "hi"', 'back\\slash'] }], + })]); + expect(script).toContain('["say \\"hi\\"", "back\\\\slash"]'); + }); + + it('supports lists for body and negated tests', () => { + const script = generateScript([makeRule({ + conditions: [ + { field: 'body', comparator: 'contains', value: ['unsubscribe', 'opt out'] }, + { field: 'to', comparator: 'not_is', value: ['x@a.com', 'y@a.com'] }, + ], + })]); + expect(script).toContain('body :contains ["unsubscribe", "opt out"]'); + expect(script).toContain('not header :is "To" ["x@a.com", "y@a.com"]'); + }); + + it('uses only the first item for numeric size conditions', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'size', comparator: 'greater_than', value: ['1000', '2000'] }], + })]); + expect(script).toContain('size :over 1000'); + }); + + it('round-trips array values through the metadata block', () => { + const rules = [makeRule({ + conditions: [{ field: 'from', comparator: 'contains', value: ['@a.com', '@b.com'] }], + })]; + const result = parseScript(generateScript(rules)); + expect(result.isOpaque).toBe(false); + expect(result.rules[0].conditions[0].value).toEqual(['@a.com', '@b.com']); + }); + + it('accepts list literals in external scripts without metadata', () => { + const script = [ + 'require ["fileinto"];', + '# rule:[Vendors]', + 'if header :contains "From" ["@a.com", "@b.com"] {', + ' fileinto "Vendors";', + '}', + '', + ].join('\n'); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].origin).toBe('external'); + expect(result.rules[0].conditions[0]).toEqual({ + field: 'from', comparator: 'contains', value: ['@a.com', '@b.com'], + }); + }); + + it('classifies uniform wildcard lists as starts_with / ends_with', () => { + const script = [ + 'if anyof(header :matches "Subject" ["RE:*", "FW:*"], header :matches "From" ["*@a.com", "*@b.com"]) {', + ' discard;', + '}', + '', + ].join('\n'); + const result = parseScript(script); + expect(result.rules[0].conditions).toEqual([ + { field: 'subject', comparator: 'starts_with', value: ['RE:', 'FW:'] }, + { field: 'from', comparator: 'ends_with', value: ['@a.com', '@b.com'] }, + ]); + }); + + it('keeps mixed wildcard lists as matches with wildcards verbatim', () => { + const script = 'if header :matches "Subject" ["RE:*", "*FW"] {\n discard;\n}\n'; + const result = parseScript(script); + expect(result.rules[0].conditions[0]).toEqual({ + field: 'subject', comparator: 'matches', value: ['RE:*', '*FW'], + }); + }); + + it('does not treat metadata rules with array values as corrupt', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'body', comparator: 'is', value: ['a', 'b'] }], + })]); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules[0].conditions[0].value).toEqual(['a', 'b']); + }); + + it('does not emit "undefined" for a field this client does not know', () => { + const rule = makeRule({ + conditions: [{ field: 'future' as never, comparator: 'contains', value: 'x' }], + }); + expect(() => generateScript([rule])).toThrow(/Unsupported filter condition field/); + }); +}); + +describe('attachment conditions', () => { + it('emits :mime :anychild for has_any and requires mime', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'attachment', comparator: 'has_any', value: '' }], + })]); + expect(script).toContain('require ["fileinto", "mime"];'); + expect(script).toContain('if header :mime :anychild :contains "Content-Disposition" "attachment"'); + }); + + it('emits a filename wildcard across both headers for has_type', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'attachment', comparator: 'has_type', value: 'pdf' }], + })]); + expect(script).toContain( + 'if header :mime :anychild :matches ["Content-Disposition", "Content-Type"] "*.pdf*"', + ); + }); + + it('normalises leading dots/stars and emits a list for several types', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'attachment', comparator: 'has_type', value: ['.pdf', '*.xml', ' docx '] }], + })]); + expect(script).toContain( + ':matches ["Content-Disposition", "Content-Type"] ["*.pdf*", "*.xml*", "*.docx*"]', + ); + }); + + it('does not require mime when the attachment rule is disabled', () => { + const script = generateScript([makeRule({ + enabled: false, + conditions: [{ field: 'attachment', comparator: 'has_any', value: '' }], + })]); + expect(script).not.toContain('"mime"'); + }); + + it('round-trips attachment rules through the metadata block', () => { + const rules = [makeRule({ + conditions: [ + { field: 'attachment', comparator: 'has_any', value: '' }, + { field: 'attachment', comparator: 'has_type', value: ['pdf', 'xml'] }, + ], + })]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules[0].conditions).toEqual(rules[0].conditions); + // Regenerating must be stable, i.e. saving on mobile keeps the web's script. + expect(generateScript(result.rules, result.vacation, { externalRequires: result.externalRequires })) + .toBe(script); + }); + + it('parses the body of a web-authored attachment rule without metadata', () => { + const script = stripMetadata(generateScript([makeRule({ + name: 'PDFs', + conditions: [{ field: 'attachment', comparator: 'has_type', value: ['pdf', 'xml'] }], + })])); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules[0].conditions[0]).toEqual({ + field: 'attachment', comparator: 'has_type', value: ['pdf', 'xml'], + }); + expect(result.externalRequires).toContain('mime'); + }); + + it('still recognises the legacy single-header has_type form', () => { + const script = 'if header :mime :anychild :matches "Content-Disposition" "*.pdf*" {\n discard;\n}\n'; + const result = parseScript(script); + expect(result.rules[0].conditions[0]).toEqual({ + field: 'attachment', comparator: 'has_type', value: 'pdf', + }); + }); + + it('parses has_any from a script without metadata', () => { + const script = 'if header :mime :anychild :contains "Content-Disposition" "attachment" {\n fileinto "Attachments";\n}\n'; + const result = parseScript(script); + expect(result.rules[0].conditions[0]).toEqual({ + field: 'attachment', comparator: 'has_any', value: '', + }); + }); + + it('keeps unknown :mime tests opaque instead of misreading them', () => { + const script = 'if header :mime :param "filename" :contains "Content-Type" "x" {\n discard;\n}\n'; + const result = parseScript(script); + // The rule is preserved as a raw block, never as a structured header test. + for (const rule of result.rules) { + for (const cond of rule.conditions) { + expect(cond.field).not.toBe('header'); + } + if (rule.origin === 'opaque') expect(rule.rawBlock).toContain(':mime'); + } + }); +}); diff --git a/src/lib/sieve/__tests__/external-rules.test.ts b/src/lib/sieve/__tests__/external-rules.test.ts index 52fd3d76..b5271ee6 100644 --- a/src/lib/sieve/__tests__/external-rules.test.ts +++ b/src/lib/sieve/__tests__/external-rules.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseScript } from '../parser'; @@ -254,7 +254,7 @@ describe('external rule preservation (issue #201)', () => { }); describe('round-trip', () => { - it('preserves external rules through parse → generate → parse', () => { + it('preserves external rules through parse → generate → parse', () => { const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`; const firstParse = parseScript(initial); @@ -340,7 +340,7 @@ describe('external rule preservation (issue #201)', () => { expect(result.externalRequires).toContain('fileinto'); }); - it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => { + it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => { const bulwark = makeBulwarkRule({ name: 'Test' }); const initial = `${generateScript([bulwark])}\n${nextcloudScript}`; diff --git a/src/lib/sieve/__tests__/generator.test.ts b/src/lib/sieve/__tests__/generator.test.ts index 033adcf6..7e4d5133 100644 --- a/src/lib/sieve/__tests__/generator.test.ts +++ b/src/lib/sieve/__tests__/generator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { generateScript } from '../generator'; import { parseScript } from '../parser'; import type { FilterRule } from '../types'; @@ -366,7 +366,7 @@ describe('generateScript', () => { }); describe('round-trip', () => { - it('preserves rules through generate → parse cycle', () => { + it('preserves rules through generate → parse cycle', () => { const rules: FilterRule[] = [ makeRule({ id: '1', name: 'Rule A', enabled: true }), makeRule({ id: '2', name: 'Rule B', enabled: false }), diff --git a/src/lib/sieve/__tests__/parser.test.ts b/src/lib/sieve/__tests__/parser.test.ts index 5396fbd2..53fdec23 100644 --- a/src/lib/sieve/__tests__/parser.test.ts +++ b/src/lib/sieve/__tests__/parser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { parseScript } from '../parser'; import { generateScript } from '../generator'; import type { FilterRule } from '../types'; @@ -101,7 +101,7 @@ describe('parseScript', () => { }); describe('round-trip', () => { - it('preserves complex rules through generate → parse', () => { + it('preserves complex rules through generate → parse', () => { const rules: FilterRule[] = [ makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }), makeRule({ diff --git a/src/lib/sieve/condition-value.ts b/src/lib/sieve/condition-value.ts new file mode 100644 index 00000000..ee987d52 --- /dev/null +++ b/src/lib/sieve/condition-value.ts @@ -0,0 +1,60 @@ +import type { FilterCondition, FilterRule } from './types'; + +// Helpers for the string | string[] condition value (1.7.3 "extended rules"). +// Mirrors the webmail's filter-rule-modal.tsx: conditions are stored as +// string | string[]; the UI presents them as one comma-separated text input. +// The user types "a, b, c" and the saved value becomes ["a","b","c"]. Single +// entries stay strings so existing rules keep their shape. + +export function valueToInputString(v: string | string[]): string { + if (Array.isArray(v)) return v.join(', '); + return v ?? ''; +} + +export 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; +} + +export function isConditionValueEmpty(v: string | string[]): boolean { + if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim()); + return !(v ?? '').trim(); +} + +export function isHasAnyCondition(cond: FilterCondition): boolean { + return cond.field === 'attachment' && cond.comparator === 'has_any'; +} + +type Translate = (key: string, fallback?: string) => string; + +// Human-readable value part of a condition: `"a"`, or `"a" or "b"` for lists +// using the locale's OR glue, or nothing for the value-less has_any test. +export function formatConditionValue(cond: FilterCondition, t: Translate): string { + if (isHasAnyCondition(cond)) return ''; + if (Array.isArray(cond.value)) { + return cond.value.map((v) => `"${v}"`).join(` ${t('settings.filters.or', 'or')} `); + } + return `"${cond.value}"`; +} + +export function describeCondition(cond: FilterCondition, t: Translate): string { + const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field); + const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator); + const value = formatConditionValue(cond, t); + return value ? `${field} ${comparator} ${value}` : `${field} ${comparator}`; +} + +// One-line summary: first two conditions joined with and/or, "(+N)" for the +// rest, then the first two actions. +export function summarizeRule(rule: FilterRule, t: Translate): string { + const joiner = rule.matchType === 'all' ? t('settings.filters.and', 'and') : t('settings.filters.or', 'or'); + const conditions = rule.conditions.slice(0, 2).map((cond) => describeCondition(cond, t)); + const extra = rule.conditions.length > 2 ? ` (+${rule.conditions.length - 2})` : ''; + const actions = rule.actions.slice(0, 2).map((a) => { + const action = t(`settings.filters.action_types.${a.type}`, a.type); + return a.value ? `${action} "${a.value}"` : action; + }); + return `${conditions.join(` ${joiner} `)}${extra} → ${actions.join(', ')}`; +} diff --git a/src/lib/sieve/generator.ts b/src/lib/sieve/generator.ts index bc8cfbd6..cc3690e0 100644 --- a/src/lib/sieve/generator.ts +++ b/src/lib/sieve/generator.ts @@ -22,42 +22,90 @@ function escapeString(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } +// Normalise the condition value to a non-empty string array. Single-value +// conditions stay one-element; arrays are filtered for empty strings. +function toValueList(value: string | string[]): string[] { + const arr = Array.isArray(value) ? value : [value]; + return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0); +} + +// Render one or many strings as a Sieve string-literal-or-list. Sieve treats +// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within +// the condition); the single-string form is emitted unchanged when len === 1 +// so existing scripts and tests stay byte-identical. +function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string { + if (values.length === 1) { + return `"${escapeString(transform(values[0]))}"`; + } + return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`; +} + function generateCondition(condition: FilterCondition): string { const { field, comparator, value } = condition; if (field === 'size') { + // Size is numeric, single value only. + const sizeValue = Array.isArray(value) ? value[0] : value; const op = comparator === 'greater_than' ? ':over' : ':under'; - return `size ${op} ${value}`; + return `size ${op} ${sizeValue}`; } + const values = toValueList(value); + if (field === 'body') { const matchType = comparator === 'is' ? ':is' : ':contains'; - return `body ${matchType} "${escapeString(value)}"`; + return `body ${matchType} ${formatStringArg(values)}`; + } + + if (field === 'attachment') { + // RFC 5703: :mime :anychild matches against headers of any MIME part. + // has_any tests Content-Disposition for "attachment"; has_type matches + // the file extension against the filename across BOTH Content-Disposition + // (filename= parameter) and Content-Type (name= parameter) - many older + // senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only + // in Content-Type and leave Content-Disposition without a filename. + // RFC 5228 §5.7 allows a string-list for header names; the test passes + // if any listed header matches. Wildcard "*.*" catches quoted, + // unquoted, and RFC-2231-encoded forms alike since "." appears as + // a literal substring in all of them. + // Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"] + // = OR within the condition (any item matches → test passes). + if (comparator === 'has_any') { + return `header :mime :anychild :contains "Content-Disposition" "attachment"`; + } + const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean); + return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`; } const headerName = field === 'header' ? (condition.headerName || 'X-Unknown') : HEADER_MAP[field]; - const escaped = escapeString(value); + // A field this client does not know (a rule authored by a newer webmail) + // must never be flattened into `header :contains "undefined"`: that would + // silently rewrite the user's rule server-side on the next save. Fail the + // save instead so the script stays untouched. + if (!headerName) { + throw new Error(`Unsupported filter condition field: ${String(field)}`); + } switch (comparator) { case 'contains': - return `header :contains "${headerName}" "${escaped}"`; + return `header :contains "${headerName}" ${formatStringArg(values)}`; case 'not_contains': - return `not header :contains "${headerName}" "${escaped}"`; + return `not header :contains "${headerName}" ${formatStringArg(values)}`; case 'is': - return `header :is "${headerName}" "${escaped}"`; + return `header :is "${headerName}" ${formatStringArg(values)}`; case 'not_is': - return `not header :is "${headerName}" "${escaped}"`; + return `not header :is "${headerName}" ${formatStringArg(values)}`; case 'starts_with': - return `header :matches "${headerName}" "${escaped}*"`; + return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`; case 'ends_with': - return `header :matches "${headerName}" "*${escaped}"`; + return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`; case 'matches': - return `header :matches "${headerName}" "${escaped}"`; + return `header :matches "${headerName}" ${formatStringArg(values)}`; default: - return `header :contains "${headerName}" "${escaped}"`; + return `header :contains "${headerName}" ${formatStringArg(values)}`; } } @@ -99,6 +147,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s for (const rule of enabledRules) { for (const condition of rule.conditions) { if (condition.field === 'body') extensions.add('body'); + if (condition.field === 'attachment') extensions.add('mime'); } for (const action of rule.actions) { switch (action.type) { @@ -196,8 +245,7 @@ export function generateScript( for (const rule of enabledBulwarkRules) { if (rule.conditions.length === 0 || rule.actions.length === 0) { - // Empty conditions/actions can't form a valid `if`; the rule modal - // prevents this, but skip defensively rather than emit broken Sieve. + console.warn(`[filters] Skipping rule "${rule.name}": empty conditions or actions`); continue; } diff --git a/src/lib/sieve/parser.ts b/src/lib/sieve/parser.ts index 584d599c..3ea615b0 100644 --- a/src/lib/sieve/parser.ts +++ b/src/lib/sieve/parser.ts @@ -40,7 +40,11 @@ const FIELD_FROM_HEADER: Record = { function isValidCondition(c: unknown): boolean { if (!c || typeof c !== 'object') return false; const cond = c as Record; - 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 { @@ -338,43 +342,150 @@ function parseAtom(raw: string): FilterCondition | null { } } - let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s); + // Parse the value-tail of a header/body test: either a single quoted + // string or a Sieve list literal ["a", "b", ...]. Returns the unwrapped + // value(s), preserving the array shape when present so the caller can + // detect multi-value conditions. + const parseValueTail = (raw: string): string | string[] | null => { + const trimmed = raw.trim(); + // List form + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + const inner = trimmed.slice(1, -1); + const items: string[] = []; + const re = /"((?:[^"\\]|\\.)*)"/g; + let mm: RegExpExecArray | null; + let cursor = 0; + while ((mm = re.exec(inner)) !== null) { + // Ensure only whitespace and commas appear between items + if (inner.slice(cursor, mm.index).replace(/[\s,]/g, '') !== '') return null; + items.push(unescapeSieveString(mm[1])); + cursor = mm.index + mm[0].length; + } + if (inner.slice(cursor).replace(/[\s,]/g, '') !== '') return null; + if (items.length === 0) return null; + return items.length === 1 ? items[0] : items; + } + // Single string form + const single = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed); + if (single) return unescapeSieveString(single[1]); + return null; + }; + + // Classify a :matches value (or values) into starts_with / ends_with / + // matches by inspecting wildcard positions. For multi-value, all items + // must share the same shape; otherwise we fall back to 'matches' and + // keep the wildcards verbatim. + const classifyMatches = ( + values: string | string[], + ): { comparator: 'starts_with' | 'ends_with' | 'matches'; stripped: string | string[] } => { + const arr = Array.isArray(values) ? values : [values]; + const isTrailing = (v: string) => { + const stars = [...v].filter((c) => c === '*').length; + return stars === 1 && v.endsWith('*'); + }; + const isLeading = (v: string) => { + const stars = [...v].filter((c) => c === '*').length; + return stars === 1 && v.startsWith('*'); + }; + if (arr.every(isTrailing)) { + const stripped = arr.map((v) => v.slice(0, -1)); + return { comparator: 'starts_with', stripped: Array.isArray(values) ? stripped : stripped[0] }; + } + if (arr.every(isLeading)) { + const stripped = arr.map((v) => v.slice(1)); + return { comparator: 'ends_with', stripped: Array.isArray(values) ? stripped : stripped[0] }; + } + return { comparator: 'matches', stripped: values }; + }; + + // Match attachment-aware :mime :anychild tests before the generic header + // pattern - emitted by our own generator for field === 'attachment'. + // has_any: ":contains Content-Disposition attachment" + let m = /^header\s+:mime\s+:anychild\s+:contains\s+"Content-Disposition"\s+"attachment"$/.exec(s); if (m) { - const [, tag, headerName, rawValue] = m; - const value = unescapeSieveString(rawValue); + return { field: 'attachment', comparator: 'has_any', value: '' }; + } + // has_type: ":matches " + // - 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 "*.*" wildcard pattern. + const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => { + // Header part: accept either a single quoted string or a 2-element list + // containing exactly Content-Disposition + Content-Type (in any order). + const single = /^"Content-Disposition"$/.exec(rawHeaders.trim()); + const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim()); + let headersOk = false; + if (single) { + headersOk = true; + } else if (listForm) { + const inner = listForm[1]; + const items: string[] = []; + const re = /"((?:[^"\\]|\\.)*)"/g; + let mm: RegExpExecArray | null; + while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1])); + const expected = new Set(['Content-Disposition', 'Content-Type']); + const got = new Set(items); + headersOk = + items.length === expected.size && + [...expected].every((h) => got.has(h)); + } + if (!headersOk) return null; + const tail = parseValueTail(rawValue); + if (tail === null) return null; + const arr = Array.isArray(tail) ? tail : [tail]; + const exts: string[] = []; + for (const item of arr) { + const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item); + if (!em) return null; + exts.push(unescapeSieveString(em[1])); + } + return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts }; + }; + m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s); + if (m) { + const result = tryHasType(m[1], m[2]); + if (result) return result; + } + // Unknown :mime :anychild pattern (e.g. from external scripts) - bail to + // opaque rendering so we don't silently misrepresent the script. + if (/^header\s+:mime\s+:anychild\b/.test(s)) { + return null; + } + + m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s); + if (m) { + const [, tag, headerName, rawTail] = m; + const value = parseValueTail(rawTail); + if (value === null) return null; const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName)); let comparator: FilterComparator; + let finalValue: string | string[]; if (tag === 'contains') { comparator = negated ? 'not_contains' : 'contains'; + finalValue = value; } else if (tag === 'is') { comparator = negated ? 'not_is' : 'is'; + finalValue = value; } else { - // :matches - distinguish starts_with / ends_with / matches - const starPositions = [...value].reduce((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []); - if (starPositions.length === 1 && starPositions[0] === value.length - 1) { - comparator = 'starts_with'; - const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) }; - if (customHeaderName !== undefined) cond.headerName = customHeaderName; - return cond; - } - if (starPositions.length === 1 && starPositions[0] === 0) { - comparator = 'ends_with'; - const cond: FilterCondition = { field, comparator, value: value.slice(1) }; - if (customHeaderName !== undefined) cond.headerName = customHeaderName; - return cond; - } - comparator = 'matches'; + const classified = classifyMatches(value); + comparator = classified.comparator; + finalValue = classified.stripped; } - const cond: FilterCondition = { field, comparator, value }; + const cond: FilterCondition = { field, comparator, value: finalValue }; if (customHeaderName !== undefined) cond.headerName = customHeaderName; return cond; } - m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s); + m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s); if (m) { - return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) }; + const value = parseValueTail(m[2]); + if (value === null) return null; + return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value }; } m = /^size\s+:(over|under)\s+(\d+)$/.exec(s); diff --git a/src/lib/sieve/types.ts b/src/lib/sieve/types.ts index 925d15be..d4409455 100644 --- a/src/lib/sieve/types.ts +++ b/src/lib/sieve/types.ts @@ -18,14 +18,21 @@ export interface SieveCapabilities { externalLists: string[]; } -export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'; +export type FilterConditionField = + | 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body' + | 'attachment'; export type FilterComparator = | 'contains' | 'not_contains' | 'is' | 'not_is' | 'starts_with' | 'ends_with' | 'matches' - | 'greater_than' | 'less_than'; + | 'greater_than' | 'less_than' + // For field === 'attachment': + // has_any → message has any attachment (Content-Disposition: attachment) + // has_type → message has an attachment whose Content-Type matches `value` + // (substring match, e.g. "application/pdf" or "image/") + | 'has_any' | 'has_type'; export type FilterActionType = | 'move' | 'copy' | 'forward' @@ -35,7 +42,16 @@ export type FilterActionType = export interface FilterCondition { field: FilterConditionField; comparator: FilterComparator; - value: string; + /** + * Match value. Use a string array for OR-within-condition semantics + * (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either). + * Sieve emits the array as a list literal which the implementation + * treats as "matches any item". Use a plain string for single-value + * conditions; existing single-value rules continue to work unchanged. + * + * Not supported for: size (numeric), has_any (no value). + */ + value: string | string[]; headerName?: string; }