fix: stop folder rules from pulling spam out of Junk

Stalwart only moves spam to Junk when nothing else decided where the
message goes. Any Bulwark rule that moved or copied mail to a folder
therefore delivered spam there too, e.g. a "subject contains invoice"
rule collected phishing. From 0.16.22 this also applies to the "Keep"
allow-list action, which older versions still send to Junk.

Move and copy rules now add
`not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"`
to their condition when the server supports `spamtestplus` and
`relational`. 50 % is Stalwart's spam threshold on the graded scale
(0.16.19+), and older versions report 0 or 100. A new per-rule option
"Also move messages marked as spam" drops the guard, and "Keep" rules
are left unguarded as the explicit allow-list.
This commit is contained in:
Linus Rath
2026-09-22 20:14:42 +02:00
parent dadfb08f9c
commit 6241ed61d6
31 changed files with 123 additions and 4 deletions
+18 -1
View File
@@ -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<FilterCondition>) => {
setConditions((prev) =>
@@ -586,6 +588,21 @@ export function FilterRuleModal({
{t("stop_processing")}
</label>
</div>
{actions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="includeSpam"
checked={includeSpam}
onChange={(e) => setIncludeSpam(e.target.checked)}
className="rounded border-input"
/>
<label htmlFor="includeSpam" className="text-sm text-foreground">
{t("include_spam")}
</label>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
+5
View File
@@ -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;
+34
View File
@@ -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 });
+39 -3
View File
@@ -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<string>();
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<FilterRule, 'origin' | 'origi
conditions: r.conditions,
actions: r.actions,
stopProcessing: r.stopProcessing,
...(r.includeSpam ? { includeSpam: true } : {}),
};
}
@@ -251,7 +281,7 @@ export function generateScript(
lines.push('');
const useMailboxId = options.extensions?.includes('mailboxid') ?? false;
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId);
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
if (options.includeVacation) bulwarkRequires.push('include');
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
@@ -302,6 +332,12 @@ export function generateScript(
conditionStr = `${wrapper}(${conditions.join(', ')})`;
}
if (needsSpamGuard(rule, options.extensions)) {
conditionStr = conditions.length > 1 && rule.matchType === 'all'
? `allof(${conditions.join(', ')}, ${SPAM_GUARD})`
: `allof(${conditionStr}, ${SPAM_GUARD})`;
}
const actionLines = generateActions(rule.actions, useMailboxId);
if (rule.stopProcessing) {
+1
View File
@@ -2002,6 +2002,7 @@
"actions": "الإجراءات",
"add_action": "إضافة إجراء",
"stop_processing": "إيقاف معالجة القواعد التالية",
"include_spam": "نقل الرسائل المصنفة كرسائل مزعجة أيضًا",
"attachment_type_placeholder": "مثال: pdf, doc, jpg",
"value_placeholder_multi": "القيمة (عدة قيم مفصولة بفواصل)",
"condition_fields": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -2002,6 +2002,7 @@
"actions": "عملیات‌ها",
"add_action": "افزودن عملیات",
"stop_processing": "توقف پردازش قوانین بعدی",
"include_spam": "پیام‌های علامت‌خورده به‌عنوان هرزنامه هم منتقل شوند",
"attachment_type_placeholder": "مثال: pdf, doc, jpg",
"value_placeholder_multi": "مقدار (چندتا با کاما جدا شوند)",
"condition_fields": {
+1
View File
@@ -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": {
+1
View File
@@ -1917,6 +1917,7 @@
"actions": "פעולות",
"add_action": "הוסף פעולה",
"stop_processing": "הפסק לעבד את הכללים הבאים",
"include_spam": "להעביר גם הודעות שסומנו כדואר זבל",
"condition_fields": {
"from": "מ",
"to": "ל",
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -1995,6 +1995,7 @@
"actions": "アクション",
"add_action": "アクションを追加",
"stop_processing": "以降のルールの処理を停止",
"include_spam": "迷惑メールと判定されたメッセージも移動する",
"attachment_type_placeholder": "例: pdf, doc, jpg",
"value_placeholder_multi": "値(カンマで複数指定可)",
"condition_fields": {
+1
View File
@@ -1995,6 +1995,7 @@
"actions": "동작",
"add_action": "동작 추가",
"stop_processing": "이후 규칙 무시하기",
"include_spam": "스팸으로 표시된 메시지도 이동",
"attachment_type_placeholder": "예: pdf, doc, jpg",
"value_placeholder_multi": "값 (쉼표로 여러 개 구분)",
"condition_fields": {
+1
View File
@@ -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": {
+1
View File
@@ -2002,6 +2002,7 @@
"actions": "Үйлдэл",
"add_action": "Үйлдэл нэмэх",
"stop_processing": "Дараагийн дүрмийг боловсруулахаа зогсоох",
"include_spam": "Спам гэж тэмдэглэсэн захидлыг мөн зөөх",
"attachment_type_placeholder": "жишээ нь pdf, doc, jpg",
"value_placeholder_multi": "Утга (олон тоог таслалаар тусгаарласан)",
"condition_fields": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -1995,6 +1995,7 @@
"actions": "Действия",
"add_action": "Добавить действие",
"stop_processing": "Прекратить обработку последующих правил",
"include_spam": "Перемещать также письма, помеченные как спам",
"attachment_type_placeholder": "напр. pdf, doc, jpg",
"value_placeholder_multi": "Значение (несколько через запятую)",
"condition_fields": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -1995,6 +1995,7 @@
"actions": "Дії",
"add_action": "Додати дію",
"stop_processing": "Зупинити обробку наступних правил",
"include_spam": "Переміщувати також листи, позначені як спам",
"attachment_type_placeholder": "напр. pdf, doc, jpg",
"value_placeholder_multi": "Значення (декілька через кому)",
"condition_fields": {
+1
View File
@@ -2002,6 +2002,7 @@
"actions": "動作",
"add_action": "新增動作",
"stop_processing": "停止處理後續規則",
"include_spam": "同時移動被標記為垃圾郵件的郵件",
"attachment_type_placeholder": "例如:pdf、doc、jpg",
"value_placeholder_multi": "值(多個用逗號分隔)",
"condition_fields": {
+1
View File
@@ -1995,6 +1995,7 @@
"actions": "操作",
"add_action": "添加操作",
"stop_processing": "停止处理后续规则",
"include_spam": "同时移动被标记为垃圾邮件的邮件",
"attachment_type_placeholder": "例如:pdf、doc、jpg",
"value_placeholder_multi": "值(多个用逗号分隔)",
"condition_fields": {