diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 564f9e8b3..7c673e1aa 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -107,6 +107,7 @@ export function FilterRuleModal({ rule?.actions.length ? [...rule.actions] : [makeEmptyAction()] ); const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false); + const [includeSpam, setIncludeSpam] = useState(rule?.includeSpam ?? false); const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); @@ -193,8 +194,9 @@ export function FilterRuleModal({ conditions: validConditions, actions: validActions, stopProcessing, + ...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}), }); - }, [name, matchType, conditions, actions, stopProcessing, rule, onSave, t, mailboxIdFor, mailboxPathMap]); + }, [name, matchType, conditions, actions, stopProcessing, includeSpam, rule, onSave, t, mailboxIdFor, mailboxPathMap]); const updateCondition = (index: number, updates: Partial) => { setConditions((prev) => @@ -586,6 +588,21 @@ export function FilterRuleModal({ {t("stop_processing")} + + {actions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) && ( +
+ setIncludeSpam(e.target.checked)} + className="rounded border-input" + /> + +
+ )}
diff --git a/lib/jmap/sieve-types.ts b/lib/jmap/sieve-types.ts index c2427f429..d2e78c7fb 100644 --- a/lib/jmap/sieve-types.ts +++ b/lib/jmap/sieve-types.ts @@ -74,6 +74,11 @@ export interface FilterRule { conditions: FilterCondition[]; actions: FilterAction[]; stopProcessing: boolean; + /** + * Also move/copy messages the server marked as spam. Off by default, so a + * folder rule does not pull spam out of Junk. + */ + includeSpam?: boolean; origin?: FilterOrigin; originLabel?: string; rawBlock?: string; diff --git a/lib/sieve/__tests__/generator.test.ts b/lib/sieve/__tests__/generator.test.ts index 665730b7c..6ad885628 100644 --- a/lib/sieve/__tests__/generator.test.ts +++ b/lib/sieve/__tests__/generator.test.ts @@ -305,6 +305,40 @@ describe('generateScript', () => { }); }); + describe('spam guard', () => { + const exts = ['fileinto', 'relational', 'spamtest', 'spamtestplus']; + const guard = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"'; + + it('keeps spam out of folder moves', () => { + const script = generateScript([makeRule()], undefined, { extensions: exts }); + expect(script).toContain(`if allof(header :contains "From" "test@example.com", ${guard}) {`); + expect(script).toContain('require ["comparator-i;ascii-numeric", "fileinto", "relational", "spamtestplus"];'); + }); + + it('extends an allof and wraps an anyof', () => { + const conditions = [ + { field: 'from' as const, comparator: 'contains' as const, value: 'a' }, + { field: 'subject' as const, comparator: 'contains' as const, value: 'b' }, + ]; + const all = generateScript([makeRule({ conditions })], undefined, { extensions: exts }); + expect(all).toContain(`if allof(header :contains "From" "a", header :contains "Subject" "b", ${guard}) {`); + const any = generateScript([makeRule({ conditions, matchType: 'any' })], undefined, { extensions: exts }); + expect(any).toContain(`if allof(anyof(header :contains "From" "a", header :contains "Subject" "b"), ${guard}) {`); + }); + + it('leaves keep rules, opted-in rules and servers without spamtestplus alone', () => { + const keep = makeRule({ actions: [{ type: 'keep' }] }); + const optIn = makeRule({ id: 'r2', name: 'Opt in', includeSpam: true }); + expect(generateScript([keep, optIn], undefined, { extensions: exts })).not.toContain('spamtest'); + expect(generateScript([makeRule()], undefined, { extensions: ['fileinto'] })).not.toContain('spamtest'); + }); + + it('round-trips the opt-in', () => { + const parsed = parseScript(generateScript([makeRule({ includeSpam: true })], undefined, { extensions: exts })); + expect(parsed.rules[0].includeSpam).toBe(true); + }); + }); + describe('vacation include', () => { it('includes the server vacation script before the rules', () => { const script = generateScript([makeRule()], undefined, { includeVacation: true }); diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts index 469ad5c23..ae77d849c 100644 --- a/lib/sieve/generator.ts +++ b/lib/sieve/generator.ts @@ -130,7 +130,8 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string // A bare `keep;` delivers to the "default place", which on Stalwart is // Junk for a message its spam filter has already classified, so an // allow-list rule made with "Keep" would change nothing. An explicit - // `fileinto "INBOX"` is honored over the spam verdict (#1027). + // `fileinto "INBOX"` is honored over the spam verdict (#1027), but + // only from Stalwart 0.16.22; older versions still move it to Junk. return 'fileinto "INBOX";'; case 'stop': return 'stop;'; @@ -138,7 +139,32 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string }); } -function computeRequires(rules: FilterRule[], vacation: VacationSieveConfig | undefined, useMailboxId: boolean): string[] { +// Stalwart maps its spam verdict to 100 % (0.16.0) or to a graded value where +// 50 % is the spam threshold (0.16.19+), so ">= 50" matches "is spam" on both. +const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"'; +const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric']; + +function supportsSpamGuard(extensions: string[] | undefined): boolean { + return ['spamtestplus', 'relational'].every(e => extensions?.includes(e)); +} + +/** + * A folder move takes a message out of the Junk folder the server would have + * put it in, so move/copy rules skip spam unless the rule opts in. "Keep" + * rules are allow-lists and stay unguarded. + */ +function needsSpamGuard(rule: FilterRule, extensions: string[] | undefined): boolean { + return !rule.includeSpam && + rule.actions.some(a => a.type === 'move' || a.type === 'copy') && + supportsSpamGuard(extensions); +} + +function computeRequires( + rules: FilterRule[], + vacation: VacationSieveConfig | undefined, + useMailboxId: boolean, + serverExtensions: string[] | undefined, +): string[] { const extensions = new Set(); const enabledRules = rules.filter(r => r.enabled); @@ -147,6 +173,9 @@ function computeRequires(rules: FilterRule[], vacation: VacationSieveConfig | un } for (const rule of enabledRules) { + if (needsSpamGuard(rule, serverExtensions)) { + for (const e of SPAM_GUARD_REQUIRES) extensions.add(e); + } for (const condition of rule.conditions) { if (condition.field === 'body') extensions.add('body'); if (condition.field === 'attachment') extensions.add('mime'); @@ -193,6 +222,7 @@ function stripRuleForMetadata(r: FilterRule): Omit 1 && rule.matchType === 'all' + ? `allof(${conditions.join(', ')}, ${SPAM_GUARD})` + : `allof(${conditionStr}, ${SPAM_GUARD})`; + } + const actionLines = generateActions(rule.actions, useMailboxId); if (rule.stopProcessing) { diff --git a/locales/ar/common.json b/locales/ar/common.json index b8a34d45e..a6c736d7b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2002,6 +2002,7 @@ "actions": "الإجراءات", "add_action": "إضافة إجراء", "stop_processing": "إيقاف معالجة القواعد التالية", + "include_spam": "نقل الرسائل المصنفة كرسائل مزعجة أيضًا", "attachment_type_placeholder": "مثال: pdf, doc, jpg", "value_placeholder_multi": "القيمة (عدة قيم مفصولة بفواصل)", "condition_fields": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 064c1d599..3c1e7c7c7 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2002,6 +2002,7 @@ "actions": "Accions", "add_action": "Afegeix una acció", "stop_processing": "Atura el processament de les regles següents", + "include_spam": "Mou també els missatges marcats com a correu brossa", "attachment_type_placeholder": "p. ex. pdf, doc, jpg", "value_placeholder_multi": "Valor (múltiples separats per comes)", "condition_fields": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 5215bb087..bf994926a 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1995,6 +1995,7 @@ "actions": "Akce", "add_action": "Přidat akci", "stop_processing": "Zastavit zpracování dalších pravidel", + "include_spam": "Přesouvat i zprávy označené jako spam", "attachment_type_placeholder": "např. pdf, doc, jpg", "value_placeholder_multi": "Hodnota (více oddělených čárkami)", "condition_fields": { diff --git a/locales/da/common.json b/locales/da/common.json index 85f6d26e7..f04ede91b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1998,6 +1998,7 @@ "actions": "Handlinger", "add_action": "Tilføj handling", "stop_processing": "Stop behandling af efterfølgende regler", + "include_spam": "Flyt også beskeder, der er markeret som spam", "attachment_type_placeholder": "f.eks. pdf, doc, jpg", "value_placeholder_multi": "Værdi (flere adskilt med komma)", "condition_fields": { diff --git a/locales/de/common.json b/locales/de/common.json index c2e5049a1..0dbed8760 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1995,6 +1995,7 @@ "actions": "Aktionen", "add_action": "Aktion hinzufügen", "stop_processing": "Verarbeitung nachfolgender Regeln stoppen", + "include_spam": "Auch als Spam markierte Nachrichten verschieben", "attachment_type_placeholder": "z.B. pdf, doc, jpg", "value_placeholder_multi": "Wert (mehrere mit Komma trennen)", "condition_fields": { diff --git a/locales/en/common.json b/locales/en/common.json index ebe06980e..851a0e4fd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2002,6 +2002,7 @@ "actions": "Actions", "add_action": "Add Action", "stop_processing": "Stop processing subsequent rules", + "include_spam": "Also move messages marked as spam", "attachment_type_placeholder": "e.g. pdf, doc, jpg", "value_placeholder_multi": "Value (multiple separated by commas)", "condition_fields": { diff --git a/locales/es/common.json b/locales/es/common.json index a29bba8a6..aa5a72a05 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1995,6 +1995,7 @@ "actions": "Acciones", "add_action": "Agregar acción", "stop_processing": "Detener el procesamiento de reglas posteriores", + "include_spam": "Mover también los mensajes marcados como spam", "attachment_type_placeholder": "p. ej. pdf, doc, jpg", "value_placeholder_multi": "Valor (varios separados por comas)", "condition_fields": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 6c334ae40..40a96f9c8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2002,6 +2002,7 @@ "actions": "عملیات‌ها", "add_action": "افزودن عملیات", "stop_processing": "توقف پردازش قوانین بعدی", + "include_spam": "پیام‌های علامت‌خورده به‌عنوان هرزنامه هم منتقل شوند", "attachment_type_placeholder": "مثال: pdf, doc, jpg", "value_placeholder_multi": "مقدار (چندتا با کاما جدا شوند)", "condition_fields": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 906b53369..6fd321684 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1995,6 +1995,7 @@ "actions": "Actions", "add_action": "Ajouter une action", "stop_processing": "Arrêter le traitement des règles suivantes", + "include_spam": "Déplacer aussi les messages marqués comme spam", "attachment_type_placeholder": "p. ex. pdf, doc, jpg", "value_placeholder_multi": "Valeur (plusieurs séparées par des virgules)", "condition_fields": { diff --git a/locales/he/common.json b/locales/he/common.json index 7c342d3e7..27d3d8741 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1917,6 +1917,7 @@ "actions": "פעולות", "add_action": "הוסף פעולה", "stop_processing": "הפסק לעבד את הכללים הבאים", + "include_spam": "להעביר גם הודעות שסומנו כדואר זבל", "condition_fields": { "from": "מ", "to": "ל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 853ad2c7b..78bc4e487 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1998,6 +1998,7 @@ "actions": "Műveletek", "add_action": "Művelet hozzáadása", "stop_processing": "További szabályok feldolgozásának leállítása", + "include_spam": "A levélszemétnek jelölt üzenetek áthelyezése is", "attachment_type_placeholder": "pl. pdf, doc, jpg", "value_placeholder_multi": "Érték (több érték vesszővel elválasztva)", "condition_fields": { diff --git a/locales/it/common.json b/locales/it/common.json index 47d793bf5..5fe5dd088 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1995,6 +1995,7 @@ "actions": "Azioni", "add_action": "Aggiungi azione", "stop_processing": "Interrompere l'elaborazione delle regole successive", + "include_spam": "Sposta anche i messaggi contrassegnati come spam", "attachment_type_placeholder": "es. pdf, doc, jpg", "value_placeholder_multi": "Valore (più valori separati da virgole)", "condition_fields": { diff --git a/locales/ja/common.json b/locales/ja/common.json index c378f370e..83658b1a4 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1995,6 +1995,7 @@ "actions": "アクション", "add_action": "アクションを追加", "stop_processing": "以降のルールの処理を停止", + "include_spam": "迷惑メールと判定されたメッセージも移動する", "attachment_type_placeholder": "例: pdf, doc, jpg", "value_placeholder_multi": "値(カンマで複数指定可)", "condition_fields": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 3521b95f6..034de1970 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1995,6 +1995,7 @@ "actions": "동작", "add_action": "동작 추가", "stop_processing": "이후 규칙 무시하기", + "include_spam": "스팸으로 표시된 메시지도 이동", "attachment_type_placeholder": "예: pdf, doc, jpg", "value_placeholder_multi": "값 (쉼표로 여러 개 구분)", "condition_fields": { diff --git a/locales/lv/common.json b/locales/lv/common.json index ffefe303c..4e86b9867 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1995,6 +1995,7 @@ "actions": "Darbības", "add_action": "Pievienot darbību", "stop_processing": "Pārtraukt nākamo noteikumu apstrādi", + "include_spam": "Pārvietot arī ziņas, kas atzīmētas kā mēstules", "attachment_type_placeholder": "piem. pdf, doc, jpg", "value_placeholder_multi": "Vērtība (vairākas atdalītas ar komatu)", "condition_fields": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 499a78052..b5b949591 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2002,6 +2002,7 @@ "actions": "Үйлдэл", "add_action": "Үйлдэл нэмэх", "stop_processing": "Дараагийн дүрмийг боловсруулахаа зогсоох", + "include_spam": "Спам гэж тэмдэглэсэн захидлыг мөн зөөх", "attachment_type_placeholder": "жишээ нь pdf, doc, jpg", "value_placeholder_multi": "Утга (олон тоог таслалаар тусгаарласан)", "condition_fields": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 6c87e5262..2a1f89edf 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2002,6 +2002,7 @@ "actions": "Handlinger", "add_action": "Legg til handling", "stop_processing": "Ikke behandle flere regler", + "include_spam": "Flytt også meldinger som er merket som søppelpost", "attachment_type_placeholder": "f.eks. pdf, doc, jpg", "value_placeholder_multi": "Verdi (skill flere verdier med komma)", "condition_fields": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 0c240864c..5bb97790e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1995,6 +1995,7 @@ "actions": "Acties", "add_action": "Actie toevoegen", "stop_processing": "Verwerking van volgende regels stoppen", + "include_spam": "Ook berichten verplaatsen die als spam zijn gemarkeerd", "attachment_type_placeholder": "bijv. pdf, doc, jpg", "value_placeholder_multi": "Waarde (meerdere met komma's gescheiden)", "condition_fields": { diff --git a/locales/pl/common.json b/locales/pl/common.json index e38263341..c4ccfdae9 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1995,6 +1995,7 @@ "actions": "Akcje", "add_action": "Dodaj akcję", "stop_processing": "Zatrzymaj przetwarzanie kolejnych reguł", + "include_spam": "Przenoś również wiadomości oznaczone jako spam", "attachment_type_placeholder": "np. pdf, doc, jpg", "value_placeholder_multi": "Wartość (kilka oddzielonych przecinkami)", "condition_fields": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 9a872f385..3c8fec9af 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1995,6 +1995,7 @@ "actions": "Ações", "add_action": "Adicionar ação", "stop_processing": "Parar o processamento das regras seguintes", + "include_spam": "Mover também as mensagens marcadas como spam", "attachment_type_placeholder": "ex.: pdf, doc, jpg", "value_placeholder_multi": "Valor (vários separados por vírgulas)", "condition_fields": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 75af310de..1a2e67460 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2002,6 +2002,7 @@ "actions": "Acțiuni", "add_action": "Adăugare acțiune", "stop_processing": "Opriți procesarea regulilor următoare", + "include_spam": "Mută și mesajele marcate ca spam", "attachment_type_placeholder": "de ex. pdf, doc, jpg", "value_placeholder_multi": "Valoare (mai multe valori separate prin virgule)", "condition_fields": { diff --git a/locales/ru/common.json b/locales/ru/common.json index d6bba3b07..9a370ddfd 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1995,6 +1995,7 @@ "actions": "Действия", "add_action": "Добавить действие", "stop_processing": "Прекратить обработку последующих правил", + "include_spam": "Перемещать также письма, помеченные как спам", "attachment_type_placeholder": "напр. pdf, doc, jpg", "value_placeholder_multi": "Значение (несколько через запятую)", "condition_fields": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 960257339..475d81b7e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2002,6 +2002,7 @@ "actions": "Akcie", "add_action": "Pridať akciu", "stop_processing": "Zastaviť spracovanie ďalších pravidiel", + "include_spam": "Presúvať aj správy označené ako spam", "attachment_type_placeholder": "napr. pdf, doc, jpg", "value_placeholder_multi": "Hodnota (viac oddelených čiarkami)", "condition_fields": { diff --git a/locales/tr/common.json b/locales/tr/common.json index b9c644d00..acc0b446d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1995,6 +1995,7 @@ "actions": "İşlemler", "add_action": "İşlem Ekle", "stop_processing": "Sonraki kuralları işlemeyi durdur", + "include_spam": "İstenmeyen olarak işaretlenen iletileri de taşı", "attachment_type_placeholder": "örn. pdf, doc, jpg", "value_placeholder_multi": "Değer (birden fazla virgülle ayrılır)", "condition_fields": { diff --git a/locales/uk/common.json b/locales/uk/common.json index e4f32ef98..cc9f0abed 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1995,6 +1995,7 @@ "actions": "Дії", "add_action": "Додати дію", "stop_processing": "Зупинити обробку наступних правил", + "include_spam": "Переміщувати також листи, позначені як спам", "attachment_type_placeholder": "напр. pdf, doc, jpg", "value_placeholder_multi": "Значення (декілька через кому)", "condition_fields": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f894494a6..507bdc3f3 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2002,6 +2002,7 @@ "actions": "動作", "add_action": "新增動作", "stop_processing": "停止處理後續規則", + "include_spam": "同時移動被標記為垃圾郵件的郵件", "attachment_type_placeholder": "例如:pdf、doc、jpg", "value_placeholder_multi": "值(多個用逗號分隔)", "condition_fields": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 0d8b24d9e..c6989b8cf 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1995,6 +1995,7 @@ "actions": "操作", "add_action": "添加操作", "stop_processing": "停止处理后续规则", + "include_spam": "同时移动被标记为垃圾邮件的邮件", "attachment_type_placeholder": "例如:pdf、doc、jpg", "value_placeholder_multi": "值(多个用逗号分隔)", "condition_fields": {