From 9be02e8dd7bc624c8ee4f7a4232e01de49f7315d Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Mon, 16 Feb 2026 23:07:06 +0100 Subject: [PATCH] feat(filters): add email filters with Sieve rules, review fixes and hardening Implement JMAP Sieve Scripts (RFC 9661) with visual rule builder and raw Sieve editor. Includes post-review fixes: parser validation guards, generator empty-rule skipping, JMAP client error hardening, focus trap accessibility, mailbox name fix, toast validation feedback, auto-save with rollback, and "Reset to visual builder" for opaque scripts. 514 tests pass. --- TODO.md | 31 +- app/[locale]/settings/page.tsx | 6 +- components/filters/filter-rule-modal.tsx | 417 +++++++++++++++++++ components/filters/sieve-editor-modal.tsx | 194 +++++++++ components/settings/filter-settings.tsx | 468 ++++++++++++++++++++++ lib/jmap/client.ts | 242 +++++++++++ lib/jmap/sieve-types.ts | 55 +++ lib/jmap/types.ts | 1 + lib/sieve/__tests__/generator.test.ts | 402 +++++++++++++++++++ lib/sieve/__tests__/parser.test.ts | 147 +++++++ lib/sieve/generator.ts | 169 ++++++++ lib/sieve/parser.ts | 68 ++++ locales/de/common.json | 166 ++++++-- locales/en/common.json | 166 ++++++-- locales/es/common.json | 166 ++++++-- locales/fr/common.json | 166 ++++++-- locales/it/common.json | 166 ++++++-- locales/ja/common.json | 166 ++++++-- locales/nl/common.json | 166 ++++++-- locales/pt/common.json | 166 ++++++-- stores/auth-store.ts | 14 +- stores/email-store.ts | 11 + stores/filter-store.ts | 168 ++++++++ 23 files changed, 3517 insertions(+), 204 deletions(-) create mode 100644 components/filters/filter-rule-modal.tsx create mode 100644 components/filters/sieve-editor-modal.tsx create mode 100644 components/settings/filter-settings.tsx create mode 100644 lib/jmap/sieve-types.ts create mode 100644 lib/sieve/__tests__/generator.test.ts create mode 100644 lib/sieve/__tests__/parser.test.ts create mode 100644 lib/sieve/generator.ts create mode 100644 lib/sieve/parser.ts create mode 100644 stores/filter-store.ts diff --git a/TODO.md b/TODO.md index 999a410..dd6b8e2 100644 --- a/TODO.md +++ b/TODO.md @@ -209,7 +209,7 @@ - [x] Add bulk contact operations (multi-select, bulk delete, bulk add to group, bulk export) ### Advanced Features -- [ ] Implement filters and labels +- [x] Implement email filters (JMAP Sieve Scripts RFC 9661 — visual rule builder + raw Sieve editor, capability-gated) - [x] Add calendar integration (JMAP Calendars - see Calendar Integration section) - [ ] Create email templates - [x] Add calendar event drag-and-drop rescheduling (week/day time snap, month date move, visual indicators) @@ -504,6 +504,32 @@ All settings are now properly wired to their functionality: - Toolbar Import button with Upload icon - i18n: calendar.import.* keys in all 8 locales (17 strings each) +### Email Filters & Sieve Rules (2026-02-16) +- **JMAP Sieve Scripts (RFC 9661)**: Full server-side email filtering with visual builder + raw editor + - Types: lib/jmap/sieve-types.ts (SieveScript, SieveCapabilities, FilterRule, FilterCondition, FilterAction) + - Client: lib/jmap/client.ts (12 Sieve methods: supportsSieve, getSieveCapabilities, getSieveScripts, getSieveScriptContent, createSieveScript, updateSieveScript, deleteSieveScript, activateSieveScript, deactivateSieveScript, validateSieveScript) + - Capability detection: `urn:ietf:params:jmap:sieve` in auth-store.ts +- **Sieve Generator/Parser**: lib/sieve/generator.ts, lib/sieve/parser.ts + - Rules stored as JSON metadata in `/* @metadata:begin ... @metadata:end */` comment block + - Dynamic `require` extension computation from enabled rules' actions + - Round-trip fidelity: parse → edit → regenerate preserves all rule data + - Hand-edited scripts detected as "opaque" → raw editor only + - 64 tests (50 generator + 14 parser) +- **Store**: stores/filter-store.ts (Zustand, no persist) + - Rules CRUD: addRule, updateRule, deleteRule, reorderRules, toggleRule + - fetchFilters, saveFilters, validateScript via JMAP client + - Opaque script detection for hand-edited Sieve scripts + - Auto-fetch on login via auth-store, clear on logout +- **UI Components**: + - components/settings/filter-settings.tsx — Settings tab: rule list with drag-and-drop reorder, toggle, edit, delete, raw editor toggle + - components/filters/filter-rule-modal.tsx — Modal: name, match type (all/any), condition rows, action rows, stop processing + - components/filters/sieve-editor-modal.tsx — Modal: monospace editor with line numbers, validate button, two-step save confirmation +- **Conditions**: From, To, Cc, Subject, Custom Header, Size, Body with comparators: contains, not contains, is, not is, starts with, ends with, matches, greater than, less than +- **Actions**: Move to folder, Copy to folder, Forward, Mark as read, Star, Add label, Discard, Reject, Keep, Stop processing +- **Integration**: Settings tab (capability-gated), push notification handling (SieveScript state changes), auth-store init/cleanup +- **i18n**: Full EN/FR/JA/ES/IT/DE/NL/PT translations (settings.filters.* namespace + sieve_editor sub-namespace) +- **Accessibility**: Focus trap in modals, ARIA labels, keyboard support (Esc to close, Ctrl+Enter to save), drag-and-drop reorder with grip handles + ### Feature Completeness - **Authentication**: ✅ Complete (secure design, no password storage) - **Email Operations**: ✅ Complete (including threading, unsubscribe) @@ -517,5 +543,6 @@ All settings are now properly wired to their functionality: - **Virtual Scrolling**: ✅ Complete (@tanstack/react-virtual, dynamic measurement, keyboard scroll-to) - **Security**: ✅ CSP Report-Only + all P0 headers deployed (nonce-based scripts, proxy.ts middleware) - **Calendar**: ✅ Phase 2 complete (drag-and-drop rescheduling, iCalendar import, plus phase 1: views, event CRUD, multi-day, overlaps, locale dates, accessibility, security) -- **Testing**: ✅ 450 tests passing (identity, sub-addressing, contacts, vCard, validation, color, threads, headers) +- **Email Filters**: ✅ Complete (JMAP Sieve RFC 9661, visual rule builder, raw Sieve editor, 64 tests, capability-gated, 8 locales) +- **Testing**: ✅ 514 tests passing (identity, sub-addressing, contacts, vCard, validation, color, threads, headers, sieve generator, sieve parser) diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 379e44a..933dda2 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -11,11 +11,12 @@ import { AccountSettings } from '@/components/settings/account-settings'; import { IdentitySettings } from '@/components/settings/identity-settings'; import { VacationSettings } from '@/components/settings/vacation-settings'; import { CalendarSettings } from '@/components/settings/calendar-settings'; +import { FilterSettings } from '@/components/settings/filter-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { useAuthStore } from '@/stores/auth-store'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'advanced'; +type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'advanced'; export default function SettingsPage() { const router = useRouter(); @@ -25,6 +26,7 @@ export default function SettingsPage() { const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false; + const supportsSieve = client?.supportsSieve() ?? false; const tabs: { id: Tab; label: string }[] = [ { id: 'appearance', label: t('tabs.appearance') }, @@ -33,6 +35,7 @@ export default function SettingsPage() { { id: 'identities', label: t('tabs.identities') }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []), ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []), + ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []), { id: 'advanced', label: t('tabs.advanced') }, ]; @@ -93,6 +96,7 @@ export default function SettingsPage() { {activeTab === 'identities' && } {activeTab === 'vacation' && } {activeTab === 'calendar' && } + {activeTab === 'filters' && } {activeTab === 'advanced' && } diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx new file mode 100644 index 0000000..46bba45 --- /dev/null +++ b/components/filters/filter-rule-modal.tsx @@ -0,0 +1,417 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { X, Plus, Trash2 } from "lucide-react"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { toast } from "@/stores/toast-store"; +import type { + FilterRule, + FilterCondition, + FilterAction, + FilterConditionField, + FilterComparator, + FilterActionType, +} from "@/lib/jmap/sieve-types"; +import type { Mailbox } from "@/lib/jmap/types"; + +interface FilterRuleModalProps { + rule?: FilterRule; + mailboxes: Mailbox[]; + onSave: (rule: FilterRule) => void; + onClose: () => void; +} + +const ALL_FIELDS: FilterConditionField[] = [ + "from", "to", "cc", "subject", "header", "size", "body", +]; + +const TEXT_COMPARATORS: FilterComparator[] = [ + "contains", "not_contains", "is", "not_is", "starts_with", "ends_with", "matches", +]; + +const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"]; + +const ALL_ACTION_TYPES: FilterActionType[] = [ + "move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop", +]; + +const ACTIONS_WITH_VALUE = new Set(["move", "copy", "forward", "reject", "add_label"]); +const ACTIONS_WITH_MAILBOX = new Set(["move", "copy"]); + +function makeEmptyCondition(): FilterCondition { + return { field: "from", comparator: "contains", value: "" }; +} + +function makeEmptyAction(): FilterAction { + return { type: "move", value: "" }; +} + +export function FilterRuleModal({ + rule, + mailboxes, + onSave, + onClose, +}: FilterRuleModalProps) { + const t = useTranslations("settings.filters"); + const isEdit = !!rule; + + const [name, setName] = useState(rule?.name || ""); + const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); + const [conditions, setConditions] = useState( + rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()] + ); + const [actions, setActions] = useState( + rule?.actions.length ? [...rule.actions] : [makeEmptyAction()] + ); + const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false); + + const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); + + const handleSave = useCallback(() => { + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error(t("validation_empty_name")); + return; + } + + const validConditions = conditions.filter( + (c) => c.value.trim() + ); + if (validConditions.length === 0) { + toast.error(t("validation_empty_conditions")); + return; + } + + const validActions = actions.filter( + (a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim() + ); + if (validActions.length === 0) { + toast.error(t("validation_empty_actions")); + return; + } + + onSave({ + id: rule?.id || crypto.randomUUID(), + name: trimmedName, + enabled: rule?.enabled ?? true, + matchType, + conditions: validConditions, + actions: validActions, + stopProcessing, + }); + }, [name, matchType, conditions, actions, stopProcessing, rule, onSave, t]); + + const updateCondition = (index: number, updates: Partial) => { + setConditions((prev) => + 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"; + } + if (updates.field && updates.field !== "header") { + delete updated.headerName; + } + return updated; + }) + ); + }; + + const removeCondition = (index: number) => { + if (conditions.length <= 1) return; + setConditions((prev) => prev.filter((_, i) => i !== index)); + }; + + const updateAction = (index: number, updates: Partial) => { + setActions((prev) => + prev.map((a, i) => { + if (i !== index) return a; + const updated = { ...a, ...updates }; + if (updates.type && !ACTIONS_WITH_VALUE.has(updates.type)) { + delete updated.value; + } + if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) { + updated.value = mailboxes[0]?.name || ""; + } + return updated; + }) + ); + }; + + const removeAction = (index: number) => { + if (actions.length <= 1) return; + setActions((prev) => prev.filter((_, i) => i !== index)); + }; + + const selectClass = + "px-2 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer"; + + return ( +
+ + ); +} diff --git a/components/filters/sieve-editor-modal.tsx b/components/filters/sieve-editor-modal.tsx new file mode 100644 index 0000000..709520a --- /dev/null +++ b/components/filters/sieve-editor-modal.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { X, AlertTriangle, CheckCircle, Loader2 } from "lucide-react"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; + +interface SieveEditorModalProps { + content: string; + onSave: (content: string) => void; + onClose: () => void; + onValidate: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>; +} + +export function SieveEditorModal({ + content, + onSave, + onClose, + onValidate, +}: SieveEditorModalProps) { + const t = useTranslations("settings.filters.sieve_editor"); + const [script, setScript] = useState(content); + const [isValidating, setIsValidating] = useState(false); + const [validationResult, setValidationResult] = useState<{ + isValid: boolean; + errors?: string[]; + } | null>(null); + const [showSaveWarning, setShowSaveWarning] = useState(false); + + const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); + const textareaRef = useRef(null); + + const lineCount = script.split("\n").length; + + const handleValidate = useCallback(async () => { + setIsValidating(true); + setValidationResult(null); + try { + const result = await onValidate(script); + setValidationResult(result); + } catch { + setValidationResult({ isValid: false, errors: [t("validation_failed")] }); + } finally { + setIsValidating(false); + } + }, [script, onValidate, t]); + + const handleSave = useCallback(() => { + if (!showSaveWarning) { + setShowSaveWarning(true); + return; + } + onSave(script); + }, [script, showSaveWarning, onSave]); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + const textarea = e.currentTarget; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + setScript(script.substring(0, start) + " " + script.substring(end)); + requestAnimationFrame(() => { + textarea.selectionStart = textarea.selectionEnd = start + 2; + }); + } + }; + + return ( +
+