mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: port the extended Sieve rules (attachment field, multi-value conditions) from the webmail
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メールテンプレート",
|
||||
|
||||
@@ -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": "이메일 템플릿",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Шаблоны писем",
|
||||
|
||||
@@ -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": "Шаблони електронної пошти",
|
||||
|
||||
@@ -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": "邮件模板",
|
||||
|
||||
@@ -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<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 [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>(
|
||||
rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()],
|
||||
);
|
||||
const [actions, setActions] = useState<FilterAction[]>(
|
||||
rule?.actions.length ? [...rule.actions] : [makeEmptyAction()],
|
||||
);
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>(() => seedConditions(rule));
|
||||
const [actions, setActions] = useState<FilterAction[]>(() => 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' }}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChangeText={(v) => 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) && (
|
||||
<Input
|
||||
value={valueToInputString(condition.value)}
|
||||
onChangeText={(v) => 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"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 { 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}`;
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 "*.<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'
|
||||
? (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;
|
||||
}
|
||||
|
||||
|
||||
+133
-22
@@ -40,7 +40,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
||||
function isValidCondition(c: unknown): boolean {
|
||||
if (!c || typeof c !== 'object') return false;
|
||||
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 {
|
||||
@@ -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 <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));
|
||||
|
||||
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<number[]>((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);
|
||||
|
||||
+19
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user