From 8353b28b339f0d795b7e958bebe4468c6f7ab818 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:05:36 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20extended=20filter=20rules=20?= =?UTF-8?q?=E2=80=94=20attachment=20field=20+=20multi-value=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Attachment" condition field (is present / of type ) backed by the RFC 5703 Sieve mime extension, matching the filename in both Content-Disposition and Content-Type headers so real-world senders that only put the name in Content-Type (Microsoft SMTPSVC, etc.) are caught. Users type extensions (pdf, doc) not MIME types. Also makes each text condition accept comma-separated multiple values emitted as a Sieve string list (OR within the condition), so "(domain1 OR domain2) AND attachment pdf/xml" is expressible in one rule. value is now string | string[] (single-value rules stay strings -> backward compatible). New filter locale keys in all 17 locales. --- components/filters/filter-rule-modal.tsx | 139 ++++++++++++++++---- components/settings/filter-settings.tsx | 29 ++++- lib/jmap/sieve-types.ts | 22 +++- lib/sieve/generator.ts | 65 ++++++++-- lib/sieve/parser.ts | 155 +++++++++++++++++++---- locales/cs/common.json | 5 + locales/da/common.json | 5 + locales/de/common.json | 5 + locales/en/common.json | 5 + locales/es/common.json | 5 + locales/fr/common.json | 5 + locales/it/common.json | 5 + locales/ja/common.json | 5 + locales/ko/common.json | 5 + locales/lv/common.json | 5 + locales/nl/common.json | 5 + locales/pl/common.json | 5 + locales/pt/common.json | 5 + locales/ru/common.json | 5 + locales/tr/common.json | 5 + locales/uk/common.json | 5 + locales/zh/common.json | 5 + 22 files changed, 431 insertions(+), 64 deletions(-) diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 25b0eb9b..221a6e7a 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -27,7 +27,7 @@ interface FilterRuleModalProps { } const ALL_FIELDS: FilterConditionField[] = [ - "from", "to", "cc", "subject", "header", "size", "body", + "from", "to", "cc", "subject", "header", "size", "body", "attachment", ]; const TEXT_COMPARATORS: FilterComparator[] = [ @@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [ 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 ALL_ACTION_TYPES: FilterActionType[] = [ "move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop", ]; @@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition { return { field: "from", comparator: "contains", value: "" }; } +// Multi-value handling: conditions are stored as string | string[]. The UI +// presents them as a single comma-separated text input — the user types +// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay +// strings so existing single-value rules don't change shape. +function valueToInputString(v: string | string[]): string { + if (Array.isArray(v)) return v.join(", "); + return v; +} + +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; +} + +function isConditionValueEmpty(v: string | string[]): boolean { + if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim()); + return !v.trim(); +} + function makeEmptyAction(): FilterAction { return { type: "move", value: "" }; } @@ -97,9 +126,23 @@ export function FilterRuleModal({ return; } - const validConditions = conditions.filter( - (c) => c.value.trim() - ); + // While editing, condition.value is always 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" actually persists as ["a","b","c"]. This is the + // moment we know editing is finished - splitting earlier would eat any + // comma the user just typed mid-edit. + const validConditions = conditions + .filter((c) => { + if (c.field === "attachment" && c.comparator === "has_any") return true; + return !isConditionValueEmpty(c.value); + }) + .map((c) => { + if (c.field === "attachment" && c.comparator === "has_any") return c; + if (c.field === "size") return c; // numeric, single-value only + if (typeof c.value !== "string") return c; // already structured + const parsed = inputStringToValue(c.value); + return { ...c, value: parsed }; + }); if (validConditions.length === 0) { toast.error(t("validation_empty_conditions")); return; @@ -129,15 +172,27 @@ export function FilterRuleModal({ prev.map((c, i) => { if (i !== index) return c; const updated = { ...c, ...updates }; - if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) { - updated.comparator = "greater_than"; - } - if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) { - updated.comparator = "contains"; + // Reconcile the comparator when the field changes so we never end up + // with e.g. (field=attachment, comparator=contains) — invalid for the + // Sieve generator. Each field has its own valid comparator set. + if (updates.field && updates.field !== c.field) { + const allowed = comparatorsFor(updates.field); + if (!allowed.includes(c.comparator)) { + updated.comparator = allowed[0]; + } } if (updates.field && updates.field !== "header") { delete updated.headerName; } + // has_any takes no value; clear it so we don't leak old text into + // the generated Sieve. + if (updated.field === "attachment" && updated.comparator === "has_any") { + updated.value = ""; + } + // Size is numeric, single value only - collapse any list to scalar. + if (updated.field === "size" && Array.isArray(updated.value)) { + updated.value = updated.value[0] ?? ""; + } return updated; }) ); @@ -281,24 +336,58 @@ export function FilterRuleModal({ className={selectClass} aria-label={t("comparators.contains")} > - {(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map( - (c) => ( - - ) - )} + {comparatorsFor(condition.field).map((c) => ( + + ))} - updateCondition(index, { value: e.target.value })} - placeholder={ - condition.field === "size" ? t("size_placeholder") : t("header_placeholder") - } - className="flex-1 min-w-[120px]" - type={condition.field === "size" ? "number" : "text"} - /> + {/* has_any takes no value; render a stub so the row layout + stays consistent but no input is editable. */} + {condition.field === "attachment" && condition.comparator === "has_any" ? ( +
+ ) : ( + + // Store the raw input string while typing. Splitting + // commas into an array on every keystroke would eat + // the comma the moment it's typed. + updateCondition(index, { value: e.target.value }) + } + onBlur={(e) => { + // On blur: normalise comma-separated input into an + // array (or single string when only one item). Size + // stays numeric/single-value; attachment-has_any has + // no value at all. + if (condition.field === "size") return; + if ( + condition.field === "attachment" && + condition.comparator === "has_any" + ) + return; + const parsed = inputStringToValue(e.target.value); + // Only update if the normalised shape actually + // differs - avoids triggering a no-op re-render and + // resetting the user's cursor on every blur. + if ( + JSON.stringify(parsed) !== JSON.stringify(condition.value) + ) { + updateCondition(index, { value: parsed }); + } + }} + placeholder={ + condition.field === "size" + ? t("size_placeholder") + : condition.field === "attachment" + ? t("attachment_type_placeholder") + : t("value_placeholder_multi") + } + className="flex-1 min-w-[120px]" + type={condition.field === "size" ? "number" : "text"} + /> + )}