diff --git a/README.md b/README.md index 586fc9f5..7a54894e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,15 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Real-time updates via JMAP push notifications - Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate) +### Email Filters +- Server-side email filtering with JMAP Sieve Scripts (RFC 9661) +- Visual rule builder with conditions (From, To, Subject, Size, Body, etc.) and actions (Move, Forward, Mark read, Star, Discard, Reject, etc.) +- Raw Sieve script editor for advanced users with syntax validation +- Auto-save on rule changes with rollback on failure +- Drag-and-drop rule reordering +- Reset opaque scripts back to visual builder +- Capability-gated (only shown when server supports Sieve) + ### Vacation Responder - JMAP VacationResponse management with date range scheduling - Dedicated settings tab with message configuration diff --git a/ROADMAP.md b/ROADMAP.md index 98a1839c..edb2613e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,18 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Drag-and-drop event rescheduling (week/day time snap, month date move) - [x] iCalendar (.ics) file import via CalendarEvent/parse with preview and bulk create +### Email Filters +- [x] JMAP Sieve Scripts (RFC 9661) with capability detection +- [x] Visual rule builder (conditions: From/To/Cc/Subject/Header/Size/Body, actions: Move/Copy/Forward/Mark read/Star/Label/Discard/Reject/Keep/Stop) +- [x] Raw Sieve script editor with syntax validation +- [x] Sieve generator and parser with JSON metadata round-trip +- [x] Filter store with CRUD, reorder, toggle, auto-save with rollback +- [x] Opaque script detection with reset to visual builder option +- [x] Focus trap accessibility in modals +- [x] Toast validation feedback for empty rules +- [x] Push notification handling for SieveScript state changes +- [x] i18n support (all 8 languages) + ### Email Display - [x] Proper email layout without horizontal scroll or clipping - [x] Blocked image container collapsing (no empty spaces in newsletters) @@ -159,6 +171,8 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Unit tests for email headers (39 tests) - [x] Component tests (contacts, UI components — 41 tests) - [x] JMAP client method tests (identity: 20, contacts: 41) +- [x] Unit tests for Sieve generator (50 tests) +- [x] Unit tests for Sieve parser (14 tests) - [x] XSS attack vector testing - [x] Playwright E2E framework setup @@ -171,7 +185,6 @@ This document tracks the development status and planned features for JMAP Webmai ## Planned Features ### Advanced Features -- [ ] Email filters and rules - [ ] Participant scheduling with iTIP invitations - [ ] Free/busy queries (Principal/getAvailability) - [ ] Calendar sharing UI (JMAP Sharing RFC 9670) diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 379e44af..933dda25 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 00000000..46bba450 --- /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 00000000..709520af --- /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 ( +
+