feat: port the extended Sieve rules (attachment field, multi-value conditions) from the webmail

This commit is contained in:
Linus Rath
2026-08-29 17:39:43 +02:00
parent e650cb2d0a
commit 1c6aa680c5
26 changed files with 782 additions and 133 deletions
+8 -3
View File
@@ -1382,7 +1382,8 @@
"subject": "Předmět", "subject": "Předmět",
"header": "Vlastní hlavička", "header": "Vlastní hlavička",
"size": "Velikost", "size": "Velikost",
"body": "Tělo zprávy" "body": "Tělo zprávy",
"attachment": "Příloha"
}, },
"comparators": { "comparators": {
"contains": "obsahuje", "contains": "obsahuje",
@@ -1393,7 +1394,9 @@
"ends_with": "končí na", "ends_with": "končí na",
"matches": "odpovídá vzoru", "matches": "odpovídá vzoru",
"greater_than": "je větší než", "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": { "action_types": {
"move": "Přesunout do složky", "move": "Přesunout do složky",
@@ -1466,7 +1469,9 @@
"actions_count": "{count, plural, one {1 akce} few {# akce} other {# akcí}}" "actions_count": "{count, plural, one {1 akce} few {# akce} other {# akcí}}"
}, },
"origin_external": "Externí", "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": { "templates": {
"title": "E-mailové šablony", "title": "E-mailové šablony",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Betreff", "subject": "Betreff",
"header": "Benutzerdefinierter Header", "header": "Benutzerdefinierter Header",
"size": "Größe", "size": "Größe",
"body": "Nachrichtentext" "body": "Nachrichtentext",
"attachment": "Anhang"
}, },
"comparators": { "comparators": {
"contains": "enthält", "contains": "enthält",
@@ -1388,7 +1389,9 @@
"ends_with": "endet mit", "ends_with": "endet mit",
"matches": "entspricht dem Muster", "matches": "entspricht dem Muster",
"greater_than": "ist größer als", "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": { "action_types": {
"move": "In Ordner verschieben", "move": "In Ordner verschieben",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}" "actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}"
}, },
"origin_external": "Extern", "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": { "templates": {
"title": "E-Mail-Vorlagen", "title": "E-Mail-Vorlagen",
+8 -4
View File
@@ -1390,7 +1390,8 @@
"subject": "Subject", "subject": "Subject",
"header": "Custom Header", "header": "Custom Header",
"size": "Size", "size": "Size",
"body": "Body" "body": "Body",
"attachment": "Attachment"
}, },
"comparators": { "comparators": {
"contains": "contains", "contains": "contains",
@@ -1401,7 +1402,9 @@
"ends_with": "ends with", "ends_with": "ends with",
"matches": "matches pattern", "matches": "matches pattern",
"greater_than": "is greater than", "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": { "action_types": {
"move": "Move to folder", "move": "Move to folder",
@@ -1474,8 +1477,9 @@
"conditions_count": "{count, plural, one {# condition} other {# conditions}}", "conditions_count": "{count, plural, one {# condition} other {# conditions}}",
"actions_count": "{count, plural, one {# action} other {# actions}}" "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": { "templates": {
"title": "Email Templates", "title": "Email Templates",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Asunto", "subject": "Asunto",
"header": "Encabezado personalizado", "header": "Encabezado personalizado",
"size": "Tamaño", "size": "Tamaño",
"body": "Cuerpo" "body": "Cuerpo",
"attachment": "Adjunto"
}, },
"comparators": { "comparators": {
"contains": "contiene", "contains": "contiene",
@@ -1388,7 +1389,9 @@
"ends_with": "termina con", "ends_with": "termina con",
"matches": "coincide con el patrón", "matches": "coincide con el patrón",
"greater_than": "es mayor que", "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": { "action_types": {
"move": "Mover a carpeta", "move": "Mover a carpeta",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# acción} other {# acciones}}" "actions_count": "{count, plural, one {# acción} other {# acciones}}"
}, },
"origin_external": "Externo", "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": { "templates": {
"title": "Plantillas de correo", "title": "Plantillas de correo",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Objet", "subject": "Objet",
"header": "En-tête personnalisé", "header": "En-tête personnalisé",
"size": "Taille", "size": "Taille",
"body": "Corps" "body": "Corps",
"attachment": "Pièce jointe"
}, },
"comparators": { "comparators": {
"contains": "contient", "contains": "contient",
@@ -1388,7 +1389,9 @@
"ends_with": "se termine par", "ends_with": "se termine par",
"matches": "correspond au motif", "matches": "correspond au motif",
"greater_than": "est supérieur à", "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": { "action_types": {
"move": "Déplacer vers le dossier", "move": "Déplacer vers le dossier",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# action} other {# actions}}" "actions_count": "{count, plural, one {# action} other {# actions}}"
}, },
"origin_external": "Externe", "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": { "templates": {
"title": "Modèles d'e-mails", "title": "Modèles d'e-mails",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Oggetto", "subject": "Oggetto",
"header": "Intestazione personalizzata", "header": "Intestazione personalizzata",
"size": "Dimensione", "size": "Dimensione",
"body": "Corpo" "body": "Corpo",
"attachment": "Allegato"
}, },
"comparators": { "comparators": {
"contains": "contiene", "contains": "contiene",
@@ -1388,7 +1389,9 @@
"ends_with": "termina con", "ends_with": "termina con",
"matches": "corrisponde al modello", "matches": "corrisponde al modello",
"greater_than": "è maggiore di", "greater_than": "è maggiore di",
"less_than": "è minore di" "less_than": "è minore di",
"has_any": "è presente",
"has_type": "di tipo"
}, },
"action_types": { "action_types": {
"move": "Sposta nella cartella", "move": "Sposta nella cartella",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# azione} other {# azioni}}" "actions_count": "{count, plural, one {# azione} other {# azioni}}"
}, },
"origin_external": "Esterno", "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": { "templates": {
"title": "Modelli email", "title": "Modelli email",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "件名", "subject": "件名",
"header": "カスタムヘッダー", "header": "カスタムヘッダー",
"size": "サイズ", "size": "サイズ",
"body": "本文" "body": "本文",
"attachment": "添付ファイル"
}, },
"comparators": { "comparators": {
"contains": "を含む", "contains": "を含む",
@@ -1388,7 +1389,9 @@
"ends_with": "で終わる", "ends_with": "で終わる",
"matches": "パターンに一致", "matches": "パターンに一致",
"greater_than": "より大きい", "greater_than": "より大きい",
"less_than": "より小さい" "less_than": "より小さい",
"has_any": "あり",
"has_type": "タイプ"
}, },
"action_types": { "action_types": {
"move": "フォルダーに移動", "move": "フォルダーに移動",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, other {#個のアクション}}" "actions_count": "{count, plural, other {#個のアクション}}"
}, },
"origin_external": "外部", "origin_external": "外部",
"managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。" "managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。",
"value_placeholder_multi": "値(カンマで複数指定可)",
"attachment_type_placeholder": "例: pdf, doc, jpg"
}, },
"templates": { "templates": {
"title": "メールテンプレート", "title": "メールテンプレート",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "제목", "subject": "제목",
"header": "사용자 지정 헤더", "header": "사용자 지정 헤더",
"size": "크기", "size": "크기",
"body": "본문" "body": "본문",
"attachment": "첨부 파일"
}, },
"comparators": { "comparators": {
"contains": "포함함", "contains": "포함함",
@@ -1388,7 +1389,9 @@
"ends_with": "다음으로 끝남", "ends_with": "다음으로 끝남",
"matches": "패턴 일치", "matches": "패턴 일치",
"greater_than": "다음보다 큼", "greater_than": "다음보다 큼",
"less_than": "다음보다 작음" "less_than": "다음보다 작음",
"has_any": "있음",
"has_type": "유형"
}, },
"action_types": { "action_types": {
"move": "폴더로 이동", "move": "폴더로 이동",
@@ -1461,7 +1464,9 @@
"actions_count": "동작 {count}개" "actions_count": "동작 {count}개"
}, },
"origin_external": "외부", "origin_external": "외부",
"managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요." "managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요.",
"value_placeholder_multi": "값 (쉼표로 여러 개 구분)",
"attachment_type_placeholder": "예: pdf, doc, jpg"
}, },
"templates": { "templates": {
"title": "이메일 템플릿", "title": "이메일 템플릿",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Temats", "subject": "Temats",
"header": "Pielāgota galvene", "header": "Pielāgota galvene",
"size": "Izmērs", "size": "Izmērs",
"body": "Teksts" "body": "Teksts",
"attachment": "Pielikums"
}, },
"comparators": { "comparators": {
"contains": "satur", "contains": "satur",
@@ -1388,7 +1389,9 @@
"ends_with": "beidzas ar", "ends_with": "beidzas ar",
"matches": "atbilst paraugam", "matches": "atbilst paraugam",
"greater_than": "lielāks nekā", "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": { "action_types": {
"move": "Pārvietot uz mapi", "move": "Pārvietot uz mapi",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# darbība} other {# darbības}}" "actions_count": "{count, plural, one {# darbība} other {# darbības}}"
}, },
"origin_external": "Ārējs", "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": { "templates": {
"title": "Vēstuļu veidnes", "title": "Vēstuļu veidnes",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Onderwerp", "subject": "Onderwerp",
"header": "Aangepaste header", "header": "Aangepaste header",
"size": "Grootte", "size": "Grootte",
"body": "Inhoud" "body": "Inhoud",
"attachment": "Bijlage"
}, },
"comparators": { "comparators": {
"contains": "bevat", "contains": "bevat",
@@ -1388,7 +1389,9 @@
"ends_with": "eindigt met", "ends_with": "eindigt met",
"matches": "komt overeen met patroon", "matches": "komt overeen met patroon",
"greater_than": "is groter dan", "greater_than": "is groter dan",
"less_than": "is kleiner dan" "less_than": "is kleiner dan",
"has_any": "aanwezig",
"has_type": "van type"
}, },
"action_types": { "action_types": {
"move": "Verplaatsen naar map", "move": "Verplaatsen naar map",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# actie} other {# acties}}" "actions_count": "{count, plural, one {# actie} other {# acties}}"
}, },
"origin_external": "Extern", "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": { "templates": {
"title": "E-mailsjablonen", "title": "E-mailsjablonen",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Temat", "subject": "Temat",
"header": "Niestandardowy nagłówek", "header": "Niestandardowy nagłówek",
"size": "Rozmiar", "size": "Rozmiar",
"body": "Treść" "body": "Treść",
"attachment": "Załącznik"
}, },
"comparators": { "comparators": {
"contains": "zawiera", "contains": "zawiera",
@@ -1388,7 +1389,9 @@
"ends_with": "kończy się na", "ends_with": "kończy się na",
"matches": "pasuje do wzorca", "matches": "pasuje do wzorca",
"greater_than": "jest większe niż", "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": { "action_types": {
"move": "Przenieś do folderu", "move": "Przenieś do folderu",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# akcja} other {# akcji}}" "actions_count": "{count, plural, one {# akcja} other {# akcji}}"
}, },
"origin_external": "Zewnętrzny", "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": { "templates": {
"title": "Szablony wiadomości e-mail", "title": "Szablony wiadomości e-mail",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Assunto", "subject": "Assunto",
"header": "Cabeçalho personalizado", "header": "Cabeçalho personalizado",
"size": "Tamanho", "size": "Tamanho",
"body": "Corpo" "body": "Corpo",
"attachment": "Anexo"
}, },
"comparators": { "comparators": {
"contains": "contém", "contains": "contém",
@@ -1388,7 +1389,9 @@
"ends_with": "termina com", "ends_with": "termina com",
"matches": "corresponde ao padrão", "matches": "corresponde ao padrão",
"greater_than": "é maior que", "greater_than": "é maior que",
"less_than": "é menor que" "less_than": "é menor que",
"has_any": "está presente",
"has_type": "do tipo"
}, },
"action_types": { "action_types": {
"move": "Mover para pasta", "move": "Mover para pasta",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# ação} other {# ações}}" "actions_count": "{count, plural, one {# ação} other {# ações}}"
}, },
"origin_external": "Externo", "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": { "templates": {
"title": "Modelos de e-mail", "title": "Modelos de e-mail",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Тема", "subject": "Тема",
"header": "Пользовательский заголовок", "header": "Пользовательский заголовок",
"size": "Размер", "size": "Размер",
"body": "Тело" "body": "Тело",
"attachment": "Вложение"
}, },
"comparators": { "comparators": {
"contains": "содержит", "contains": "содержит",
@@ -1388,7 +1389,9 @@
"ends_with": "заканчивается на", "ends_with": "заканчивается на",
"matches": "соответствует шаблону", "matches": "соответствует шаблону",
"greater_than": "больше чем", "greater_than": "больше чем",
"less_than": "меньше чем" "less_than": "меньше чем",
"has_any": "присутствует",
"has_type": "типа"
}, },
"action_types": { "action_types": {
"move": "Переместить в папку", "move": "Переместить в папку",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# действие} other {# действий}}" "actions_count": "{count, plural, one {# действие} other {# действий}}"
}, },
"origin_external": "Внешнее", "origin_external": "Внешнее",
"managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve." "managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve.",
"value_placeholder_multi": "Значение (несколько через запятую)",
"attachment_type_placeholder": "напр. pdf, doc, jpg"
}, },
"templates": { "templates": {
"title": "Шаблоны писем", "title": "Шаблоны писем",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "Тема", "subject": "Тема",
"header": "Спеціальний заголовок", "header": "Спеціальний заголовок",
"size": "Розмір", "size": "Розмір",
"body": "Тіло" "body": "Тіло",
"attachment": "Вкладення"
}, },
"comparators": { "comparators": {
"contains": "містить", "contains": "містить",
@@ -1388,7 +1389,9 @@
"ends_with": "закінчується на", "ends_with": "закінчується на",
"matches": "відповідає шаблону", "matches": "відповідає шаблону",
"greater_than": "більше ніж", "greater_than": "більше ніж",
"less_than": "менше ніж" "less_than": "менше ніж",
"has_any": "присутнє",
"has_type": "типу"
}, },
"action_types": { "action_types": {
"move": "Перемістити в папку", "move": "Перемістити в папку",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# дія} few {# дії} many {# дій} other {# дій}}" "actions_count": "{count, plural, one {# дія} few {# дії} many {# дій} other {# дій}}"
}, },
"origin_external": "Зовнішнє", "origin_external": "Зовнішнє",
"managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve." "managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve.",
"value_placeholder_multi": "Значення (декілька через кому)",
"attachment_type_placeholder": "напр. pdf, doc, jpg"
}, },
"templates": { "templates": {
"title": "Шаблони електронної пошти", "title": "Шаблони електронної пошти",
+8 -3
View File
@@ -1377,7 +1377,8 @@
"subject": "主题", "subject": "主题",
"header": "自定义标头", "header": "自定义标头",
"size": "大小", "size": "大小",
"body": "正文" "body": "正文",
"attachment": "附件"
}, },
"comparators": { "comparators": {
"contains": "包含", "contains": "包含",
@@ -1388,7 +1389,9 @@
"ends_with": "以…结尾", "ends_with": "以…结尾",
"matches": "匹配模式", "matches": "匹配模式",
"greater_than": "大于", "greater_than": "大于",
"less_than": "小于" "less_than": "小于",
"has_any": "存在",
"has_type": "类型为"
}, },
"action_types": { "action_types": {
"move": "移至文件夹", "move": "移至文件夹",
@@ -1461,7 +1464,9 @@
"actions_count": "{count, plural, one {# 个操作} other {# 个操作}}" "actions_count": "{count, plural, one {# 个操作} other {# 个操作}}"
}, },
"origin_external": "外部", "origin_external": "外部",
"managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。" "managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。",
"value_placeholder_multi": "值(多个用逗号分隔)",
"attachment_type_placeholder": "例如:pdf、doc、jpg"
}, },
"templates": { "templates": {
"title": "邮件模板", "title": "邮件模板",
+76 -25
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert, View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
KeyboardAvoidingView, Platform, KeyboardAvoidingView, Platform,
@@ -14,6 +14,12 @@ import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore } from '../../stores/keywords-store'; import { useKeywordsStore } from '../../stores/keywords-store';
import { buildMailboxTree, type MailboxNode } from '../../lib/mailbox-tree'; import { buildMailboxTree, type MailboxNode } from '../../lib/mailbox-tree';
import { generateUUID } from '../../lib/uuid'; import { generateUUID } from '../../lib/uuid';
import {
inputStringToValue,
isConditionValueEmpty,
isHasAnyCondition,
valueToInputString,
} from '../../lib/sieve/condition-value';
import type { Mailbox } from '../../api/types'; import type { Mailbox } from '../../api/types';
import type { import type {
FilterRule, FilterRule,
@@ -24,9 +30,25 @@ import type {
FilterActionType, FilterActionType,
} from '../../lib/sieve/types'; } 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 TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than']; const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than'];
const ATTACHMENT_COMPARATORS: FilterComparator[] = ['has_any', 'has_type'];
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
if (field === 'size') return SIZE_COMPARATORS;
if (field === 'attachment') return ATTACHMENT_COMPARATORS;
return TEXT_COMPARATORS;
}
const 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 ALL_ACTION_TYPES: FilterActionType[] = ['move', 'copy', 'forward', 'mark_read', 'star', 'add_label', 'discard', 'reject', 'keep', 'stop'];
const ACTIONS_WITH_VALUE = new Set<FilterActionType>(['move', 'copy', 'forward', 'reject', 'add_label']); const ACTIONS_WITH_VALUE = new Set<FilterActionType>(['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 [name, setName] = useState(rule?.name || '');
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all'); const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
const [conditions, setConditions] = useState<FilterCondition[]>( const [conditions, setConditions] = useState<FilterCondition[]>(() => seedConditions(rule));
rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()], const [actions, setActions] = useState<FilterAction[]>(() => seedActions(rule));
);
const [actions, setActions] = useState<FilterAction[]>(
rule?.actions.length ? [...rule.actions] : [makeEmptyAction()],
);
const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false); 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 mailboxOptions = useMemo(() => buildMailboxOptions(mailboxes), [mailboxes]);
const fieldOptions = useMemo( const fieldOptions = useMemo(
@@ -98,7 +129,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
const comparatorOptions = useCallback( const comparatorOptions = useCallback(
(field: FilterConditionField) => (field: FilterConditionField) =>
(field === 'size' ? SIZE_COMPARATORS : TEXT_COMPARATORS).map((cmp) => ({ comparatorsFor(field).map((cmp) => ({
value: cmp, value: cmp,
label: t(`settings.filters.comparators.${cmp}`, 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) => { prev.map((cond, i) => {
if (i !== index) return cond; if (i !== index) return cond;
const updated = { ...cond, ...updates }; const updated = { ...cond, ...updates };
if (updates.field === 'size' && !SIZE_COMPARATORS.includes(cond.comparator)) { // Reconcile the comparator when the field family changes so a size
updated.comparator = 'greater_than'; // rule never keeps "contains" and an attachment rule never keeps
} // "greater than".
if (updates.field && updates.field !== 'size' && SIZE_COMPARATORS.includes(cond.comparator)) { if (updates.field && !comparatorsFor(updates.field).includes(updated.comparator)) {
updated.comparator = 'contains'; updated.comparator = comparatorsFor(updates.field)[0];
} }
if (updates.field && updates.field !== 'header') { if (updates.field && updates.field !== 'header') {
delete updated.headerName; delete updated.headerName;
} }
if (isHasAny(updated)) {
updated.value = '';
}
return updated; 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')); Alert.alert(t('settings.filters.validation_empty_name', 'Rule name is required'));
return; 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) { if (validConditions.length === 0) {
Alert.alert(t('settings.filters.validation_empty_conditions', 'At least one condition with a value is required')); Alert.alert(t('settings.filters.validation_empty_conditions', 'At least one condition with a value is required'));
return; return;
@@ -267,16 +312,22 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
style={{ alignSelf: 'flex-start' }} style={{ alignSelf: 'flex-start' }}
/> />
<Input {/* has_any takes no value ("an attachment is present"). */}
value={condition.value} {!isHasAny(condition) && (
onChangeText={(v) => updateCondition(index, { value: v })} <Input
placeholder={ value={valueToInputString(condition.value)}
condition.field === 'size' onChangeText={(v) => updateCondition(index, { value: v })}
? t('settings.filters.size_placeholder', 'e.g., 1000000') placeholder={
: t('settings.filters.header_placeholder', 'Value') condition.field === 'size'
} ? t('settings.filters.size_placeholder', 'e.g., 1000000')
keyboardType={condition.field === 'size' ? 'numeric' : 'default'} : 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"
/>
)}
</View> </View>
))} ))}
</View> </View>
+1 -17
View File
@@ -17,28 +17,12 @@ import { useLocaleStore } from '../../stores/locale-store';
import { FilterRuleModal } from '../filters/FilterRuleModal'; import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet'; import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types'; import type { FilterRule } from '../../lib/sieve/types';
import { summarizeRule } from '../../lib/sieve/condition-value';
type Translate = (key: string, fallback?: string) => string;
function isReadonlyRule(r: FilterRule): boolean { function isReadonlyRule(r: FilterRule): boolean {
return r.origin === 'external' || r.origin === 'opaque'; 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() { export function FilterSettings() {
const c = useColors(); const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]); const styles = useMemo(() => makeStyles(c), [c]);
@@ -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',
);
});
});
@@ -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> = {}): 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');
}
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { parseScript } from '../parser'; import { parseScript } from '../parser';
@@ -254,7 +254,7 @@ describe('external rule preservation (issue #201)', () => {
}); });
describe('round-trip', () => { 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 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); const firstParse = parseScript(initial);
@@ -340,7 +340,7 @@ describe('external rule preservation (issue #201)', () => {
expect(result.externalRequires).toContain('fileinto'); 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 bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`; const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
+2 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { generateScript } from '../generator'; import { generateScript } from '../generator';
import { parseScript } from '../parser'; import { parseScript } from '../parser';
import type { FilterRule } from '../types'; import type { FilterRule } from '../types';
@@ -366,7 +366,7 @@ describe('generateScript', () => {
}); });
describe('round-trip', () => { describe('round-trip', () => {
it('preserves rules through generate → parse cycle', () => { it('preserves rules through generate → parse cycle', () => {
const rules: FilterRule[] = [ const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Rule A', enabled: true }), makeRule({ id: '1', name: 'Rule A', enabled: true }),
makeRule({ id: '2', name: 'Rule B', enabled: false }), makeRule({ id: '2', name: 'Rule B', enabled: false }),
+2 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseScript } from '../parser'; import { parseScript } from '../parser';
import { generateScript } from '../generator'; import { generateScript } from '../generator';
import type { FilterRule } from '../types'; import type { FilterRule } from '../types';
@@ -101,7 +101,7 @@ describe('parseScript', () => {
}); });
describe('round-trip', () => { describe('round-trip', () => {
it('preserves complex rules through generate → parse', () => { it('preserves complex rules through generate → parse', () => {
const rules: FilterRule[] = [ const rules: FilterRule[] = [
makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }), makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }),
makeRule({ makeRule({
+60
View File
@@ -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(', ')}`;
}
+61 -13
View File
@@ -22,42 +22,90 @@ function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
} }
// Normalise the condition value to a non-empty string array. Single-value
// conditions stay one-element; arrays are filtered for empty strings.
function toValueList(value: string | string[]): string[] {
const arr = Array.isArray(value) ? value : [value];
return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0);
}
// Render one or many strings as a Sieve string-literal-or-list. Sieve treats
// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within
// the condition); the single-string form is emitted unchanged when len === 1
// so existing scripts and tests stay byte-identical.
function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string {
if (values.length === 1) {
return `"${escapeString(transform(values[0]))}"`;
}
return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`;
}
function generateCondition(condition: FilterCondition): string { function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition; const { field, comparator, value } = condition;
if (field === 'size') { if (field === 'size') {
// Size is numeric, single value only.
const sizeValue = Array.isArray(value) ? value[0] : value;
const op = comparator === 'greater_than' ? ':over' : ':under'; const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${value}`; return `size ${op} ${sizeValue}`;
} }
const values = toValueList(value);
if (field === 'body') { if (field === 'body') {
const matchType = comparator === 'is' ? ':is' : ':contains'; const matchType = comparator === 'is' ? ':is' : ':contains';
return `body ${matchType} "${escapeString(value)}"`; return `body ${matchType} ${formatStringArg(values)}`;
}
if (field === 'attachment') {
// RFC 5703: :mime :anychild matches against headers of any MIME part.
// has_any tests Content-Disposition for "attachment"; has_type matches
// the file extension against the filename across BOTH Content-Disposition
// (filename= parameter) and Content-Type (name= parameter) - many older
// senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only
// in Content-Type and leave Content-Disposition without a filename.
// RFC 5228 §5.7 allows a string-list for header names; the test passes
// if any listed header matches. Wildcard "*.<ext>*" catches quoted,
// unquoted, and RFC-2231-encoded forms alike since ".<ext>" appears as
// a literal substring in all of them.
// Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"]
// = OR within the condition (any item matches → test passes).
if (comparator === 'has_any') {
return `header :mime :anychild :contains "Content-Disposition" "attachment"`;
}
const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean);
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
} }
const headerName = field === 'header' const headerName = field === 'header'
? (condition.headerName || 'X-Unknown') ? (condition.headerName || 'X-Unknown')
: HEADER_MAP[field]; : HEADER_MAP[field];
const escaped = escapeString(value); // 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) { switch (comparator) {
case 'contains': case 'contains':
return `header :contains "${headerName}" "${escaped}"`; return `header :contains "${headerName}" ${formatStringArg(values)}`;
case 'not_contains': case 'not_contains':
return `not header :contains "${headerName}" "${escaped}"`; return `not header :contains "${headerName}" ${formatStringArg(values)}`;
case 'is': case 'is':
return `header :is "${headerName}" "${escaped}"`; return `header :is "${headerName}" ${formatStringArg(values)}`;
case 'not_is': case 'not_is':
return `not header :is "${headerName}" "${escaped}"`; return `not header :is "${headerName}" ${formatStringArg(values)}`;
case 'starts_with': case 'starts_with':
return `header :matches "${headerName}" "${escaped}*"`; return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
case 'ends_with': case 'ends_with':
return `header :matches "${headerName}" "*${escaped}"`; return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
case 'matches': case 'matches':
return `header :matches "${headerName}" "${escaped}"`; return `header :matches "${headerName}" ${formatStringArg(values)}`;
default: default:
return `header :contains "${headerName}" "${escaped}"`; return `header :contains "${headerName}" ${formatStringArg(values)}`;
} }
} }
@@ -99,6 +147,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
for (const rule of enabledRules) { for (const rule of enabledRules) {
for (const condition of rule.conditions) { for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body'); if (condition.field === 'body') extensions.add('body');
if (condition.field === 'attachment') extensions.add('mime');
} }
for (const action of rule.actions) { for (const action of rule.actions) {
switch (action.type) { switch (action.type) {
@@ -196,8 +245,7 @@ export function generateScript(
for (const rule of enabledBulwarkRules) { for (const rule of enabledBulwarkRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) { if (rule.conditions.length === 0 || rule.actions.length === 0) {
// Empty conditions/actions can't form a valid `if`; the rule modal console.warn(`[filters] Skipping rule "${rule.name}": empty conditions or actions`);
// prevents this, but skip defensively rather than emit broken Sieve.
continue; continue;
} }
+133 -22
View File
@@ -40,7 +40,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
function isValidCondition(c: unknown): boolean { function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false; if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>; const cond = c as Record<string, unknown>;
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string'; if (typeof cond.field !== 'string' || typeof cond.comparator !== 'string') return false;
// value may be a string OR a non-empty array of strings (Patch 11
// multi-value semantics). Accept both.
if (typeof cond.value === 'string') return true;
return Array.isArray(cond.value) && cond.value.every((v) => typeof v === 'string');
} }
function isValidAction(a: unknown): boolean { function isValidAction(a: unknown): boolean {
@@ -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) { if (m) {
const [, tag, headerName, rawValue] = m; return { field: 'attachment', comparator: 'has_any', value: '' };
const value = unescapeSieveString(rawValue); }
// has_type: ":matches <headers> <value-tail>"
// - Current emit form uses a header-list ["Content-Disposition", "Content-Type"]
// to catch senders who put the filename only in Content-Type's name= param
// (Microsoft SMTPSVC, PrintToMail.net, etc.).
// - Legacy emit form used a single "Content-Disposition" header - still
// recognised here so rules saved before the fix remain editable.
// Each value item must be a "*.<ext>*" wildcard pattern.
const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => {
// Header part: accept either a single quoted string or a 2-element list
// containing exactly Content-Disposition + Content-Type (in any order).
const single = /^"Content-Disposition"$/.exec(rawHeaders.trim());
const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim());
let headersOk = false;
if (single) {
headersOk = true;
} else if (listForm) {
const inner = listForm[1];
const items: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let mm: RegExpExecArray | null;
while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1]));
const expected = new Set(['Content-Disposition', 'Content-Type']);
const got = new Set(items);
headersOk =
items.length === expected.size &&
[...expected].every((h) => got.has(h));
}
if (!headersOk) return null;
const tail = parseValueTail(rawValue);
if (tail === null) return null;
const arr = Array.isArray(tail) ? tail : [tail];
const exts: string[] = [];
for (const item of arr) {
const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item);
if (!em) return null;
exts.push(unescapeSieveString(em[1]));
}
return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts };
};
m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s);
if (m) {
const result = tryHasType(m[1], m[2]);
if (result) return result;
}
// Unknown :mime :anychild pattern (e.g. from external scripts) - bail to
// opaque rendering so we don't silently misrepresent the script.
if (/^header\s+:mime\s+:anychild\b/.test(s)) {
return null;
}
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
if (m) {
const [, tag, headerName, rawTail] = m;
const value = parseValueTail(rawTail);
if (value === null) return null;
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName)); const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator; let comparator: FilterComparator;
let finalValue: string | string[];
if (tag === 'contains') { if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains'; comparator = negated ? 'not_contains' : 'contains';
finalValue = value;
} else if (tag === 'is') { } else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is'; comparator = negated ? 'not_is' : 'is';
finalValue = value;
} else { } else {
// :matches - distinguish starts_with / ends_with / matches const classified = classifyMatches(value);
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []); comparator = classified.comparator;
if (starPositions.length === 1 && starPositions[0] === value.length - 1) { finalValue = classified.stripped;
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
} }
const cond: FilterCondition = { field, comparator, value }; const cond: FilterCondition = { field, comparator, value: finalValue };
if (customHeaderName !== undefined) cond.headerName = customHeaderName; if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond; return cond;
} }
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s); m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
if (m) { if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) }; const value = parseValueTail(m[2]);
if (value === null) return null;
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
} }
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s); m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
+19 -3
View File
@@ -18,14 +18,21 @@ export interface SieveCapabilities {
externalLists: string[]; externalLists: string[];
} }
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'; export type FilterConditionField =
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
| 'attachment';
export type FilterComparator = export type FilterComparator =
| 'contains' | 'not_contains' | 'contains' | 'not_contains'
| 'is' | 'not_is' | 'is' | 'not_is'
| 'starts_with' | 'ends_with' | 'starts_with' | 'ends_with'
| 'matches' | 'matches'
| 'greater_than' | 'less_than'; | 'greater_than' | 'less_than'
// For field === 'attachment':
// has_any → message has any attachment (Content-Disposition: attachment)
// has_type → message has an attachment whose Content-Type matches `value`
// (substring match, e.g. "application/pdf" or "image/")
| 'has_any' | 'has_type';
export type FilterActionType = export type FilterActionType =
| 'move' | 'copy' | 'forward' | 'move' | 'copy' | 'forward'
@@ -35,7 +42,16 @@ export type FilterActionType =
export interface FilterCondition { export interface FilterCondition {
field: FilterConditionField; field: FilterConditionField;
comparator: FilterComparator; comparator: FilterComparator;
value: string; /**
* Match value. Use a string array for OR-within-condition semantics
* (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either).
* Sieve emits the array as a list literal which the implementation
* treats as "matches any item". Use a plain string for single-value
* conditions; existing single-value rules continue to work unchanged.
*
* Not supported for: size (numeric), has_any (no value).
*/
value: string | string[];
headerName?: string; headerName?: string;
} }