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 ( + + + + + + {isEdit ? t("edit_rule") : t("new_rule")} + + + + + + + + + + {t("rule_name")} + + setName(e.target.value)} + placeholder={t("rule_name_placeholder")} + maxLength={200} + autoFocus + /> + + + + + {t("match_type")} + + + setMatchType("all")} + className={`px-3 py-1.5 text-xs rounded transition-colors ${ + matchType === "all" + ? "bg-primary text-primary-foreground" + : "bg-muted hover:bg-accent text-foreground" + }`} + > + {t("match_all")} + + setMatchType("any")} + className={`px-3 py-1.5 text-xs rounded transition-colors ${ + matchType === "any" + ? "bg-primary text-primary-foreground" + : "bg-muted hover:bg-accent text-foreground" + }`} + > + {t("match_any")} + + + + + + + {t("conditions")} + + + {conditions.map((condition, index) => ( + + + updateCondition(index, { field: e.target.value as FilterConditionField }) + } + className={selectClass} + aria-label={t("conditions")} + > + {ALL_FIELDS.map((f) => ( + + {t(`condition_fields.${f}`)} + + ))} + + + {condition.field === "header" && ( + + updateCondition(index, { headerName: e.target.value }) + } + placeholder={t("header_name")} + className="w-28" + /> + )} + + + updateCondition(index, { comparator: e.target.value as FilterComparator }) + } + className={selectClass} + aria-label={t("comparators.contains")} + > + {(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map( + (c) => ( + + {t(`comparators.${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"} + /> + + removeCondition(index)} + disabled={conditions.length <= 1} + className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-30 disabled:pointer-events-none" + aria-label={t("delete_rule")} + > + + + + ))} + + setConditions((prev) => [...prev, makeEmptyCondition()])} + className="flex items-center gap-1 mt-2 text-sm text-primary hover:underline" + > + + {t("add_condition")} + + + + + + {t("actions")} + + + {actions.map((action, index) => ( + + + updateAction(index, { type: e.target.value as FilterActionType }) + } + className={selectClass} + aria-label={t("actions")} + > + {ALL_ACTION_TYPES.map((a) => ( + + {t(`action_types.${a}`)} + + ))} + + + {ACTIONS_WITH_MAILBOX.has(action.type) && ( + updateAction(index, { value: e.target.value })} + className={`${selectClass} flex-1 min-w-[140px]`} + aria-label={t("move_to_folder")} + > + {t("move_to_folder")} + {mailboxes.map((mb) => ( + + {mb.name} + + ))} + + )} + + {action.type === "forward" && ( + updateAction(index, { value: e.target.value })} + placeholder={t("forward_placeholder")} + type="email" + className="flex-1 min-w-[180px]" + /> + )} + + {action.type === "reject" && ( + updateAction(index, { value: e.target.value })} + placeholder={t("reject_placeholder")} + className="flex-1 min-w-[180px]" + /> + )} + + {action.type === "add_label" && ( + updateAction(index, { value: e.target.value })} + placeholder={t("label_placeholder")} + className="flex-1 min-w-[140px]" + /> + )} + + removeAction(index)} + disabled={actions.length <= 1} + className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-30 disabled:pointer-events-none" + aria-label={t("delete_rule")} + > + + + + ))} + + setActions((prev) => [...prev, makeEmptyAction()])} + className="flex items-center gap-1 mt-2 text-sm text-primary hover:underline" + > + + {t("add_action")} + + + + + setStopProcessing(e.target.checked)} + className="rounded border-input" + /> + + {t("stop_processing")} + + + + + + + {t("cancel")} + + + {t("save")} + + + + + ); +} 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 ( + + + + + {t("title")} + + + + + + + + + {t("warning")} + + + + + {Array.from({ length: lineCount }, (_, i) => ( + + {i + 1} + + ))} + + { + setScript(e.target.value); + setValidationResult(null); + setShowSaveWarning(false); + }} + onKeyDown={handleKeyDown} + className="flex-1 bg-background text-foreground font-mono text-sm p-2 resize-none focus:outline-none leading-[1.5rem]" + spellCheck={false} + aria-label={t("script_content")} + /> + + + {validationResult && ( + + {validationResult.isValid ? ( + <> + + {t("valid")} + > + ) : ( + <> + + + {t("invalid")} + {validationResult.errors?.map((err, i) => ( + + {err} + + ))} + + > + )} + + )} + + {showSaveWarning && ( + + + {t("save_warning")} + + )} + + + + + {isValidating ? ( + <> + + {t("validating")} + > + ) : ( + t("validate") + )} + + + + {t("cancel")} + + + {showSaveWarning ? t("confirm_save") : t("save")} + + + + + + ); +} diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx new file mode 100644 index 00000000..1799f26e --- /dev/null +++ b/components/settings/filter-settings.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { SettingsSection, ToggleSwitch } from "./settings-section"; +import { Button } from "@/components/ui/button"; +import { FilterRuleModal } from "@/components/filters/filter-rule-modal"; +import { SieveEditorModal } from "@/components/filters/sieve-editor-modal"; +import { useFilterStore } from "@/stores/filter-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; +import type { FilterRule } from "@/lib/jmap/sieve-types"; +import { + Plus, + GripVertical, + X, + Code, + AlertTriangle, + Loader2, + Filter, + RotateCcw, +} from "lucide-react"; + +function RuleSummary({ rule }: { rule: FilterRule }) { + const t = useTranslations("settings.filters"); + + const conditionSummary = rule.conditions + .slice(0, 2) + .map((c) => { + const field = t(`condition_fields.${c.field}`); + const comparator = t(`comparators.${c.comparator}`); + return `${field} ${comparator} "${c.value}"`; + }) + .join(rule.matchType === "all" ? ` ${t("and")} ` : ` ${t("or")} `); + + const extra = rule.conditions.length > 2 + ? ` (+${rule.conditions.length - 2})` + : ""; + + const actionSummary = rule.actions + .slice(0, 2) + .map((a) => { + const action = t(`action_types.${a.type}`); + return a.value ? `${action} "${a.value}"` : action; + }) + .join(", "); + + return ( + + {conditionSummary}{extra} → {actionSummary} + + ); +} + +export function FilterSettings() { + const t = useTranslations("settings.filters"); + const tNotifications = useTranslations("notifications"); + const { client } = useAuthStore(); + const mailboxes = useEmailStore((s) => s.mailboxes); + + const { + rules, + isLoading, + isSaving, + error, + isSupported, + isOpaque, + rawScript, + fetchFilters, + saveFilters, + addRule, + updateRule, + deleteRule, + reorderRules, + toggleRule, + setRawScript, + resetToVisualBuilder, + validateScript, + } = useFilterStore(); + + const [editingRule, setEditingRule] = useState(); + const [showRuleModal, setShowRuleModal] = useState(false); + const [showSieveEditor, setShowSieveEditor] = useState(false); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [dragOverIndex, setDragOverIndex] = useState(null); + const draggedIndexRef = useRef(null); + + useEffect(() => { + if (client && isSupported) { + void fetchFilters(client); + } + }, [client, isSupported, fetchFilters]); + + const handleToggle = useCallback( + async (ruleId: string) => { + toggleRule(ruleId); + if (client) { + try { + await saveFilters(client); + } catch { + toggleRule(ruleId); + toast.error(tNotifications("filters_save_failed")); + } + } + }, + [client, toggleRule, saveFilters, tNotifications] + ); + + const handleDelete = useCallback( + async (ruleId: string) => { + const deletedRule = rules.find((r) => r.id === ruleId); + deleteRule(ruleId); + setDeleteConfirmId(null); + if (client) { + try { + await saveFilters(client); + toast.success(tNotifications("filters_deleted")); + } catch { + if (deletedRule) addRule(deletedRule); + toast.error(tNotifications("filters_save_failed")); + } + } + }, + [client, rules, deleteRule, addRule, saveFilters, tNotifications] + ); + + const handleSaveRule = useCallback( + async (rule: FilterRule) => { + const previousRules = [...rules]; + if (editingRule) { + updateRule(rule.id, rule); + } else { + addRule(rule); + } + setShowRuleModal(false); + setEditingRule(undefined); + + if (client) { + try { + await saveFilters(client); + } catch { + useFilterStore.setState({ rules: previousRules }); + toast.error(tNotifications("filters_save_failed")); + } + } + }, + [editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications] + ); + + const handleSaveSieve = useCallback( + async (content: string) => { + setRawScript(content); + useFilterStore.setState({ isOpaque: true, rules: [] }); + if (client) { + try { + await saveFilters(client); + toast.success(tNotifications("filters_saved")); + setShowSieveEditor(false); + } catch { + toast.error(tNotifications("filters_save_failed")); + } + } + }, + [client, setRawScript, saveFilters, tNotifications] + ); + + const handleResetToVisual = useCallback(async () => { + if (!showResetConfirm) { + setShowResetConfirm(true); + return; + } + resetToVisualBuilder(); + setShowResetConfirm(false); + if (client) { + try { + await saveFilters(client); + toast.success(tNotifications("filters_saved")); + } catch { + toast.error(tNotifications("filters_save_failed")); + } + } + }, [showResetConfirm, resetToVisualBuilder, client, saveFilters, tNotifications]); + + const handleValidate = useCallback( + async (content: string) => { + if (!client) return { isValid: false, errors: ["No client"] }; + return validateScript(client, content); + }, + [client, validateScript] + ); + + const handleDragStart = useCallback( + (e: React.DragEvent, index: number) => { + draggedIndexRef.current = index; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + }, + [] + ); + + const handleDragOver = useCallback( + (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIndex(index); + }, + [] + ); + + const handleDrop = useCallback( + async (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + setDragOverIndex(null); + const fromIndex = draggedIndexRef.current; + if (fromIndex === null || fromIndex === dropIndex) return; + + const previousOrder = rules.map((r) => r.id); + const newOrder = [...previousOrder]; + const [moved] = newOrder.splice(fromIndex, 1); + newOrder.splice(dropIndex, 0, moved); + reorderRules(newOrder); + + if (client) { + try { + await saveFilters(client); + } catch { + reorderRules(previousOrder); + toast.error(tNotifications("filters_save_failed")); + } + } + }, + [rules, reorderRules, client, saveFilters, tNotifications] + ); + + const handleDragEnd = useCallback(() => { + draggedIndexRef.current = null; + setDragOverIndex(null); + }, []); + + if (!isSupported) { + return ( + + + {t("not_supported")} + + + ); + } + + if (isLoading) { + return ( + + + + {t("loading")} + + + ); + } + + if (error) { + return ( + + + {t("fetch_error")} + + + ); + } + + return ( + + + {isOpaque && ( + + + + {t("opaque_warning")} + + setShowSieveEditor(true)} + className="text-primary hover:underline font-medium" + > + {t("open_sieve_editor")} + + {showResetConfirm ? ( + + {t("reset_warning")} + + {t("confirm_reset")} + + setShowResetConfirm(false)} + className="text-muted-foreground hover:underline" + > + {t("cancel")} + + + ) : ( + + + {t("reset_to_visual")} + + )} + + + + )} + + {!isOpaque && rules.length === 0 && ( + + + {t("no_rules")} + + )} + + {!isOpaque && rules.length > 0 && ( + + {rules.map((rule, index) => ( + handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + className={`flex items-center gap-3 p-3 rounded-md border transition-colors ${ + dragOverIndex === index + ? "border-primary bg-primary/5" + : "border-border hover:bg-muted/50" + } ${!rule.enabled ? "opacity-60" : ""}`} + > + + + + + handleToggle(rule.id)} + /> + + { + setEditingRule(rule); + setShowRuleModal(true); + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setEditingRule(rule); + setShowRuleModal(true); + } + }} + > + + {rule.name} + + + + + {deleteConfirmId === rule.id ? ( + + handleDelete(rule.id)} + > + {t("confirm_delete")} + + setDeleteConfirmId(null)} + > + {t("cancel")} + + + ) : ( + setDeleteConfirmId(rule.id)} + className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors" + aria-label={t("delete_rule")} + > + + + )} + + ))} + + )} + + + + + {!isOpaque && ( + { + setEditingRule(undefined); + setShowRuleModal(true); + }} + > + + {t("add_rule")} + + )} + setShowSieveEditor(true)} + > + + {t("raw_editor")} + + + + {isSaving && ( + + + {t("saving")} + + )} + + + {showRuleModal && ( + { + setShowRuleModal(false); + setEditingRule(undefined); + }} + /> + )} + + {showSieveEditor && ( + setShowSieveEditor(false)} + onValidate={handleValidate} + /> + )} + + ); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d2aa1ea9..4440753c 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,5 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types"; +import type { SieveScript, SieveCapabilities } from "./sieve-types"; // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { @@ -1598,6 +1599,227 @@ export class JMAPClient { return this.hasCapability("urn:ietf:params:jmap:calendars"); } + supportsSieve(): boolean { + return this.hasCapability("urn:ietf:params:jmap:sieve"); + } + + getSieveAccountId(): string { + const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"]; + return sieveAccount || this.accountId; + } + + private sieveUsing(): string[] { + return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"]; + } + + getSieveCapabilities(): SieveCapabilities | null { + const sieveAccountId = this.getSieveAccountId(); + const accountInfo = this.accounts[sieveAccountId]; + if (!accountInfo?.accountCapabilities) return null; + const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"]; + return (caps as SieveCapabilities) || null; + } + + async getSieveScripts(): Promise { + const response = await this.request([ + ["SieveScript/get", { + accountId: this.getSieveAccountId(), + }, "0"] + ], this.sieveUsing()); + + if (response.methodResponses?.[0]?.[0] === "SieveScript/get") { + return (response.methodResponses[0][1].list || []) as SieveScript[]; + } + throw new Error('Failed to fetch Sieve scripts'); + } + + async getSieveScriptContent(blobId: string): Promise { + const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve'); + const response = await fetch(url, { + headers: { 'Authorization': this.authHeader }, + }); + if (!response.ok) throw new Error(`Failed to download script: ${response.status}`); + return response.text(); + } + + private async uploadSieveBlob(content: string): Promise { + if (!this.session?.uploadUrl) { + throw new Error('Upload URL not available'); + } + + const uploadUrl = this.session.uploadUrl.replace( + '{accountId}', + encodeURIComponent(this.getSieveAccountId()) + ); + + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'Authorization': this.authHeader, + 'Content-Type': 'application/sieve', + }, + body: content, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to upload sieve script: ${response.status} - ${errorText.substring(0, 200)}`); + } + + const result = await response.json(); + if (result.blobId) return result.blobId; + const blobInfo = result[this.getSieveAccountId()]; + if (blobInfo?.blobId) return blobInfo.blobId; + throw new Error('Invalid upload response: blobId not found'); + } + + async createSieveScript(name: string, content: string): Promise { + const blobId = await this.uploadSieveBlob(content); + const accountId = this.getSieveAccountId(); + + const response = await this.request([ + ["SieveScript/set", { + accountId, + create: { + "new-script": { name, blobId } + } + }, "0"] + ], this.sieveUsing()); + + if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { + const result = response.methodResponses[0][1]; + if (result.notCreated?.["new-script"]) { + const error = result.notCreated["new-script"]; + throw new Error(error.description || "Failed to create sieve script"); + } + const createdId = result.created?.["new-script"]?.id; + if (createdId) { + const scripts = await this.getSieveScripts(); + const script = scripts.find(s => s.id === createdId); + if (script) return script; + } + } + throw new Error("Failed to create sieve script"); + } + + async updateSieveScript(scriptId: string, content: string): Promise { + const blobId = await this.uploadSieveBlob(content); + const accountId = this.getSieveAccountId(); + + const response = await this.request([ + ["SieveScript/set", { + accountId, + update: { + [scriptId]: { blobId } + } + }, "0"] + ], this.sieveUsing()); + + if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { + const result = response.methodResponses[0][1]; + if (result.notUpdated?.[scriptId]) { + const error = result.notUpdated[scriptId]; + throw new Error(error.description || "Failed to update sieve script"); + } + return; + } + throw new Error("Failed to update sieve script"); + } + + async deleteSieveScript(scriptId: string): Promise { + const accountId = this.getSieveAccountId(); + + const response = await this.request([ + ["SieveScript/set", { + accountId, + destroy: [scriptId] + }, "0"] + ], this.sieveUsing()); + + if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { + const result = response.methodResponses[0][1]; + if (result.notDestroyed?.[scriptId]) { + const error = result.notDestroyed[scriptId]; + throw new Error(error.description || "Failed to delete sieve script"); + } + return; + } + throw new Error("Failed to delete sieve script"); + } + + async activateSieveScript(scriptId: string): Promise { + const accountId = this.getSieveAccountId(); + + const response = await this.request([ + ["SieveScript/set", { + accountId, + update: { + [scriptId]: { isActive: true } + } + }, "0"] + ], this.sieveUsing()); + + if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { + const result = response.methodResponses[0][1]; + if (result.notUpdated?.[scriptId]) { + const error = result.notUpdated[scriptId]; + throw new Error(error.description || "Failed to activate sieve script"); + } + return; + } + throw new Error("Failed to activate sieve script"); + } + + async deactivateSieveScript(scriptId: string): Promise { + const accountId = this.getSieveAccountId(); + + const response = await this.request([ + ["SieveScript/set", { + accountId, + update: { + [scriptId]: { isActive: false } + } + }, "0"] + ], this.sieveUsing()); + + if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { + const result = response.methodResponses[0][1]; + if (result.notUpdated?.[scriptId]) { + const error = result.notUpdated[scriptId]; + throw new Error(error.description || "Failed to deactivate sieve script"); + } + return; + } + throw new Error("Failed to deactivate sieve script"); + } + + async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> { + const blobId = await this.uploadSieveBlob(content); + const accountId = this.getSieveAccountId(); + + const response = await this.request([ + ["SieveScript/validate", { + accountId, + blobId, + }, "0"] + ], this.sieveUsing()); + + if (response.methodResponses?.[0]?.[0] === "SieveScript/validate") { + const result = response.methodResponses[0][1]; + if (result.error) { + return { isValid: false, errors: [result.error.description || "Validation failed"] }; + } + return { isValid: true }; + } + + if (response.methodResponses?.[0]?.[0]?.endsWith('/error')) { + const error = response.methodResponses[0][1]; + return { isValid: false, errors: [error.description || "Validation failed"] }; + } + + return { isValid: false, errors: ['Unexpected validation response'] }; + } + getContactsAccountId(): string { const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"]; return contactsAccount || this.accountId; @@ -2185,6 +2407,14 @@ export class JMAPClient { ); } + if (this.supportsSieve()) { + using.push('urn:ietf:params:jmap:sieve'); + const sieveAccountId = this.getSieveAccountId(); + methodCalls.push( + ['SieveScript/get', { accountId: sieveAccountId, ids: [], properties: ['id'] }, 'e'], + ); + } + const response = await fetch(this.apiUrl, { method: 'POST', headers: { @@ -2209,6 +2439,9 @@ export class JMAPClient { if (method === 'CalendarEvent/get' && result.state) { this.pollingStates['CalendarEvent'] = result.state; } + if (method === 'SieveScript/get' && result.state) { + this.pollingStates['SieveScript'] = result.state; + } } } } catch { @@ -2233,6 +2466,14 @@ export class JMAPClient { ); } + if (this.supportsSieve()) { + using.push('urn:ietf:params:jmap:sieve'); + const sieveAccountId = this.getSieveAccountId(); + methodCalls.push( + ['SieveScript/get', { accountId: sieveAccountId, ids: [], properties: ['id'] }, 'e'], + ); + } + const response = await fetch(this.apiUrl, { method: 'POST', headers: { @@ -2253,6 +2494,7 @@ export class JMAPClient { 'Email/get': 'Email', 'Calendar/get': 'Calendar', 'CalendarEvent/get': 'CalendarEvent', + 'SieveScript/get': 'SieveScript', }; const stateKey = typeMap[method]; if (stateKey && result.state) { diff --git a/lib/jmap/sieve-types.ts b/lib/jmap/sieve-types.ts new file mode 100644 index 00000000..cfa577ec --- /dev/null +++ b/lib/jmap/sieve-types.ts @@ -0,0 +1,55 @@ +export interface SieveScript { + id: string; + name: string; + blobId: string; + isActive: boolean; +} + +export interface SieveCapabilities { + implementation: string; + maxSizeScript: number; + sieveExtensions: string[]; + notificationMethods: string[]; + externalLists: string[]; +} + +export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'; + +export type FilterComparator = + | 'contains' | 'not_contains' + | 'is' | 'not_is' + | 'starts_with' | 'ends_with' + | 'matches' + | 'greater_than' | 'less_than'; + +export type FilterActionType = + | 'move' | 'copy' | 'forward' + | 'mark_read' | 'star' | 'add_label' + | 'discard' | 'reject' | 'keep' | 'stop'; + +export interface FilterCondition { + field: FilterConditionField; + comparator: FilterComparator; + value: string; + headerName?: string; +} + +export interface FilterAction { + type: FilterActionType; + value?: string; +} + +export interface FilterRule { + id: string; + name: string; + enabled: boolean; + matchType: 'all' | 'any'; + conditions: FilterCondition[]; + actions: FilterAction[]; + stopProcessing: boolean; +} + +export interface FilterMetadata { + version: 1; + rules: FilterRule[]; +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index c0e7b47a..7cf0908a 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -500,6 +500,7 @@ export interface StateChange { AddressBook?: string; Calendar?: string; CalendarEvent?: string; + SieveScript?: string; }; }; } diff --git a/lib/sieve/__tests__/generator.test.ts b/lib/sieve/__tests__/generator.test.ts new file mode 100644 index 00000000..140c7d93 --- /dev/null +++ b/lib/sieve/__tests__/generator.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect } from 'vitest'; +import { generateScript } from '../generator'; +import { parseScript } from '../parser'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; + +function makeRule(overrides: Partial = {}): FilterRule { + return { + id: 'rule-1', + name: 'Test Rule', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }], + actions: [{ type: 'move', value: 'Archive' }], + stopProcessing: false, + ...overrides, + }; +} + +describe('generateScript', () => { + it('outputs metadata and no require for empty rules', () => { + const script = generateScript([]); + expect(script).toContain('/* @metadata:begin'); + expect(script).toContain('@metadata:end */'); + expect(script).not.toContain('require'); + }); + + it('embeds compact metadata JSON', () => { + const rules = [makeRule()]; + const script = generateScript(rules); + const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/); + expect(match).not.toBeNull(); + const metadata = JSON.parse(match![1]); + expect(metadata.version).toBe(1); + expect(metadata.rules).toHaveLength(1); + expect(metadata.rules[0].id).toBe('rule-1'); + }); + + it('generates single rule with from/contains', () => { + const script = generateScript([makeRule()]); + expect(script).toContain('# Rule: Test Rule'); + expect(script).toContain('if header :contains "From" "test@example.com"'); + expect(script).toContain('fileinto "Archive";'); + }); + + describe('condition fields', () => { + it('maps to field to "To" header', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'to', comparator: 'contains', value: 'me@x.com' }], + })]); + expect(script).toContain('header :contains "To" "me@x.com"'); + }); + + it('maps cc field to "Cc" header', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'cc', comparator: 'is', value: 'cc@x.com' }], + })]); + expect(script).toContain('header :is "Cc" "cc@x.com"'); + }); + + it('maps subject field to "Subject" header', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'contains', value: 'hello' }], + })]); + expect(script).toContain('header :contains "Subject" "hello"'); + }); + + it('maps header field with custom headerName', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-Custom' }], + })]); + expect(script).toContain('header :contains "X-Custom" "test"'); + }); + + it('handles size greater_than', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }], + })]); + expect(script).toContain('size :over 1000000'); + }); + + it('handles size less_than', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], + })]); + expect(script).toContain('size :under 500'); + }); + + it('handles body contains', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'body', comparator: 'contains', value: 'keyword' }], + })]); + expect(script).toContain('body :contains "keyword"'); + expect(script).toContain('"body"'); + }); + + it('handles body is', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'body', comparator: 'is', value: 'exact' }], + })]); + expect(script).toContain('body :is "exact"'); + }); + }); + + describe('comparators', () => { + it('generates not_contains with not wrapper', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'from', comparator: 'not_contains', value: 'spam' }], + })]); + expect(script).toContain('not header :contains "From" "spam"'); + }); + + it('generates not_is with not wrapper', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'from', comparator: 'not_is', value: 'bad@x.com' }], + })]); + expect(script).toContain('not header :is "From" "bad@x.com"'); + }); + + it('generates starts_with as :matches with trailing *', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Re:' }], + })]); + expect(script).toContain('header :matches "Subject" "Re:*"'); + }); + + it('generates ends_with as :matches with leading *', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'ends_with', value: 'urgent' }], + })]); + expect(script).toContain('header :matches "Subject" "*urgent"'); + }); + + it('generates matches as :matches', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'from', comparator: 'matches', value: '*@company.com' }], + })]); + expect(script).toContain('header :matches "From" "*@company.com"'); + }); + }); + + describe('match types', () => { + it('wraps multiple conditions with allof for matchType all', () => { + const script = generateScript([makeRule({ + matchType: 'all', + conditions: [ + { field: 'from', comparator: 'contains', value: 'a' }, + { field: 'subject', comparator: 'contains', value: 'b' }, + ], + })]); + expect(script).toContain('allof(header :contains "From" "a", header :contains "Subject" "b")'); + }); + + it('wraps multiple conditions with anyof for matchType any', () => { + const script = generateScript([makeRule({ + matchType: 'any', + conditions: [ + { field: 'from', comparator: 'contains', value: 'x' }, + { field: 'to', comparator: 'contains', value: 'y' }, + ], + })]); + expect(script).toContain('anyof(header :contains "From" "x", header :contains "To" "y")'); + }); + + it('uses no wrapper for single condition', () => { + const script = generateScript([makeRule()]); + expect(script).not.toContain('allof'); + expect(script).not.toContain('anyof'); + }); + }); + + describe('actions', () => { + it('generates move as fileinto', () => { + const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'Spam' }] })]); + expect(script).toContain('fileinto "Spam";'); + }); + + it('generates copy as fileinto :copy', () => { + const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'Backup' }] })]); + expect(script).toContain('fileinto :copy "Backup";'); + }); + + it('generates forward as redirect', () => { + const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'fwd@x.com' }] })]); + expect(script).toContain('redirect "fwd@x.com";'); + }); + + it('generates mark_read as addflag \\Seen', () => { + const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]); + expect(script).toContain('addflag "\\\\Seen";'); + }); + + it('generates star as addflag \\Flagged', () => { + const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]); + expect(script).toContain('addflag "\\\\Flagged";'); + }); + + it('generates add_label as addflag $Label', () => { + const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]); + expect(script).toContain('addflag "$Important";'); + }); + + it('generates discard', () => { + const script = generateScript([makeRule({ actions: [{ type: 'discard' }] })]); + expect(script).toContain('discard;'); + }); + + it('generates reject with message', () => { + const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'Go away' }] })]); + expect(script).toContain('reject "Go away";'); + }); + + it('generates keep', () => { + const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]); + expect(script).toContain('keep;'); + }); + + it('generates stop', () => { + const script = generateScript([makeRule({ actions: [{ type: 'stop' }] })]); + expect(script).toContain('stop;'); + }); + }); + + describe('stopProcessing', () => { + it('appends stop when stopProcessing is true', () => { + const script = generateScript([makeRule({ stopProcessing: true })]); + const ifBlock = script.slice(script.indexOf('if ')); + expect(ifBlock).toContain('stop;'); + }); + + it('does not duplicate stop if last action is stop', () => { + const script = generateScript([makeRule({ + actions: [{ type: 'move', value: 'X' }, { type: 'stop' }], + stopProcessing: true, + })]); + const matches = script.match(/stop;/g); + expect(matches).toHaveLength(1); + }); + + it('does not append stop after discard', () => { + const script = generateScript([makeRule({ + actions: [{ type: 'discard' }], + stopProcessing: true, + })]); + const matches = script.match(/stop;/g); + expect(matches).toBeNull(); + }); + + it('does not append stop after reject', () => { + const script = generateScript([makeRule({ + actions: [{ type: 'reject', value: 'No' }], + stopProcessing: true, + })]); + const matches = script.match(/stop;/g); + expect(matches).toBeNull(); + }); + }); + + describe('disabled rules', () => { + it('excludes disabled rules from Sieve code', () => { + const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]); + expect(script).not.toContain('# Rule: Hidden'); + expect(script).not.toContain('if header'); + }); + + it('preserves disabled rules in metadata', () => { + const rules = [makeRule({ enabled: false })]; + const script = generateScript(rules); + const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/); + const metadata = JSON.parse(match![1]); + expect(metadata.rules[0].enabled).toBe(false); + }); + + it('handles mixed enabled and disabled rules', () => { + const rules = [ + makeRule({ id: '1', name: 'Active', enabled: true }), + makeRule({ id: '2', name: 'Inactive', enabled: false }), + makeRule({ id: '3', name: 'Also Active', enabled: true }), + ]; + const script = generateScript(rules); + expect(script).toContain('# Rule: Active'); + expect(script).not.toContain('# Rule: Inactive'); + expect(script).toContain('# Rule: Also Active'); + }); + }); + + describe('require extensions', () => { + it('includes fileinto for move', () => { + const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'X' }] })]); + expect(script).toContain('"fileinto"'); + }); + + it('includes fileinto and copy for copy action', () => { + const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'X' }] })]); + expect(script).toContain('"copy"'); + expect(script).toContain('"fileinto"'); + }); + + it('includes imap4flags for mark_read', () => { + const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]); + expect(script).toContain('"imap4flags"'); + }); + + it('includes imap4flags for star', () => { + const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]); + expect(script).toContain('"imap4flags"'); + }); + + it('includes imap4flags for add_label', () => { + const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'X' }] })]); + expect(script).toContain('"imap4flags"'); + }); + + it('includes reject for reject action', () => { + const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'No' }] })]); + expect(script).toContain('"reject"'); + }); + + it('includes body extension for body conditions', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'body', comparator: 'contains', value: 'test' }], + })]); + expect(script).toContain('"body"'); + }); + + it('deduplicates extensions', () => { + const script = generateScript([ + makeRule({ id: '1', actions: [{ type: 'star' }] }), + makeRule({ id: '2', actions: [{ type: 'mark_read' }] }), + ]); + const matches = script.match(/"imap4flags"/g); + expect(matches).toHaveLength(1); + }); + + it('only considers enabled rules for requires', () => { + const script = generateScript([ + makeRule({ id: '1', enabled: false, actions: [{ type: 'reject', value: 'X' }] }), + makeRule({ id: '2', enabled: true, actions: [{ type: 'move', value: 'Y' }] }), + ]); + const requireLine = script.split('\n').find(l => l.startsWith('require')); + expect(requireLine).not.toContain('reject'); + expect(requireLine).toContain('fileinto'); + }); + }); + + describe('escaping', () => { + it('escapes double quotes in values', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello"' }], + })]); + expect(script).toContain('say \\"hello\\"'); + }); + + it('escapes backslashes in values', () => { + const script = generateScript([makeRule({ + conditions: [{ field: 'subject', comparator: 'contains', value: 'path\\to\\file' }], + })]); + expect(script).toContain('path\\\\to\\\\file'); + }); + + it('escapes folder names in actions', () => { + const script = generateScript([makeRule({ + actions: [{ type: 'move', value: 'My "Folder"' }], + })]); + expect(script).toContain('fileinto "My \\"Folder\\"";'); + }); + }); + + describe('round-trip', () => { + it('preserves rules through generate → parse cycle', () => { + const rules: FilterRule[] = [ + makeRule({ id: '1', name: 'Rule A', enabled: true }), + makeRule({ id: '2', name: 'Rule B', enabled: false }), + makeRule({ + id: '3', + name: 'Complex', + matchType: 'any', + conditions: [ + { field: 'from', comparator: 'contains', value: 'boss' }, + { field: 'subject', comparator: 'starts_with', value: 'URGENT' }, + ], + actions: [{ type: 'star' }, { type: 'mark_read' }], + stopProcessing: true, + }), + ]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toEqual(rules); + }); + }); + + it('generates multiple rules in order', () => { + const rules = [ + makeRule({ id: '1', name: 'First', actions: [{ type: 'move', value: 'A' }] }), + makeRule({ id: '2', name: 'Second', actions: [{ type: 'move', value: 'B' }] }), + ]; + const script = generateScript(rules); + const firstIdx = script.indexOf('# Rule: First'); + const secondIdx = script.indexOf('# Rule: Second'); + expect(firstIdx).toBeLessThan(secondIdx); + }); +}); diff --git a/lib/sieve/__tests__/parser.test.ts b/lib/sieve/__tests__/parser.test.ts new file mode 100644 index 00000000..2511146a --- /dev/null +++ b/lib/sieve/__tests__/parser.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { parseScript } from '../parser'; +import { generateScript } from '../generator'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; + +function makeRule(overrides: Partial = {}): FilterRule { + return { + id: 'rule-1', + name: 'Test Rule', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }], + actions: [{ type: 'move', value: 'Archive' }], + stopProcessing: false, + ...overrides, + }; +} + +describe('parseScript', () => { + it('extracts rules from valid metadata', () => { + const rules = [makeRule()]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toEqual(rules); + }); + + it('returns isOpaque for missing metadata', () => { + const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }'); + expect(result.isOpaque).toBe(true); + expect(result.rules).toEqual([]); + }); + + it('returns isOpaque for corrupted JSON', () => { + const script = '/* @metadata:begin\n{not valid json\n@metadata:end */'; + const result = parseScript(script); + expect(result.isOpaque).toBe(true); + expect(result.rules).toEqual([]); + }); + + it('returns isOpaque for version mismatch', () => { + const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */'; + const result = parseScript(script); + expect(result.isOpaque).toBe(true); + expect(result.rules).toEqual([]); + }); + + it('returns isOpaque for empty metadata block', () => { + const script = '/* @metadata:begin\n\n@metadata:end */'; + const result = parseScript(script); + expect(result.isOpaque).toBe(true); + expect(result.rules).toEqual([]); + }); + + it('returns isOpaque for missing rules array', () => { + const script = '/* @metadata:begin\n{"version":1}\n@metadata:end */'; + const result = parseScript(script); + expect(result.isOpaque).toBe(true); + expect(result.rules).toEqual([]); + }); + + it('returns isOpaque for invalid rule objects', () => { + const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x"}]}\n@metadata:end */'; + const result = parseScript(script); + expect(result.isOpaque).toBe(true); + expect(result.rules).toEqual([]); + }); + + it('handles metadata with extra whitespace', () => { + const rules = [makeRule()]; + const json = JSON.stringify({ version: 1, rules }); + const script = `/* @metadata:begin\n ${json} \n@metadata:end */\n\nrequire ["fileinto"];`; + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toEqual(rules); + }); + + it('handles script with only metadata block', () => { + const rules = [makeRule({ enabled: false })]; + const json = JSON.stringify({ version: 1, rules }); + const script = `/* @metadata:begin\n${json}\n@metadata:end */`; + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toEqual(rules); + }); + + it('returns isOpaque for missing end marker', () => { + const script = '/* @metadata:begin\n{"version":1,"rules":[]}'; + const result = parseScript(script); + expect(result.isOpaque).toBe(true); + }); + + it('returns isOpaque for empty string', () => { + const result = parseScript(''); + expect(result.isOpaque).toBe(true); + }); + + describe('round-trip', () => { + it('preserves complex rules through generate → parse', () => { + const rules: FilterRule[] = [ + makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }), + makeRule({ + id: '2', + name: 'VIP', + matchType: 'any', + conditions: [ + { field: 'from', comparator: 'is', value: 'boss@company.com' }, + { field: 'from', comparator: 'is', value: 'ceo@company.com' }, + ], + actions: [{ type: 'star' }, { type: 'mark_read' }], + }), + makeRule({ id: '3', name: 'Disabled', enabled: false }), + ]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.isOpaque).toBe(false); + expect(result.rules).toEqual(rules); + }); + + it('preserves rules with special characters', () => { + const rules = [makeRule({ + conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello" \\ world' }], + actions: [{ type: 'move', value: 'My "Folder"' }], + })]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.rules).toEqual(rules); + }); + + it('preserves all action types', () => { + const rules = [makeRule({ + actions: [ + { type: 'move', value: 'Folder' }, + { type: 'copy', value: 'Backup' }, + { type: 'forward', value: 'fwd@x.com' }, + { type: 'mark_read' }, + { type: 'star' }, + { type: 'add_label', value: 'Tag' }, + { type: 'keep' }, + ], + })]; + const script = generateScript(rules); + const result = parseScript(script); + expect(result.rules).toEqual(rules); + }); + }); +}); diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts new file mode 100644 index 00000000..3ee02a1d --- /dev/null +++ b/lib/sieve/generator.ts @@ -0,0 +1,169 @@ +import type { FilterRule, FilterCondition, FilterAction, FilterMetadata } from '@/lib/jmap/sieve-types'; +import { debug } from '@/lib/debug'; + +const HEADER_MAP: Record = { + from: 'From', + to: 'To', + cc: 'Cc', + subject: 'Subject', +}; + +function escapeString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function generateCondition(condition: FilterCondition): string { + const { field, comparator, value } = condition; + + if (field === 'size') { + const op = comparator === 'greater_than' ? ':over' : ':under'; + return `size ${op} ${value}`; + } + + if (field === 'body') { + const matchType = comparator === 'is' ? ':is' : ':contains'; + return `body ${matchType} "${escapeString(value)}"`; + } + + const headerName = field === 'header' + ? (condition.headerName || 'X-Unknown') + : HEADER_MAP[field]; + + const escaped = escapeString(value); + + switch (comparator) { + case 'contains': + return `header :contains "${headerName}" "${escaped}"`; + case 'not_contains': + return `not header :contains "${headerName}" "${escaped}"`; + case 'is': + return `header :is "${headerName}" "${escaped}"`; + case 'not_is': + return `not header :is "${headerName}" "${escaped}"`; + case 'starts_with': + return `header :matches "${headerName}" "${escaped}*"`; + case 'ends_with': + return `header :matches "${headerName}" "*${escaped}"`; + case 'matches': + return `header :matches "${headerName}" "${escaped}"`; + default: + return `header :contains "${headerName}" "${escaped}"`; + } +} + +function generateActions(actions: FilterAction[]): string[] { + return actions.map(action => { + switch (action.type) { + case 'move': + return `fileinto "${escapeString(action.value || '')}";`; + case 'copy': + return `fileinto :copy "${escapeString(action.value || '')}";`; + case 'forward': + return `redirect "${escapeString(action.value || '')}";`; + case 'mark_read': + return 'addflag "\\\\Seen";'; + case 'star': + return 'addflag "\\\\Flagged";'; + case 'add_label': + return `addflag "$${escapeString(action.value || '')}";`; + case 'discard': + return 'discard;'; + case 'reject': + return `reject "${escapeString(action.value || '')}";`; + case 'keep': + return 'keep;'; + case 'stop': + return 'stop;'; + } + }); +} + +function computeRequires(rules: FilterRule[]): string[] { + const extensions = new Set(); + const enabledRules = rules.filter(r => r.enabled); + + for (const rule of enabledRules) { + for (const condition of rule.conditions) { + if (condition.field === 'body') extensions.add('body'); + } + for (const action of rule.actions) { + switch (action.type) { + case 'move': + extensions.add('fileinto'); + break; + case 'copy': + extensions.add('fileinto'); + extensions.add('copy'); + break; + case 'mark_read': + case 'star': + case 'add_label': + extensions.add('imap4flags'); + break; + case 'reject': + extensions.add('reject'); + break; + } + } + } + + return [...extensions].sort(); +} + +export function generateScript(rules: FilterRule[]): string { + const metadata: FilterMetadata = { version: 1, rules }; + const metadataJson = JSON.stringify(metadata); + const lines: string[] = []; + + lines.push('/* @metadata:begin'); + lines.push(metadataJson); + lines.push('@metadata:end */'); + lines.push(''); + + const requires = computeRequires(rules); + if (requires.length > 0) { + lines.push(`require [${requires.map(r => `"${r}"`).join(', ')}];`); + } + + const enabledRules = rules.filter(r => r.enabled); + + for (const rule of enabledRules) { + if (rule.conditions.length === 0 || rule.actions.length === 0) { + debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`); + continue; + } + + lines.push(''); + lines.push(`# Rule: ${rule.name}`); + + const conditions = rule.conditions.map(generateCondition); + let conditionStr: string; + + if (conditions.length === 0) { + conditionStr = 'true'; + } else if (conditions.length === 1) { + conditionStr = conditions[0]; + } else { + const wrapper = rule.matchType === 'all' ? 'allof' : 'anyof'; + conditionStr = `${wrapper}(${conditions.join(', ')})`; + } + + const actionLines = generateActions(rule.actions); + + if (rule.stopProcessing) { + const lastAction = rule.actions[rule.actions.length - 1]; + if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) { + actionLines.push('stop;'); + } + } + + lines.push(`if ${conditionStr} {`); + for (const actionLine of actionLines) { + lines.push(` ${actionLine}`); + } + lines.push('}'); + } + + lines.push(''); + return lines.join('\n'); +} diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts new file mode 100644 index 00000000..e08d18d4 --- /dev/null +++ b/lib/sieve/parser.ts @@ -0,0 +1,68 @@ +import type { FilterRule, FilterMetadata } from '@/lib/jmap/sieve-types'; +import { debug } from '@/lib/debug'; + +export interface ParseResult { + rules: FilterRule[]; + isOpaque: boolean; +} + +const OPAQUE: ParseResult = { rules: [], isOpaque: true }; + +const METADATA_BEGIN = '/* @metadata:begin'; +const METADATA_END = '@metadata:end */'; + +function isValidCondition(c: unknown): boolean { + if (!c || typeof c !== 'object') return false; + const cond = c as Record; + return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string'; +} + +function isValidAction(a: unknown): boolean { + if (!a || typeof a !== 'object') return false; + const act = a as Record; + return typeof act.type === 'string'; +} + +function isValidRule(rule: unknown): rule is FilterRule { + if (!rule || typeof rule !== 'object') return false; + const r = rule as Record; + if ( + typeof r.id !== 'string' || + typeof r.name !== 'string' || + typeof r.enabled !== 'boolean' || + (r.matchType !== 'all' && r.matchType !== 'any') || + !Array.isArray(r.conditions) || + !Array.isArray(r.actions) || + typeof r.stopProcessing !== 'boolean' + ) return false; + + return r.conditions.every(isValidCondition) && r.actions.every(isValidAction); +} + +export function parseScript(content: string): ParseResult { + const beginIdx = content.indexOf(METADATA_BEGIN); + if (beginIdx === -1) return OPAQUE; + + const endIdx = content.indexOf(METADATA_END, beginIdx); + if (endIdx === -1) return OPAQUE; + + const jsonStart = beginIdx + METADATA_BEGIN.length; + const jsonStr = content.slice(jsonStart, endIdx).trim(); + + let metadata: FilterMetadata; + try { + metadata = JSON.parse(jsonStr); + } catch (e) { + debug.warn('Failed to parse Sieve metadata JSON:', e); + return OPAQUE; + } + + if (!metadata || metadata.version !== 1) return OPAQUE; + if (!Array.isArray(metadata.rules)) return OPAQUE; + + for (const rule of metadata.rules) { + if (!isValidRule(rule)) return OPAQUE; + } + + return { rules: metadata.rules, isOpaque: false }; +} diff --git a/locales/de/common.json b/locales/de/common.json index 73ede7fc..57b7407d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -74,8 +74,8 @@ "loading": "E-Mails werden geladen...", "unread": "ungelesen", "to_me": "An mich", - "to_recipients": "An {{count}} Empfänger", - "and_others": "und {{count}} weitere", + "to_recipients": "An {count} Empfänger", + "and_others": "und {count} weitere", "draft": "Entwurf", "starred": "Mit Stern", "conversations_count": "{count} von {total} Unterhaltungen", @@ -264,12 +264,12 @@ "no_subject": "(Kein Betreff)", "unknown_sender": "Unbekannt", "quote": { - "reply_header": "Am {{date}} schrieb {{sender}}:", + "reply_header": "Am {date} schrieb {sender}:", "forward_header": "---------- Weitergeleitete Nachricht ----------", - "from": "Von: {{sender}}", - "date": "Datum: {{date}}", - "subject": "Betreff: {{subject}}", - "to": "An: {{recipients}}" + "from": "Von: {sender}", + "date": "Datum: {date}", + "subject": "Betreff: {subject}", + "to": "An: {recipients}" }, "remove_sub_address": "Sub-Adresse entfernen" }, @@ -316,13 +316,16 @@ "identity_created": "Identität erfolgreich erstellt", "identity_updated": "Identität erfolgreich aktualisiert", "identity_deleted": "Identität gelöscht", - "identity_create_failed": "Identität erstellen fehlgeschlagen: {{error}}", - "identity_update_failed": "Identität aktualisieren fehlgeschlagen: {{error}}", - "identity_delete_failed": "Identität löschen fehlgeschlagen: {{error}}", + "identity_create_failed": "Identität erstellen fehlgeschlagen: {error}", + "identity_update_failed": "Identität aktualisieren fehlgeschlagen: {error}", + "identity_delete_failed": "Identität löschen fehlgeschlagen: {error}", "identity_unauthorized": "Sie sind nicht autorisiert, von dieser E-Mail-Adresse zu senden", "identity_not_found": "Identität nicht gefunden", "vacation_saved": "Abwesenheitsnotiz-Einstellungen gespeichert", - "vacation_save_failed": "Fehler beim Speichern der Abwesenheitsnotiz-Einstellungen" + "vacation_save_failed": "Fehler beim Speichern der Abwesenheitsnotiz-Einstellungen", + "filters_saved": "Filter erfolgreich gespeichert", + "filters_save_failed": "Fehler beim Speichern der Filter", + "filters_deleted": "Filterregel gelöscht" }, "date": { "today": "Heute", @@ -332,12 +335,12 @@ "this_month": "Dieser Monat", "older": "Älter", "just_now": "Gerade eben", - "minutes_ago": "Vor {{count}} Minute", - "minutes_ago_plural": "Vor {{count}} Minuten", - "hours_ago": "Vor {{count}} Stunde", - "hours_ago_plural": "Vor {{count}} Stunden", - "days_ago": "Vor {{count}} Tag", - "days_ago_plural": "Vor {{count}} Tagen" + "minutes_ago": "Vor {count} Minute", + "minutes_ago_plural": "Vor {count} Minuten", + "hours_ago": "Vor {count} Stunde", + "hours_ago_plural": "Vor {count} Stunden", + "days_ago": "Vor {count} Tag", + "days_ago_plural": "Vor {count} Tagen" }, "language": { "title": "Sprache", @@ -377,7 +380,8 @@ "identities": "Identitäten", "vacation": "Abwesenheitsnotiz", "advanced": "Erweitert", - "calendar": "Kalender" + "calendar": "Kalender", + "filters": "Filter" }, "appearance": { "title": "Darstellung", @@ -546,20 +550,20 @@ "description": "Zeigen Sie Ihre Kontoinformationen an", "email": { "label": "E-Mail-Adresse", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "JMAP-Server", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "Speichernutzung", - "used": "{{used}} von {{total}} verwendet", - "percentage": "{{percent}}% verwendet" + "used": "{used} von {total} verwendet", + "percentage": "{percent}% verwendet" }, "last_sync": { "label": "Letzte Synchronisierung", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "Für das Senden konfigurierte E-Mail-Adressen", "count_zero": "Keine Identitäten", "count_one": "1 Identität", - "count_other": "{{count}} Identitäten" + "count_other": "{count} Identitäten" }, "manage": "Identitäten verwalten", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "Einstellungen aus JSON-Datei hochladen", "button": "Importieren" } + }, + "filters": { + "title": "E-Mail-Filter", + "description": "Erstellen Sie Regeln, um eingehende E-Mails automatisch zu sortieren, zu kennzeichnen und zu verwalten", + "add_rule": "Regel hinzufügen", + "no_rules": "Keine Filterregeln", + "no_rules_description": "Erstellen Sie Regeln, um Ihre eingehenden E-Mails automatisch zu organisieren", + "edit_rule": "Regel bearbeiten", + "new_rule": "Neue Regel", + "delete_rule": "Regel löschen", + "delete_confirm": "Sind Sie sicher, dass Sie diese Regel löschen möchten?", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "raw_editor": "Sieve-Skripteditor", + "raw_editor_warning": "Das Bearbeiten des Sieve-Skripts kann die visuelle Regelbearbeitung beeinträchtigen. Änderungen hier überschreiben den visuellen Builder.", + "validate": "Validieren", + "validation_success": "Das Skript ist gültig", + "validation_error": "Das Skript enthält Fehler", + "save": "Regeln speichern", + "saving": "Wird gespeichert...", + "saved": "Filter erfolgreich gespeichert", + "save_failed": "Fehler beim Speichern der Filter", + "loading": "Filter werden geladen...", + "not_supported": "Ihr Mailserver unterstützt keine E-Mail-Filter.", + "rule_name": "Regelname", + "rule_name_placeholder": "z.B. Newsletter sortieren", + "match_all": "ALLE Bedingungen erfüllen", + "match_any": "EINE beliebige Bedingung erfüllen", + "conditions": "Bedingungen", + "add_condition": "Bedingung hinzufügen", + "actions": "Aktionen", + "add_action": "Aktion hinzufügen", + "stop_processing": "Verarbeitung nachfolgender Regeln stoppen", + "condition_fields": { + "from": "Von", + "to": "An", + "cc": "Cc", + "subject": "Betreff", + "header": "Benutzerdefinierter Header", + "size": "Größe", + "body": "Nachrichtentext" + }, + "comparators": { + "contains": "enthält", + "not_contains": "enthält nicht", + "is": "ist genau", + "not_is": "ist nicht", + "starts_with": "beginnt mit", + "ends_with": "endet mit", + "matches": "entspricht dem Muster", + "greater_than": "ist größer als", + "less_than": "ist kleiner als" + }, + "action_types": { + "move": "In Ordner verschieben", + "copy": "In Ordner kopieren", + "forward": "Weiterleiten an", + "mark_read": "Als gelesen markieren", + "star": "Nachricht markieren", + "add_label": "Label hinzufügen", + "discard": "Lautlos löschen", + "reject": "Mit Nachricht ablehnen", + "keep": "Im Posteingang behalten", + "stop": "Verarbeitung stoppen" + }, + "move_to_folder": "Ordner auswählen", + "copy_to_folder": "Ordner auswählen", + "forward_to": "An E-Mail-Adresse weiterleiten", + "forward_placeholder": "email@beispiel.de", + "reject_message": "Ablehnungsnachricht", + "reject_placeholder": "Ihre E-Mail wurde abgelehnt", + "label_name": "Label-Name", + "label_placeholder": "z.B. wichtig", + "header_name": "Header-Name", + "header_placeholder": "z.B. X-Mailing-List", + "size_bytes": "Größe in Bytes", + "size_placeholder": "z.B. 1000000", + "system_managed": "Systemverwaltete Regel", + "opaque_warning": "Dieses Skript wurde außerhalb des visuellen Builders bearbeitet. Nur die Sieve-Skriptbearbeitung ist verfügbar.", + "open_sieve_editor": "Sieve-Skript-Editor öffnen", + "fetch_error": "Filter konnten nicht geladen werden", + "and": "und", + "or": "oder", + "cancel": "Abbrechen", + "confirm_delete": "Löschen", + "rule_list": "Filterregeln", + "drag_to_reorder": "Ziehen zum Sortieren", + "match_type": "Übereinstimmungstyp", + "reset_to_visual": "Zum visuellen Builder zurücksetzen", + "reset_warning": "Dies verwirft das aktuelle Skript und beginnt von vorne.", + "confirm_reset": "Zurücksetzen", + "validation_empty_name": "Regelname ist erforderlich", + "validation_empty_conditions": "Mindestens eine Bedingung mit einem Wert ist erforderlich", + "validation_empty_actions": "Mindestens eine Aktion ist erforderlich", + "sieve_editor": { + "title": "Sieve-Skript-Editor", + "warning": "Das Bearbeiten des Sieve-Skripts kann die visuelle Regelbearbeitung beeinträchtigen. Änderungen hier überschreiben den visuellen Builder.", + "script_content": "Sieve-Skript", + "valid": "Skript ist gültig", + "invalid": "Skript enthält Fehler", + "save_warning": "Das Speichern überschreibt alle visuellen Regeln. Dies kann nicht rückgängig gemacht werden. Klicken Sie erneut auf Speichern zur Bestätigung.", + "validating": "Wird validiert...", + "validate": "Validieren", + "cancel": "Abbrechen", + "save": "Speichern", + "confirm_save": "Speichern bestätigen", + "validation_failed": "Validierungsanfrage fehlgeschlagen" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}", + "actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "Kein Spam", "color_tag": "Farb-Tag", "remove_color": "Farbe entfernen", - "items_selected": "{{count}} E-Mails ausgewählt" + "items_selected": "{count} E-Mails ausgewählt" }, "shortcuts": { "title": "Tastaturkürzel", diff --git a/locales/en/common.json b/locales/en/common.json index cb5abaf2..18b63043 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -74,8 +74,8 @@ "loading": "Loading emails...", "unread": "unread", "to_me": "To me", - "to_recipients": "To {{count}} recipients", - "and_others": "and {{count}} others", + "to_recipients": "To {count} recipients", + "and_others": "and {count} others", "draft": "Draft", "starred": "Starred", "conversations_count": "{count} of {total} conversations", @@ -264,12 +264,12 @@ "no_subject": "(No Subject)", "unknown_sender": "Unknown", "quote": { - "reply_header": "On {{date}}, {{sender}} wrote:", + "reply_header": "On {date}, {sender} wrote:", "forward_header": "---------- Forwarded message ----------", - "from": "From: {{sender}}", - "date": "Date: {{date}}", - "subject": "Subject: {{subject}}", - "to": "To: {{recipients}}" + "from": "From: {sender}", + "date": "Date: {date}", + "subject": "Subject: {subject}", + "to": "To: {recipients}" }, "remove_sub_address": "Remove sub-address" }, @@ -316,13 +316,16 @@ "identity_created": "Identity created successfully", "identity_updated": "Identity updated successfully", "identity_deleted": "Identity deleted", - "identity_create_failed": "Failed to create identity: {{error}}", - "identity_update_failed": "Failed to update identity: {{error}}", - "identity_delete_failed": "Failed to delete identity: {{error}}", + "identity_create_failed": "Failed to create identity: {error}", + "identity_update_failed": "Failed to update identity: {error}", + "identity_delete_failed": "Failed to delete identity: {error}", "identity_unauthorized": "You are not authorized to send from this email address", "identity_not_found": "Identity not found", "vacation_saved": "Vacation responder settings saved", - "vacation_save_failed": "Failed to save vacation responder settings" + "vacation_save_failed": "Failed to save vacation responder settings", + "filters_saved": "Filters saved successfully", + "filters_save_failed": "Failed to save filters", + "filters_deleted": "Filter rule deleted" }, "date": { "today": "Today", @@ -332,12 +335,12 @@ "this_month": "This month", "older": "Older", "just_now": "Just now", - "minutes_ago": "{{count}} minute ago", - "minutes_ago_plural": "{{count}} minutes ago", - "hours_ago": "{{count}} hour ago", - "hours_ago_plural": "{{count}} hours ago", - "days_ago": "{{count}} day ago", - "days_ago_plural": "{{count}} days ago" + "minutes_ago": "{count} minute ago", + "minutes_ago_plural": "{count} minutes ago", + "hours_ago": "{count} hour ago", + "hours_ago_plural": "{count} hours ago", + "days_ago": "{count} day ago", + "days_ago_plural": "{count} days ago" }, "language": { "title": "Language", @@ -377,7 +380,8 @@ "identities": "Identities", "vacation": "Vacation Responder", "advanced": "Advanced", - "calendar": "Calendar" + "calendar": "Calendar", + "filters": "Filters" }, "appearance": { "title": "Appearance", @@ -546,20 +550,20 @@ "description": "View your account information", "email": { "label": "Email Address", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "JMAP Server", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "Storage Usage", - "used": "{{used}} of {{total}} used", - "percentage": "{{percent}}% used" + "used": "{used} of {total} used", + "percentage": "{percent}% used" }, "last_sync": { "label": "Last Sync", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "Email addresses configured for sending", "count_zero": "No identities", "count_one": "1 identity", - "count_other": "{{count}} identities" + "count_other": "{count} identities" }, "manage": "Manage Identities", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "Upload settings from JSON file", "button": "Import" } + }, + "filters": { + "title": "Email Filters", + "description": "Create rules to automatically sort, label, and manage incoming emails", + "add_rule": "Add Rule", + "no_rules": "No filter rules", + "no_rules_description": "Create rules to automatically organize your incoming emails", + "edit_rule": "Edit Rule", + "new_rule": "New Rule", + "delete_rule": "Delete Rule", + "delete_confirm": "Are you sure you want to delete this rule?", + "enable": "Enable", + "disable": "Disable", + "raw_editor": "Raw Sieve Editor", + "raw_editor_warning": "Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.", + "validate": "Validate", + "validation_success": "Script is valid", + "validation_error": "Script has errors", + "save": "Save Rules", + "saving": "Saving...", + "saved": "Filters saved successfully", + "save_failed": "Failed to save filters", + "loading": "Loading filters...", + "not_supported": "Your mail server does not support email filters.", + "rule_name": "Rule Name", + "rule_name_placeholder": "e.g., Sort newsletters", + "match_all": "Match ALL conditions", + "match_any": "Match ANY condition", + "conditions": "Conditions", + "add_condition": "Add Condition", + "actions": "Actions", + "add_action": "Add Action", + "stop_processing": "Stop processing subsequent rules", + "condition_fields": { + "from": "From", + "to": "To", + "cc": "Cc", + "subject": "Subject", + "header": "Custom Header", + "size": "Size", + "body": "Body" + }, + "comparators": { + "contains": "contains", + "not_contains": "does not contain", + "is": "is exactly", + "not_is": "is not", + "starts_with": "starts with", + "ends_with": "ends with", + "matches": "matches pattern", + "greater_than": "is greater than", + "less_than": "is less than" + }, + "action_types": { + "move": "Move to folder", + "copy": "Copy to folder", + "forward": "Forward to", + "mark_read": "Mark as read", + "star": "Star message", + "add_label": "Add label", + "discard": "Discard (delete silently)", + "reject": "Reject with message", + "keep": "Keep in inbox", + "stop": "Stop processing" + }, + "move_to_folder": "Select folder", + "copy_to_folder": "Select folder", + "forward_to": "Forward to email address", + "forward_placeholder": "email@example.com", + "reject_message": "Rejection message", + "reject_placeholder": "Your email has been rejected", + "label_name": "Label name", + "label_placeholder": "e.g., important", + "header_name": "Header name", + "header_placeholder": "e.g., X-Mailing-List", + "size_bytes": "Size in bytes", + "size_placeholder": "e.g., 1000000", + "system_managed": "System-managed rule", + "opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.", + "open_sieve_editor": "Open raw Sieve editor", + "fetch_error": "Failed to load filters", + "and": "and", + "or": "or", + "cancel": "Cancel", + "confirm_delete": "Delete", + "rule_list": "Filter rules", + "drag_to_reorder": "Drag to reorder", + "match_type": "Match type", + "reset_to_visual": "Reset to visual builder", + "reset_warning": "This will discard the current script and start fresh.", + "confirm_reset": "Reset", + "validation_empty_name": "Rule name is required", + "validation_empty_conditions": "At least one condition with a value is required", + "validation_empty_actions": "At least one action is required", + "sieve_editor": { + "title": "Sieve Script Editor", + "warning": "Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.", + "script_content": "Sieve script", + "valid": "Script is valid", + "invalid": "Script has errors", + "save_warning": "Saving will overwrite any visual rules. This cannot be undone. Click Save again to confirm.", + "validating": "Validating...", + "validate": "Validate", + "cancel": "Cancel", + "save": "Save", + "confirm_save": "Confirm Save", + "validation_failed": "Validation request failed" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# condition} other {# conditions}}", + "actions_count": "{count, plural, one {# action} other {# actions}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "Not spam", "color_tag": "Color Tag", "remove_color": "Remove Color", - "items_selected": "{{count}} emails selected" + "items_selected": "{count} emails selected" }, "shortcuts": { "title": "Keyboard Shortcuts", diff --git a/locales/es/common.json b/locales/es/common.json index 5eef978d..9988c20f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -74,8 +74,8 @@ "loading": "Cargando correos...", "unread": "no leído", "to_me": "Para mí", - "to_recipients": "Para {{count}} destinatarios", - "and_others": "y {{count}} más", + "to_recipients": "Para {count} destinatarios", + "and_others": "y {count} más", "draft": "Borrador", "starred": "Destacado", "conversations_count": "{count} de {total} conversaciones", @@ -264,12 +264,12 @@ "no_subject": "(Sin Asunto)", "unknown_sender": "Desconocido", "quote": { - "reply_header": "El {{date}}, {{sender}} escribió:", + "reply_header": "El {date}, {sender} escribió:", "forward_header": "---------- Mensaje reenviado ----------", - "from": "De: {{sender}}", - "date": "Fecha: {{date}}", - "subject": "Asunto: {{subject}}", - "to": "Para: {{recipients}}" + "from": "De: {sender}", + "date": "Fecha: {date}", + "subject": "Asunto: {subject}", + "to": "Para: {recipients}" }, "remove_sub_address": "Eliminar sub-dirección" }, @@ -316,13 +316,16 @@ "identity_created": "Identidad creada exitosamente", "identity_updated": "Identidad actualizada exitosamente", "identity_deleted": "Identidad eliminada", - "identity_create_failed": "Error al crear identidad: {{error}}", - "identity_update_failed": "Error al actualizar identidad: {{error}}", - "identity_delete_failed": "Error al eliminar identidad: {{error}}", + "identity_create_failed": "Error al crear identidad: {error}", + "identity_update_failed": "Error al actualizar identidad: {error}", + "identity_delete_failed": "Error al eliminar identidad: {error}", "identity_unauthorized": "No está autorizado para enviar desde esta dirección de correo", "identity_not_found": "Identidad no encontrada", "vacation_saved": "Configuración de respuesta automática guardada", - "vacation_save_failed": "Error al guardar la configuración de respuesta automática" + "vacation_save_failed": "Error al guardar la configuración de respuesta automática", + "filters_saved": "Filtros guardados correctamente", + "filters_save_failed": "Error al guardar los filtros", + "filters_deleted": "Regla de filtrado eliminada" }, "date": { "today": "Hoy", @@ -332,12 +335,12 @@ "this_month": "Este mes", "older": "Más antiguo", "just_now": "Justo ahora", - "minutes_ago": "hace {{count}} minuto", - "minutes_ago_plural": "hace {{count}} minutos", - "hours_ago": "hace {{count}} hora", - "hours_ago_plural": "hace {{count}} horas", - "days_ago": "hace {{count}} día", - "days_ago_plural": "hace {{count}} días" + "minutes_ago": "hace {count} minuto", + "minutes_ago_plural": "hace {count} minutos", + "hours_ago": "hace {count} hora", + "hours_ago_plural": "hace {count} horas", + "days_ago": "hace {count} día", + "days_ago_plural": "hace {count} días" }, "language": { "title": "Idioma", @@ -377,7 +380,8 @@ "identities": "Identidades", "vacation": "Respuesta automática", "advanced": "Avanzado", - "calendar": "Calendario" + "calendar": "Calendario", + "filters": "Filtros" }, "appearance": { "title": "Apariencia", @@ -546,20 +550,20 @@ "description": "Vea la información de su cuenta", "email": { "label": "Dirección de Correo", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "Servidor JMAP", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "Uso de Almacenamiento", - "used": "{{used}} de {{total}} usado", - "percentage": "{{percent}}% usado" + "used": "{used} de {total} usado", + "percentage": "{percent}% usado" }, "last_sync": { "label": "Última Sincronización", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "Direcciones de correo configuradas para enviar", "count_zero": "Sin identidades", "count_one": "1 identidad", - "count_other": "{{count}} identidades" + "count_other": "{count} identidades" }, "manage": "Administrar Identidades", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "Cargar configuración desde archivo JSON", "button": "Importar" } + }, + "filters": { + "title": "Filtros de correo", + "description": "Cree reglas para ordenar, etiquetar y gestionar automáticamente los correos entrantes", + "add_rule": "Agregar regla", + "no_rules": "Sin reglas de filtrado", + "no_rules_description": "Cree reglas para organizar automáticamente sus correos entrantes", + "edit_rule": "Editar regla", + "new_rule": "Nueva regla", + "delete_rule": "Eliminar regla", + "delete_confirm": "¿Está seguro de que desea eliminar esta regla?", + "enable": "Activar", + "disable": "Desactivar", + "raw_editor": "Editor Sieve sin formato", + "raw_editor_warning": "Editar el script Sieve sin formato puede romper la edición visual de reglas. Los cambios realizados aquí anulan el constructor visual.", + "validate": "Validar", + "validation_success": "El script es válido", + "validation_error": "El script tiene errores", + "save": "Guardar reglas", + "saving": "Guardando...", + "saved": "Filtros guardados correctamente", + "save_failed": "Error al guardar los filtros", + "loading": "Cargando filtros...", + "not_supported": "Su servidor de correo no admite filtros de correo electrónico.", + "rule_name": "Nombre de la regla", + "rule_name_placeholder": "ej. Ordenar boletines", + "match_all": "Coincidir con TODAS las condiciones", + "match_any": "Coincidir con CUALQUIER condición", + "conditions": "Condiciones", + "add_condition": "Agregar condición", + "actions": "Acciones", + "add_action": "Agregar acción", + "stop_processing": "Detener el procesamiento de reglas posteriores", + "condition_fields": { + "from": "De", + "to": "Para", + "cc": "Cc", + "subject": "Asunto", + "header": "Encabezado personalizado", + "size": "Tamaño", + "body": "Cuerpo" + }, + "comparators": { + "contains": "contiene", + "not_contains": "no contiene", + "is": "es exactamente", + "not_is": "no es", + "starts_with": "comienza con", + "ends_with": "termina con", + "matches": "coincide con el patrón", + "greater_than": "es mayor que", + "less_than": "es menor que" + }, + "action_types": { + "move": "Mover a carpeta", + "copy": "Copiar a carpeta", + "forward": "Reenviar a", + "mark_read": "Marcar como leído", + "star": "Destacar mensaje", + "add_label": "Agregar etiqueta", + "discard": "Descartar (eliminar silenciosamente)", + "reject": "Rechazar con mensaje", + "keep": "Mantener en la bandeja de entrada", + "stop": "Detener procesamiento" + }, + "move_to_folder": "Seleccionar carpeta", + "copy_to_folder": "Seleccionar carpeta", + "forward_to": "Reenviar a dirección de correo", + "forward_placeholder": "correo@ejemplo.com", + "reject_message": "Mensaje de rechazo", + "reject_placeholder": "Su correo ha sido rechazado", + "label_name": "Nombre de la etiqueta", + "label_placeholder": "ej. importante", + "header_name": "Nombre del encabezado", + "header_placeholder": "ej. X-Mailing-List", + "size_bytes": "Tamaño en bytes", + "size_placeholder": "ej. 1000000", + "system_managed": "Regla gestionada por el sistema", + "opaque_warning": "Este script fue editado fuera del constructor visual. Solo está disponible la edición Sieve sin formato.", + "open_sieve_editor": "Abrir editor Sieve", + "fetch_error": "Error al cargar los filtros", + "and": "y", + "or": "o", + "cancel": "Cancelar", + "confirm_delete": "Eliminar", + "rule_list": "Reglas de filtrado", + "drag_to_reorder": "Arrastrar para reordenar", + "match_type": "Tipo de coincidencia", + "reset_to_visual": "Restablecer al constructor visual", + "reset_warning": "Esto descartará el script actual y comenzará de nuevo.", + "confirm_reset": "Restablecer", + "validation_empty_name": "El nombre de la regla es obligatorio", + "validation_empty_conditions": "Se requiere al menos una condición con un valor", + "validation_empty_actions": "Se requiere al menos una acción", + "sieve_editor": { + "title": "Editor de script Sieve", + "warning": "Editar el script Sieve puede romper la edición visual de reglas. Los cambios realizados aquí anulan el constructor visual.", + "script_content": "Script Sieve", + "valid": "El script es válido", + "invalid": "El script tiene errores", + "save_warning": "Guardar sobrescribirá todas las reglas visuales. Esta acción no se puede deshacer. Haga clic en Guardar de nuevo para confirmar.", + "validating": "Validando...", + "validate": "Validar", + "cancel": "Cancelar", + "save": "Guardar", + "confirm_save": "Confirmar guardado", + "validation_failed": "Error en la solicitud de validación" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# condición} other {# condiciones}}", + "actions_count": "{count, plural, one {# acción} other {# acciones}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "No es spam", "color_tag": "Etiqueta de Color", "remove_color": "Eliminar Color", - "items_selected": "{{count}} correos seleccionados" + "items_selected": "{count} correos seleccionados" }, "shortcuts": { "title": "Atajos de Teclado", diff --git a/locales/fr/common.json b/locales/fr/common.json index c007290b..7a861799 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -74,8 +74,8 @@ "loading": "Chargement des emails...", "unread": "non lu", "to_me": "À moi", - "to_recipients": "À {{count}} destinataires", - "and_others": "et {{count}} autres", + "to_recipients": "À {count} destinataires", + "and_others": "et {count} autres", "draft": "Brouillon", "starred": "Favori", "conversations_count": "{count} sur {total} conversations", @@ -264,12 +264,12 @@ "no_subject": "(Sans objet)", "unknown_sender": "Inconnu", "quote": { - "reply_header": "Le {{date}}, {{sender}} a écrit :", + "reply_header": "Le {date}, {sender} a écrit :", "forward_header": "---------- Message transféré ----------", - "from": "De : {{sender}}", - "date": "Date : {{date}}", - "subject": "Objet : {{subject}}", - "to": "À : {{recipients}}" + "from": "De : {sender}", + "date": "Date : {date}", + "subject": "Objet : {subject}", + "to": "À : {recipients}" }, "remove_sub_address": "Retirer le sous-adressage" }, @@ -316,13 +316,16 @@ "identity_created": "Identité créée avec succès", "identity_updated": "Identité mise à jour avec succès", "identity_deleted": "Identité supprimée", - "identity_create_failed": "Échec de la création de l'identité: {{error}}", - "identity_update_failed": "Échec de la mise à jour de l'identité: {{error}}", - "identity_delete_failed": "Échec de la suppression de l'identité: {{error}}", + "identity_create_failed": "Échec de la création de l'identité: {error}", + "identity_update_failed": "Échec de la mise à jour de l'identité: {error}", + "identity_delete_failed": "Échec de la suppression de l'identité: {error}", "identity_unauthorized": "Vous n'êtes pas autorisé à envoyer depuis cette adresse email", "identity_not_found": "Identité introuvable", "vacation_saved": "Paramètres du répondeur d'absence enregistrés", - "vacation_save_failed": "Échec de l'enregistrement des paramètres du répondeur d'absence" + "vacation_save_failed": "Échec de l'enregistrement des paramètres du répondeur d'absence", + "filters_saved": "Filtres enregistrés avec succès", + "filters_save_failed": "Échec de l'enregistrement des filtres", + "filters_deleted": "Règle de filtrage supprimée" }, "date": { "today": "Aujourd'hui", @@ -332,12 +335,12 @@ "this_month": "Ce mois-ci", "older": "Plus ancien", "just_now": "À l'instant", - "minutes_ago": "Il y a {{count}} minute", - "minutes_ago_plural": "Il y a {{count}} minutes", - "hours_ago": "Il y a {{count}} heure", - "hours_ago_plural": "Il y a {{count}} heures", - "days_ago": "Il y a {{count}} jour", - "days_ago_plural": "Il y a {{count}} jours" + "minutes_ago": "Il y a {count} minute", + "minutes_ago_plural": "Il y a {count} minutes", + "hours_ago": "Il y a {count} heure", + "hours_ago_plural": "Il y a {count} heures", + "days_ago": "Il y a {count} jour", + "days_ago_plural": "Il y a {count} jours" }, "language": { "title": "Langue", @@ -377,7 +380,8 @@ "identities": "Identités", "vacation": "Répondeur d'absence", "advanced": "Avancé", - "calendar": "Calendrier" + "calendar": "Calendrier", + "filters": "Filtres" }, "appearance": { "title": "Apparence", @@ -546,20 +550,20 @@ "description": "Consultez les informations de votre compte", "email": { "label": "Adresse email", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "Serveur JMAP", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "Utilisation du stockage", - "used": "{{used}} sur {{total}} utilisés", - "percentage": "{{percent}}% utilisé" + "used": "{used} sur {total} utilisés", + "percentage": "{percent}% utilisé" }, "last_sync": { "label": "Dernière synchronisation", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "Adresses email configurées pour l'envoi", "count_zero": "Aucune identité", "count_one": "1 identité", - "count_other": "{{count}} identités" + "count_other": "{count} identités" }, "manage": "Gérer les identités", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "Charger les paramètres depuis un fichier JSON", "button": "Importer" } + }, + "filters": { + "title": "Filtres de courrier", + "description": "Créez des règles pour trier, étiqueter et gérer automatiquement les courriers entrants", + "add_rule": "Ajouter une règle", + "no_rules": "Aucune règle de filtrage", + "no_rules_description": "Créez des règles pour organiser automatiquement vos courriers entrants", + "edit_rule": "Modifier la règle", + "new_rule": "Nouvelle règle", + "delete_rule": "Supprimer la règle", + "delete_confirm": "Êtes-vous sûr de vouloir supprimer cette règle ?", + "enable": "Activer", + "disable": "Désactiver", + "raw_editor": "Éditeur Sieve brut", + "raw_editor_warning": "La modification du script Sieve brut peut casser l'édition visuelle des règles. Les modifications effectuées ici remplacent le constructeur visuel.", + "validate": "Valider", + "validation_success": "Le script est valide", + "validation_error": "Le script contient des erreurs", + "save": "Enregistrer les règles", + "saving": "Enregistrement...", + "saved": "Filtres enregistrés avec succès", + "save_failed": "Échec de l'enregistrement des filtres", + "loading": "Chargement des filtres...", + "not_supported": "Votre serveur de messagerie ne prend pas en charge les filtres de courrier.", + "rule_name": "Nom de la règle", + "rule_name_placeholder": "ex. Trier les newsletters", + "match_all": "Correspondre à TOUTES les conditions", + "match_any": "Correspondre à N'IMPORTE QUELLE condition", + "conditions": "Conditions", + "add_condition": "Ajouter une condition", + "actions": "Actions", + "add_action": "Ajouter une action", + "stop_processing": "Arrêter le traitement des règles suivantes", + "condition_fields": { + "from": "De", + "to": "À", + "cc": "Cc", + "subject": "Objet", + "header": "En-tête personnalisé", + "size": "Taille", + "body": "Corps" + }, + "comparators": { + "contains": "contient", + "not_contains": "ne contient pas", + "is": "est exactement", + "not_is": "n'est pas", + "starts_with": "commence par", + "ends_with": "se termine par", + "matches": "correspond au motif", + "greater_than": "est supérieur à", + "less_than": "est inférieur à" + }, + "action_types": { + "move": "Déplacer vers le dossier", + "copy": "Copier dans le dossier", + "forward": "Transférer à", + "mark_read": "Marquer comme lu", + "star": "Marquer d'une étoile", + "add_label": "Ajouter un libellé", + "discard": "Supprimer silencieusement", + "reject": "Rejeter avec un message", + "keep": "Conserver dans la boîte de réception", + "stop": "Arrêter le traitement" + }, + "move_to_folder": "Sélectionner le dossier", + "copy_to_folder": "Sélectionner le dossier", + "forward_to": "Transférer à l'adresse e-mail", + "forward_placeholder": "email@exemple.com", + "reject_message": "Message de rejet", + "reject_placeholder": "Votre e-mail a été rejeté", + "label_name": "Nom du libellé", + "label_placeholder": "ex. important", + "header_name": "Nom de l'en-tête", + "header_placeholder": "ex. X-Mailing-List", + "size_bytes": "Taille en octets", + "size_placeholder": "ex. 1000000", + "system_managed": "Règle gérée par le système", + "opaque_warning": "Ce script a été modifié en dehors du constructeur visuel. Seule l'édition Sieve brute est disponible.", + "open_sieve_editor": "Ouvrir l'éditeur Sieve brut", + "fetch_error": "Échec du chargement des filtres", + "and": "et", + "or": "ou", + "cancel": "Annuler", + "confirm_delete": "Supprimer", + "rule_list": "Règles de filtrage", + "drag_to_reorder": "Glisser pour réorganiser", + "match_type": "Type de correspondance", + "reset_to_visual": "Revenir au constructeur visuel", + "reset_warning": "Cela supprimera le script actuel et recommencera à zéro.", + "confirm_reset": "Réinitialiser", + "validation_empty_name": "Le nom de la règle est requis", + "validation_empty_conditions": "Au moins une condition avec une valeur est requise", + "validation_empty_actions": "Au moins une action est requise", + "sieve_editor": { + "title": "Éditeur de script Sieve", + "warning": "La modification du script Sieve brut peut casser l'édition visuelle des règles. Les modifications effectuées ici remplacent le constructeur visuel.", + "script_content": "Script Sieve", + "valid": "Le script est valide", + "invalid": "Le script contient des erreurs", + "save_warning": "L'enregistrement écrasera toutes les règles visuelles. Cette action est irréversible. Cliquez à nouveau sur Enregistrer pour confirmer.", + "validating": "Validation...", + "validate": "Valider", + "cancel": "Annuler", + "save": "Enregistrer", + "confirm_save": "Confirmer l'enregistrement", + "validation_failed": "Échec de la requête de validation" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# condition} other {# conditions}}", + "actions_count": "{count, plural, one {# action} other {# actions}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "Pas un spam", "color_tag": "Étiquette de couleur", "remove_color": "Supprimer la couleur", - "items_selected": "{{count}} emails sélectionnés" + "items_selected": "{count} emails sélectionnés" }, "shortcuts": { "title": "Raccourcis clavier", diff --git a/locales/it/common.json b/locales/it/common.json index 0992bb85..df4fbf50 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -74,8 +74,8 @@ "loading": "Caricamento messaggi...", "unread": "non letto", "to_me": "A me", - "to_recipients": "A {{count}} destinatari", - "and_others": "e altri {{count}}", + "to_recipients": "A {count} destinatari", + "and_others": "e altri {count}", "draft": "Bozza", "starred": "Speciale", "conversations_count": "{count} di {total} conversazioni", @@ -264,12 +264,12 @@ "no_subject": "(Nessun oggetto)", "unknown_sender": "Sconosciuto", "quote": { - "reply_header": "Il {{date}}, {{sender}} ha scritto:", + "reply_header": "Il {date}, {sender} ha scritto:", "forward_header": "---------- Messaggio inoltrato ----------", - "from": "Da: {{sender}}", - "date": "Data: {{date}}", - "subject": "Oggetto: {{subject}}", - "to": "A: {{recipients}}" + "from": "Da: {sender}", + "date": "Data: {date}", + "subject": "Oggetto: {subject}", + "to": "A: {recipients}" }, "remove_sub_address": "Rimuovi sotto-indirizzo" }, @@ -316,13 +316,16 @@ "identity_created": "Identità creata con successo", "identity_updated": "Identità aggiornata con successo", "identity_deleted": "Identità eliminata", - "identity_create_failed": "Impossibile creare l'identità: {{error}}", - "identity_update_failed": "Impossibile aggiornare l'identità: {{error}}", - "identity_delete_failed": "Impossibile eliminare l'identità: {{error}}", + "identity_create_failed": "Impossibile creare l'identità: {error}", + "identity_update_failed": "Impossibile aggiornare l'identità: {error}", + "identity_delete_failed": "Impossibile eliminare l'identità: {error}", "identity_unauthorized": "Non sei autorizzato a inviare da questo indirizzo email", "identity_not_found": "Identità non trovata", "vacation_saved": "Impostazioni del risponditore automatico salvate", - "vacation_save_failed": "Impossibile salvare le impostazioni del risponditore automatico" + "vacation_save_failed": "Impossibile salvare le impostazioni del risponditore automatico", + "filters_saved": "Filtri salvati con successo", + "filters_save_failed": "Impossibile salvare i filtri", + "filters_deleted": "Regola di filtraggio eliminata" }, "date": { "today": "Oggi", @@ -332,12 +335,12 @@ "this_month": "Questo mese", "older": "Meno recenti", "just_now": "Proprio ora", - "minutes_ago": "{{count}} minuto fa", - "minutes_ago_plural": "{{count}} minuti fa", - "hours_ago": "{{count}} ora fa", - "hours_ago_plural": "{{count}} ore fa", - "days_ago": "{{count}} giorno fa", - "days_ago_plural": "{{count}} giorni fa" + "minutes_ago": "{count} minuto fa", + "minutes_ago_plural": "{count} minuti fa", + "hours_ago": "{count} ora fa", + "hours_ago_plural": "{count} ore fa", + "days_ago": "{count} giorno fa", + "days_ago_plural": "{count} giorni fa" }, "language": { "title": "Lingua", @@ -377,7 +380,8 @@ "identities": "Identità", "vacation": "Risponditore automatico", "advanced": "Avanzate", - "calendar": "Calendario" + "calendar": "Calendario", + "filters": "Filtri" }, "appearance": { "title": "Aspetto", @@ -546,20 +550,20 @@ "description": "Visualizza le informazioni del tuo account", "email": { "label": "Indirizzo email", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "Server JMAP", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "Utilizzo spazio", - "used": "{{used}} di {{total}} utilizzati", - "percentage": "{{percent}}% utilizzato" + "used": "{used} di {total} utilizzati", + "percentage": "{percent}% utilizzato" }, "last_sync": { "label": "Ultima sincronizzazione", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "Indirizzi email configurati per l'invio", "count_zero": "Nessuna identità", "count_one": "1 identità", - "count_other": "{{count}} identità" + "count_other": "{count} identità" }, "manage": "Gestisci identità", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "Carica impostazioni da file JSON", "button": "Importa" } + }, + "filters": { + "title": "Filtri email", + "description": "Crea regole per ordinare, etichettare e gestire automaticamente le email in arrivo", + "add_rule": "Aggiungi regola", + "no_rules": "Nessuna regola di filtro", + "no_rules_description": "Crea regole per organizzare automaticamente le tue email in arrivo", + "edit_rule": "Modifica regola", + "new_rule": "Nuova regola", + "delete_rule": "Elimina regola", + "delete_confirm": "Sei sicuro di voler eliminare questa regola?", + "enable": "Attiva", + "disable": "Disattiva", + "raw_editor": "Editor Sieve grezzo", + "raw_editor_warning": "La modifica dello script Sieve grezzo potrebbe compromettere l'editor visuale delle regole. Le modifiche effettuate qui sovrascrivono il costruttore visuale.", + "validate": "Convalida", + "validation_success": "Lo script è valido", + "validation_error": "Lo script contiene errori", + "save": "Salva regole", + "saving": "Salvataggio...", + "saved": "Filtri salvati con successo", + "save_failed": "Impossibile salvare i filtri", + "loading": "Caricamento filtri...", + "not_supported": "Il tuo server di posta non supporta i filtri email.", + "rule_name": "Nome della regola", + "rule_name_placeholder": "es. Ordina newsletter", + "match_all": "Corrispondere a TUTTE le condizioni", + "match_any": "Corrispondere a QUALSIASI condizione", + "conditions": "Condizioni", + "add_condition": "Aggiungi condizione", + "actions": "Azioni", + "add_action": "Aggiungi azione", + "stop_processing": "Interrompere l'elaborazione delle regole successive", + "condition_fields": { + "from": "Da", + "to": "A", + "cc": "Cc", + "subject": "Oggetto", + "header": "Intestazione personalizzata", + "size": "Dimensione", + "body": "Corpo" + }, + "comparators": { + "contains": "contiene", + "not_contains": "non contiene", + "is": "è esattamente", + "not_is": "non è", + "starts_with": "inizia con", + "ends_with": "termina con", + "matches": "corrisponde al modello", + "greater_than": "è maggiore di", + "less_than": "è minore di" + }, + "action_types": { + "move": "Sposta nella cartella", + "copy": "Copia nella cartella", + "forward": "Inoltra a", + "mark_read": "Segna come letto", + "star": "Aggiungi stella", + "add_label": "Aggiungi etichetta", + "discard": "Elimina silenziosamente", + "reject": "Rifiuta con messaggio", + "keep": "Mantieni nella posta in arrivo", + "stop": "Interrompi elaborazione" + }, + "move_to_folder": "Seleziona cartella", + "copy_to_folder": "Seleziona cartella", + "forward_to": "Inoltra a indirizzo email", + "forward_placeholder": "email@esempio.com", + "reject_message": "Messaggio di rifiuto", + "reject_placeholder": "La tua email è stata rifiutata", + "label_name": "Nome dell'etichetta", + "label_placeholder": "es. importante", + "header_name": "Nome dell'intestazione", + "header_placeholder": "es. X-Mailing-List", + "size_bytes": "Dimensione in byte", + "size_placeholder": "es. 1000000", + "system_managed": "Regola gestita dal sistema", + "opaque_warning": "Questo script è stato modificato al di fuori del costruttore visuale. È disponibile solo la modifica Sieve grezza.", + "open_sieve_editor": "Apri editor Sieve", + "fetch_error": "Impossibile caricare i filtri", + "and": "e", + "or": "o", + "cancel": "Annulla", + "confirm_delete": "Elimina", + "rule_list": "Regole di filtraggio", + "drag_to_reorder": "Trascina per riordinare", + "match_type": "Tipo di corrispondenza", + "reset_to_visual": "Ripristina il costruttore visuale", + "reset_warning": "Questo eliminerà lo script attuale e ricomincerà da zero.", + "confirm_reset": "Ripristina", + "validation_empty_name": "Il nome della regola è obbligatorio", + "validation_empty_conditions": "È necessaria almeno una condizione con un valore", + "validation_empty_actions": "È necessaria almeno un'azione", + "sieve_editor": { + "title": "Editor script Sieve", + "warning": "La modifica dello script Sieve può interrompere la modifica visuale delle regole. Le modifiche effettuate qui sovrascrivono il costruttore visuale.", + "script_content": "Script Sieve", + "valid": "Lo script è valido", + "invalid": "Lo script contiene errori", + "save_warning": "Il salvataggio sovrascriverà tutte le regole visuali. Questa azione non può essere annullata. Fai clic su Salva di nuovo per confermare.", + "validating": "Validazione...", + "validate": "Valida", + "cancel": "Annulla", + "save": "Salva", + "confirm_save": "Conferma salvataggio", + "validation_failed": "Richiesta di validazione non riuscita" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# condizione} other {# condizioni}}", + "actions_count": "{count, plural, one {# azione} other {# azioni}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "Non spam", "color_tag": "Etichetta colore", "remove_color": "Rimuovi colore", - "items_selected": "{{count}} messaggi selezionati" + "items_selected": "{count} messaggi selezionati" }, "shortcuts": { "title": "Scorciatoie da tastiera", diff --git a/locales/ja/common.json b/locales/ja/common.json index a84384db..be15f1fc 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -74,8 +74,8 @@ "loading": "メールを読み込み中...", "unread": "未読", "to_me": "宛先: 自分", - "to_recipients": "{{count}}人の宛先", - "and_others": "他{{count}}人", + "to_recipients": "{count}人の宛先", + "and_others": "他{count}人", "draft": "下書き", "starred": "スター付き", "conversations_count": "{total}件中{count}件の会話", @@ -264,12 +264,12 @@ "no_subject": "(件名なし)", "unknown_sender": "不明", "quote": { - "reply_header": "{{date}}に{{sender}}が書きました:", + "reply_header": "{date}に{sender}が書きました:", "forward_header": "---------- 転送メッセージ ----------", - "from": "送信者: {{sender}}", - "date": "日付: {{date}}", - "subject": "件名: {{subject}}", - "to": "宛先: {{recipients}}" + "from": "送信者: {sender}", + "date": "日付: {date}", + "subject": "件名: {subject}", + "to": "宛先: {recipients}" }, "remove_sub_address": "サブアドレスを削除" }, @@ -316,13 +316,16 @@ "identity_created": "送信者情報を作成しました", "identity_updated": "送信者情報を更新しました", "identity_deleted": "送信者情報を削除しました", - "identity_create_failed": "送信者情報の作成に失敗しました: {{error}}", - "identity_update_failed": "送信者情報の更新に失敗しました: {{error}}", - "identity_delete_failed": "送信者情報の削除に失敗しました: {{error}}", + "identity_create_failed": "送信者情報の作成に失敗しました: {error}", + "identity_update_failed": "送信者情報の更新に失敗しました: {error}", + "identity_delete_failed": "送信者情報の削除に失敗しました: {error}", "identity_unauthorized": "このメールアドレスからの送信は許可されていません", "identity_not_found": "送信者情報が見つかりません", "vacation_saved": "不在応答の設定を保存しました", - "vacation_save_failed": "不在応答の設定の保存に失敗しました" + "vacation_save_failed": "不在応答の設定の保存に失敗しました", + "filters_saved": "フィルターを保存しました", + "filters_save_failed": "フィルターの保存に失敗しました", + "filters_deleted": "フィルタールールが削除されました" }, "date": { "today": "今日", @@ -332,12 +335,12 @@ "this_month": "今月", "older": "それ以前", "just_now": "たった今", - "minutes_ago": "{{count}}分前", - "minutes_ago_plural": "{{count}}分前", - "hours_ago": "{{count}}時間前", - "hours_ago_plural": "{{count}}時間前", - "days_ago": "{{count}}日前", - "days_ago_plural": "{{count}}日前" + "minutes_ago": "{count}分前", + "minutes_ago_plural": "{count}分前", + "hours_ago": "{count}時間前", + "hours_ago_plural": "{count}時間前", + "days_ago": "{count}日前", + "days_ago_plural": "{count}日前" }, "language": { "title": "言語", @@ -377,7 +380,8 @@ "identities": "送信者情報", "vacation": "不在応答", "advanced": "詳細設定", - "calendar": "カレンダー" + "calendar": "カレンダー", + "filters": "フィルター" }, "appearance": { "title": "外観", @@ -546,20 +550,20 @@ "description": "アカウント情報を表示", "email": { "label": "メールアドレス", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "JMAPサーバー", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "ストレージ使用量", - "used": "{{total}}中{{used}}使用", - "percentage": "{{percent}}%使用" + "used": "{total}中{used}使用", + "percentage": "{percent}%使用" }, "last_sync": { "label": "最終同期", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "送信用に設定されたメールアドレス", "count_zero": "送信者情報なし", "count_one": "1件", - "count_other": "{{count}}件" + "count_other": "{count}件" }, "manage": "送信者情報を管理", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "JSONファイルから設定をアップロード", "button": "インポート" } + }, + "filters": { + "title": "メールフィルター", + "description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します", + "add_rule": "ルールを追加", + "no_rules": "フィルタールールなし", + "no_rules_description": "受信メールを自動で整理するルールを作成してください", + "edit_rule": "ルールを編集", + "new_rule": "新しいルール", + "delete_rule": "ルールを削除", + "delete_confirm": "このルールを削除してもよろしいですか?", + "enable": "有効にする", + "disable": "無効にする", + "raw_editor": "Sieveスクリプトエディター", + "raw_editor_warning": "Sieveスクリプトを直接編集すると、ビジュアルルール編集が使えなくなる場合があります。ここでの変更はビジュアルビルダーを上書きします。", + "validate": "検証", + "validation_success": "スクリプトは有効です", + "validation_error": "スクリプトにエラーがあります", + "save": "ルールを保存", + "saving": "保存中...", + "saved": "フィルターを保存しました", + "save_failed": "フィルターの保存に失敗しました", + "loading": "フィルターを読み込み中...", + "not_supported": "お使いのメールサーバーはメールフィルターに対応していません。", + "rule_name": "ルール名", + "rule_name_placeholder": "例:ニュースレターを振り分け", + "match_all": "すべての条件に一致", + "match_any": "いずれかの条件に一致", + "conditions": "条件", + "add_condition": "条件を追加", + "actions": "アクション", + "add_action": "アクションを追加", + "stop_processing": "以降のルールの処理を停止", + "condition_fields": { + "from": "差出人", + "to": "宛先", + "cc": "Cc", + "subject": "件名", + "header": "カスタムヘッダー", + "size": "サイズ", + "body": "本文" + }, + "comparators": { + "contains": "を含む", + "not_contains": "を含まない", + "is": "と完全一致", + "not_is": "ではない", + "starts_with": "で始まる", + "ends_with": "で終わる", + "matches": "パターンに一致", + "greater_than": "より大きい", + "less_than": "より小さい" + }, + "action_types": { + "move": "フォルダーに移動", + "copy": "フォルダーにコピー", + "forward": "転送先", + "mark_read": "既読にする", + "star": "スターを付ける", + "add_label": "ラベルを追加", + "discard": "サイレント削除", + "reject": "メッセージ付きで拒否", + "keep": "受信トレイに保持", + "stop": "処理を停止" + }, + "move_to_folder": "フォルダーを選択", + "copy_to_folder": "フォルダーを選択", + "forward_to": "転送先メールアドレス", + "forward_placeholder": "email@example.com", + "reject_message": "拒否メッセージ", + "reject_placeholder": "あなたのメールは拒否されました", + "label_name": "ラベル名", + "label_placeholder": "例:重要", + "header_name": "ヘッダー名", + "header_placeholder": "例:X-Mailing-List", + "size_bytes": "サイズ(バイト)", + "size_placeholder": "例:1000000", + "system_managed": "システム管理ルール", + "opaque_warning": "このスクリプトはビジュアルビルダーの外部で編集されました。Sieveスクリプトの直接編集のみ可能です。", + "open_sieve_editor": "Sieveスクリプトエディタを開く", + "fetch_error": "フィルターの読み込みに失敗しました", + "and": "かつ", + "or": "または", + "cancel": "キャンセル", + "confirm_delete": "削除", + "rule_list": "フィルタールール", + "drag_to_reorder": "ドラッグして並べ替え", + "match_type": "一致タイプ", + "reset_to_visual": "ビジュアルビルダーにリセット", + "reset_warning": "現在のスクリプトを破棄して最初からやり直します。", + "confirm_reset": "リセット", + "validation_empty_name": "ルール名は必須です", + "validation_empty_conditions": "値を持つ条件が少なくとも1つ必要です", + "validation_empty_actions": "少なくとも1つのアクションが必要です", + "sieve_editor": { + "title": "Sieveスクリプトエディタ", + "warning": "Sieveスクリプトを直接編集すると、ビジュアルルール編集が使用できなくなる場合があります。ここでの変更はビジュアルビルダーを上書きします。", + "script_content": "Sieveスクリプト", + "valid": "スクリプトは有効です", + "invalid": "スクリプトにエラーがあります", + "save_warning": "保存するとすべてのビジュアルルールが上書きされます。この操作は元に戻せません。もう一度「保存」をクリックして確認してください。", + "validating": "検証中...", + "validate": "検証", + "cancel": "キャンセル", + "save": "保存", + "confirm_save": "保存を確認", + "validation_failed": "検証リクエストに失敗しました" + }, + "rule_summary": { + "conditions_count": "{count, plural, other {#個の条件}}", + "actions_count": "{count, plural, other {#個のアクション}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "迷惑メールでない", "color_tag": "カラータグ", "remove_color": "色を削除", - "items_selected": "{{count}}件のメールを選択" + "items_selected": "{count}件のメールを選択" }, "shortcuts": { "title": "キーボードショートカット", diff --git a/locales/nl/common.json b/locales/nl/common.json index 1407ac07..93d3d7f0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -74,8 +74,8 @@ "loading": "E-mails laden...", "unread": "ongelezen", "to_me": "Aan mij", - "to_recipients": "Aan {{count}} ontvangers", - "and_others": "en {{count}} anderen", + "to_recipients": "Aan {count} ontvangers", + "and_others": "en {count} anderen", "draft": "Concept", "starred": "Met ster", "conversations_count": "{count} van {total} gesprekken", @@ -264,12 +264,12 @@ "no_subject": "(Geen onderwerp)", "unknown_sender": "Onbekend", "quote": { - "reply_header": "Op {{date}} schreef {{sender}}:", + "reply_header": "Op {date} schreef {sender}:", "forward_header": "---------- Doorgestuurd bericht ----------", - "from": "Van: {{sender}}", - "date": "Datum: {{date}}", - "subject": "Onderwerp: {{subject}}", - "to": "Aan: {{recipients}}" + "from": "Van: {sender}", + "date": "Datum: {date}", + "subject": "Onderwerp: {subject}", + "to": "Aan: {recipients}" }, "remove_sub_address": "Sub-adres verwijderen" }, @@ -316,13 +316,16 @@ "identity_created": "Identiteit succesvol aangemaakt", "identity_updated": "Identiteit succesvol bijgewerkt", "identity_deleted": "Identiteit verwijderd", - "identity_create_failed": "Kan identiteit niet aanmaken: {{error}}", - "identity_update_failed": "Kan identiteit niet bijwerken: {{error}}", - "identity_delete_failed": "Kan identiteit niet verwijderen: {{error}}", + "identity_create_failed": "Kan identiteit niet aanmaken: {error}", + "identity_update_failed": "Kan identiteit niet bijwerken: {error}", + "identity_delete_failed": "Kan identiteit niet verwijderen: {error}", "identity_unauthorized": "Je bent niet geautoriseerd om vanaf dit e-mailadres te verzenden", "identity_not_found": "Identiteit niet gevonden", "vacation_saved": "Afwezigheidsinstellingen opgeslagen", - "vacation_save_failed": "Kan afwezigheidsinstellingen niet opslaan" + "vacation_save_failed": "Kan afwezigheidsinstellingen niet opslaan", + "filters_saved": "Filters succesvol opgeslagen", + "filters_save_failed": "Filters opslaan mislukt", + "filters_deleted": "Filterregel verwijderd" }, "date": { "today": "Vandaag", @@ -332,12 +335,12 @@ "this_month": "Deze maand", "older": "Ouder", "just_now": "Zojuist", - "minutes_ago": "{{count}} minuut geleden", - "minutes_ago_plural": "{{count}} minuten geleden", - "hours_ago": "{{count}} uur geleden", - "hours_ago_plural": "{{count}} uur geleden", - "days_ago": "{{count}} dag geleden", - "days_ago_plural": "{{count}} dagen geleden" + "minutes_ago": "{count} minuut geleden", + "minutes_ago_plural": "{count} minuten geleden", + "hours_ago": "{count} uur geleden", + "hours_ago_plural": "{count} uur geleden", + "days_ago": "{count} dag geleden", + "days_ago_plural": "{count} dagen geleden" }, "language": { "title": "Taal", @@ -377,7 +380,8 @@ "identities": "Identiteiten", "vacation": "Afwezigheidsmelder", "advanced": "Geavanceerd", - "calendar": "Agenda" + "calendar": "Agenda", + "filters": "Filters" }, "appearance": { "title": "Uiterlijk", @@ -546,20 +550,20 @@ "description": "Bekijk je accountinformatie", "email": { "label": "E-mailadres", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "JMAP-server", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "Opslaggebruik", - "used": "{{used}} van {{total}} gebruikt", - "percentage": "{{percent}}% gebruikt" + "used": "{used} van {total} gebruikt", + "percentage": "{percent}% gebruikt" }, "last_sync": { "label": "Laatste synchronisatie", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "E-mailadressen geconfigureerd voor verzenden", "count_zero": "Geen identiteiten", "count_one": "1 identiteit", - "count_other": "{{count}} identiteiten" + "count_other": "{count} identiteiten" }, "manage": "Identiteiten beheren", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "Upload instellingen vanuit JSON-bestand", "button": "Importeren" } + }, + "filters": { + "title": "E-mailfilters", + "description": "Maak regels om inkomende e-mails automatisch te sorteren, labelen en beheren", + "add_rule": "Regel toevoegen", + "no_rules": "Geen filterregels", + "no_rules_description": "Maak regels om uw inkomende e-mails automatisch te organiseren", + "edit_rule": "Regel bewerken", + "new_rule": "Nieuwe regel", + "delete_rule": "Regel verwijderen", + "delete_confirm": "Weet u zeker dat u deze regel wilt verwijderen?", + "enable": "Inschakelen", + "disable": "Uitschakelen", + "raw_editor": "Sieve-scripteditor", + "raw_editor_warning": "Het bewerken van het Sieve-script kan de visuele regelbewerking verstoren. Wijzigingen hier overschrijven de visuele builder.", + "validate": "Valideren", + "validation_success": "Het script is geldig", + "validation_error": "Het script bevat fouten", + "save": "Regels opslaan", + "saving": "Opslaan...", + "saved": "Filters succesvol opgeslagen", + "save_failed": "Filters opslaan mislukt", + "loading": "Filters laden...", + "not_supported": "Uw mailserver ondersteunt geen e-mailfilters.", + "rule_name": "Regelnaam", + "rule_name_placeholder": "bijv. Nieuwsbrieven sorteren", + "match_all": "Aan ALLE voorwaarden voldoen", + "match_any": "Aan EEN willekeurige voorwaarde voldoen", + "conditions": "Voorwaarden", + "add_condition": "Voorwaarde toevoegen", + "actions": "Acties", + "add_action": "Actie toevoegen", + "stop_processing": "Verwerking van volgende regels stoppen", + "condition_fields": { + "from": "Van", + "to": "Aan", + "cc": "Cc", + "subject": "Onderwerp", + "header": "Aangepaste header", + "size": "Grootte", + "body": "Inhoud" + }, + "comparators": { + "contains": "bevat", + "not_contains": "bevat niet", + "is": "is precies", + "not_is": "is niet", + "starts_with": "begint met", + "ends_with": "eindigt met", + "matches": "komt overeen met patroon", + "greater_than": "is groter dan", + "less_than": "is kleiner dan" + }, + "action_types": { + "move": "Verplaatsen naar map", + "copy": "Kopiëren naar map", + "forward": "Doorsturen naar", + "mark_read": "Markeren als gelezen", + "star": "Ster toevoegen", + "add_label": "Label toevoegen", + "discard": "Stilletjes verwijderen", + "reject": "Afwijzen met bericht", + "keep": "In postvak IN houden", + "stop": "Verwerking stoppen" + }, + "move_to_folder": "Map selecteren", + "copy_to_folder": "Map selecteren", + "forward_to": "Doorsturen naar e-mailadres", + "forward_placeholder": "email@voorbeeld.nl", + "reject_message": "Afwijzingsbericht", + "reject_placeholder": "Uw e-mail is afgewezen", + "label_name": "Labelnaam", + "label_placeholder": "bijv. belangrijk", + "header_name": "Headernaam", + "header_placeholder": "bijv. X-Mailing-List", + "size_bytes": "Grootte in bytes", + "size_placeholder": "bijv. 1000000", + "system_managed": "Systeembeheerde regel", + "opaque_warning": "Dit script is buiten de visuele builder bewerkt. Alleen Sieve-scriptbewerking is beschikbaar.", + "open_sieve_editor": "Sieve-scripteditor openen", + "fetch_error": "Filters konden niet worden geladen", + "and": "en", + "or": "of", + "cancel": "Annuleren", + "confirm_delete": "Verwijderen", + "rule_list": "Filterregels", + "drag_to_reorder": "Slepen om te herschikken", + "match_type": "Overeenkomsttype", + "reset_to_visual": "Terugzetten naar visuele builder", + "reset_warning": "Dit verwijdert het huidige script en begint opnieuw.", + "confirm_reset": "Terugzetten", + "validation_empty_name": "Regelnaam is verplicht", + "validation_empty_conditions": "Minstens één voorwaarde met een waarde is vereist", + "validation_empty_actions": "Minstens één actie is vereist", + "sieve_editor": { + "title": "Sieve-scripteditor", + "warning": "Het bewerken van het Sieve-script kan de visuele regelbewerking verbreken. Wijzigingen hier overschrijven de visuele builder.", + "script_content": "Sieve-script", + "valid": "Script is geldig", + "invalid": "Script bevat fouten", + "save_warning": "Opslaan overschrijft alle visuele regels. Dit kan niet ongedaan worden gemaakt. Klik nogmaals op Opslaan om te bevestigen.", + "validating": "Valideren...", + "validate": "Valideren", + "cancel": "Annuleren", + "save": "Opslaan", + "confirm_save": "Opslaan bevestigen", + "validation_failed": "Validatieverzoek mislukt" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}", + "actions_count": "{count, plural, one {# actie} other {# acties}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "Geen spam", "color_tag": "Kleurtag", "remove_color": "Kleur verwijderen", - "items_selected": "{{count}} e-mails geselecteerd" + "items_selected": "{count} e-mails geselecteerd" }, "shortcuts": { "title": "Sneltoetsen", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4b8dc2d9..a9757dce 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -74,8 +74,8 @@ "loading": "Carregando e-mails...", "unread": "não lido", "to_me": "Para mim", - "to_recipients": "Para {{count}} destinatários", - "and_others": "e {{count}} outros", + "to_recipients": "Para {count} destinatários", + "and_others": "e {count} outros", "draft": "Rascunho", "starred": "Com Estrela", "conversations_count": "{count} de {total} conversas", @@ -264,12 +264,12 @@ "no_subject": "(Sem Assunto)", "unknown_sender": "Desconhecido", "quote": { - "reply_header": "Em {{date}}, {{sender}} escreveu:", + "reply_header": "Em {date}, {sender} escreveu:", "forward_header": "---------- Mensagem encaminhada ----------", - "from": "De: {{sender}}", - "date": "Data: {{date}}", - "subject": "Assunto: {{subject}}", - "to": "Para: {{recipients}}" + "from": "De: {sender}", + "date": "Data: {date}", + "subject": "Assunto: {subject}", + "to": "Para: {recipients}" }, "remove_sub_address": "Remover sub-endereço" }, @@ -316,13 +316,16 @@ "identity_created": "Identidade criada com sucesso", "identity_updated": "Identidade atualizada com sucesso", "identity_deleted": "Identidade excluída", - "identity_create_failed": "Falha ao criar identidade: {{error}}", - "identity_update_failed": "Falha ao atualizar identidade: {{error}}", - "identity_delete_failed": "Falha ao excluir identidade: {{error}}", + "identity_create_failed": "Falha ao criar identidade: {error}", + "identity_update_failed": "Falha ao atualizar identidade: {error}", + "identity_delete_failed": "Falha ao excluir identidade: {error}", "identity_unauthorized": "Você não está autorizado a enviar deste endereço de e-mail", "identity_not_found": "Identidade não encontrada", "vacation_saved": "Configurações de resposta automática salvas", - "vacation_save_failed": "Falha ao salvar configurações de resposta automática" + "vacation_save_failed": "Falha ao salvar configurações de resposta automática", + "filters_saved": "Filtros salvos com sucesso", + "filters_save_failed": "Falha ao salvar os filtros", + "filters_deleted": "Regra de filtragem eliminada" }, "date": { "today": "Hoje", @@ -332,12 +335,12 @@ "this_month": "Este mês", "older": "Mais antigos", "just_now": "Agora mesmo", - "minutes_ago": "{{count}} minuto atrás", - "minutes_ago_plural": "{{count}} minutos atrás", - "hours_ago": "{{count}} hora atrás", - "hours_ago_plural": "{{count}} horas atrás", - "days_ago": "{{count}} dia atrás", - "days_ago_plural": "{{count}} dias atrás" + "minutes_ago": "{count} minuto atrás", + "minutes_ago_plural": "{count} minutos atrás", + "hours_ago": "{count} hora atrás", + "hours_ago_plural": "{count} horas atrás", + "days_ago": "{count} dia atrás", + "days_ago_plural": "{count} dias atrás" }, "language": { "title": "Idioma", @@ -377,7 +380,8 @@ "identities": "Identidades", "vacation": "Resposta automática", "advanced": "Avançado", - "calendar": "Calendário" + "calendar": "Calendário", + "filters": "Filtros" }, "appearance": { "title": "Aparência", @@ -546,20 +550,20 @@ "description": "Visualize as informações da sua conta", "email": { "label": "Endereço de E-mail", - "value": "{{email}}" + "value": "{email}" }, "server": { "label": "Servidor JMAP", - "value": "{{server}}" + "value": "{server}" }, "storage": { "label": "Uso de Armazenamento", - "used": "{{used}} de {{total}} usado", - "percentage": "{{percent}}% usado" + "used": "{used} de {total} usado", + "percentage": "{percent}% usado" }, "last_sync": { "label": "Última Sincronização", - "value": "{{time}}" + "value": "{time}" } }, "identities": { @@ -570,7 +574,7 @@ "description": "Endereços de e-mail configurados para envio", "count_zero": "Nenhuma identidade", "count_one": "1 identidade", - "count_other": "{{count}} identidades" + "count_other": "{count} identidades" }, "manage": "Gerenciar Identidades", "sub_addressing": { @@ -649,6 +653,118 @@ "description": "Fazer upload de configurações de arquivo JSON", "button": "Importar" } + }, + "filters": { + "title": "Filtros de e-mail", + "description": "Crie regras para classificar, rotular e gerenciar automaticamente os e-mails recebidos", + "add_rule": "Adicionar regra", + "no_rules": "Nenhuma regra de filtro", + "no_rules_description": "Crie regras para organizar automaticamente seus e-mails recebidos", + "edit_rule": "Editar regra", + "new_rule": "Nova regra", + "delete_rule": "Excluir regra", + "delete_confirm": "Tem certeza de que deseja excluir esta regra?", + "enable": "Ativar", + "disable": "Desativar", + "raw_editor": "Editor Sieve bruto", + "raw_editor_warning": "Editar o script Sieve bruto pode quebrar a edição visual de regras. As alterações feitas aqui substituem o construtor visual.", + "validate": "Validar", + "validation_success": "O script é válido", + "validation_error": "O script contém erros", + "save": "Salvar regras", + "saving": "Salvando...", + "saved": "Filtros salvos com sucesso", + "save_failed": "Falha ao salvar os filtros", + "loading": "Carregando filtros...", + "not_supported": "Seu servidor de e-mail não suporta filtros de e-mail.", + "rule_name": "Nome da regra", + "rule_name_placeholder": "ex. Classificar newsletters", + "match_all": "Corresponder a TODAS as condições", + "match_any": "Corresponder a QUALQUER condição", + "conditions": "Condições", + "add_condition": "Adicionar condição", + "actions": "Ações", + "add_action": "Adicionar ação", + "stop_processing": "Parar o processamento das regras seguintes", + "condition_fields": { + "from": "De", + "to": "Para", + "cc": "Cc", + "subject": "Assunto", + "header": "Cabeçalho personalizado", + "size": "Tamanho", + "body": "Corpo" + }, + "comparators": { + "contains": "contém", + "not_contains": "não contém", + "is": "é exatamente", + "not_is": "não é", + "starts_with": "começa com", + "ends_with": "termina com", + "matches": "corresponde ao padrão", + "greater_than": "é maior que", + "less_than": "é menor que" + }, + "action_types": { + "move": "Mover para pasta", + "copy": "Copiar para pasta", + "forward": "Encaminhar para", + "mark_read": "Marcar como lido", + "star": "Destacar mensagem", + "add_label": "Adicionar rótulo", + "discard": "Descartar (excluir silenciosamente)", + "reject": "Rejeitar com mensagem", + "keep": "Manter na caixa de entrada", + "stop": "Parar processamento" + }, + "move_to_folder": "Selecionar pasta", + "copy_to_folder": "Selecionar pasta", + "forward_to": "Encaminhar para endereço de e-mail", + "forward_placeholder": "email@exemplo.com", + "reject_message": "Mensagem de rejeição", + "reject_placeholder": "Seu e-mail foi rejeitado", + "label_name": "Nome do rótulo", + "label_placeholder": "ex. importante", + "header_name": "Nome do cabeçalho", + "header_placeholder": "ex. X-Mailing-List", + "size_bytes": "Tamanho em bytes", + "size_placeholder": "ex. 1000000", + "system_managed": "Regra gerenciada pelo sistema", + "opaque_warning": "Este script foi editado fora do construtor visual. Apenas a edição Sieve bruta está disponível.", + "open_sieve_editor": "Abrir editor Sieve", + "fetch_error": "Falha ao carregar filtros", + "and": "e", + "or": "ou", + "cancel": "Cancelar", + "confirm_delete": "Eliminar", + "rule_list": "Regras de filtragem", + "drag_to_reorder": "Arrastar para reordenar", + "match_type": "Tipo de correspondência", + "reset_to_visual": "Redefinir para o construtor visual", + "reset_warning": "Isto irá descartar o script atual e começar do zero.", + "confirm_reset": "Redefinir", + "validation_empty_name": "O nome da regra é obrigatório", + "validation_empty_conditions": "É necessária pelo menos uma condição com um valor", + "validation_empty_actions": "É necessária pelo menos uma ação", + "sieve_editor": { + "title": "Editor de script Sieve", + "warning": "Editar o script Sieve pode interromper a edição visual de regras. As alterações feitas aqui substituem o construtor visual.", + "script_content": "Script Sieve", + "valid": "O script é válido", + "invalid": "O script contém erros", + "save_warning": "Guardar irá substituir todas as regras visuais. Esta ação não pode ser desfeita. Clique em Guardar novamente para confirmar.", + "validating": "A validar...", + "validate": "Validar", + "cancel": "Cancelar", + "save": "Guardar", + "confirm_save": "Confirmar guardar", + "validation_failed": "Falha no pedido de validação" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# condição} other {# condições}}", + "actions_count": "{count, plural, one {# ação} other {# ações}}" + } } }, "errors": { @@ -683,7 +799,7 @@ "not_spam": "Não é spam", "color_tag": "Etiqueta de Cor", "remove_color": "Remover Cor", - "items_selected": "{{count}} e-mails selecionados" + "items_selected": "{count} e-mails selecionados" }, "shortcuts": { "title": "Atalhos de Teclado", diff --git a/stores/auth-store.ts b/stores/auth-store.ts index e6af3f87..81603399 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -6,6 +6,8 @@ import { useIdentityStore } from './identity-store'; import { useContactStore } from './contact-store'; import { useVacationStore } from './vacation-store'; import { useCalendarStore } from './calendar-store'; +import { useFilterStore } from './filter-store'; +import { debug } from '@/lib/debug'; import type { Identity } from '@/lib/jmap/types'; interface AuthState { @@ -80,6 +82,13 @@ export const useAuthStore = create()( calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err)); } + // Initialize Sieve filters if supported + if (client.supportsSieve()) { + const filterStore = useFilterStore.getState(); + filterStore.setSupported(true); + filterStore.fetchFilters(client).catch((err) => debug.error('Failed to fetch filters:', err)); + } + // Success - save state (but NOT the password) set({ isAuthenticated: true, @@ -94,7 +103,7 @@ export const useAuthStore = create()( return true; } catch (error) { - console.error('Login error:', error); + debug.error('Login error:', error); let errorKey = 'generic'; // Map common errors to translation keys @@ -163,6 +172,9 @@ export const useAuthStore = create()( // Clear calendar store state useCalendarStore.getState().clearState(); + + // Clear filter store state + useFilterStore.getState().clearState(); }, checkAuth: async () => { diff --git a/stores/email-store.ts b/stores/email-store.ts index c4ed29f2..e9eca35c 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1022,6 +1022,17 @@ export const useEmailStore = create((set, get) => ({ } } } + + // Handle SieveScript state changes - refresh filter rules + if (accountChanges.SieveScript) { + const { useFilterStore } = await import('./filter-store'); + const filterStore = useFilterStore.getState(); + if (filterStore.isSupported) { + filterStore.fetchFilters(client).catch((err) => { + console.error('Failed to refresh filters:', err); + }); + } + } } catch (error) { console.error('Failed to handle state change:', error); set({ diff --git a/stores/filter-store.ts b/stores/filter-store.ts new file mode 100644 index 00000000..a5bc40d2 --- /dev/null +++ b/stores/filter-store.ts @@ -0,0 +1,168 @@ +import { create } from 'zustand'; +import type { JMAPClient } from '@/lib/jmap/client'; +import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types'; +import { parseScript } from '@/lib/sieve/parser'; +import { generateScript } from '@/lib/sieve/generator'; +import { debug } from '@/lib/debug'; + +interface FilterStore { + rules: FilterRule[]; + isLoading: boolean; + isSaving: boolean; + error: string | null; + isSupported: boolean; + sieveCapabilities: SieveCapabilities | null; + activeScriptId: string | null; + isOpaque: boolean; + rawScript: string; + + setSupported: (supported: boolean) => void; + fetchFilters: (client: JMAPClient) => Promise; + saveFilters: (client: JMAPClient) => Promise; + validateScript: (client: JMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>; + addRule: (rule: FilterRule) => void; + updateRule: (ruleId: string, updates: Partial) => void; + deleteRule: (ruleId: string) => void; + reorderRules: (ruleIds: string[]) => void; + toggleRule: (ruleId: string) => void; + setRawScript: (content: string) => void; + resetToVisualBuilder: () => void; + clearState: () => void; +} + +export const useFilterStore = create()((set, get) => ({ + rules: [], + isLoading: false, + isSaving: false, + error: null, + isSupported: false, + sieveCapabilities: null, + activeScriptId: null, + isOpaque: false, + rawScript: '', + + setSupported: (supported) => set({ isSupported: supported }), + + fetchFilters: async (client) => { + set({ isLoading: true, error: null }); + try { + const capabilities = client.getSieveCapabilities(); + set({ sieveCapabilities: capabilities }); + + const scripts = await client.getSieveScripts(); + debug.log('Sieve scripts fetched:', scripts.length); + + const activeScript = scripts.find(s => s.isActive) || scripts[0]; + if (!activeScript) { + set({ isLoading: false, rules: [], activeScriptId: null, rawScript: '', isOpaque: false }); + return; + } + + set({ activeScriptId: activeScript.id }); + + const content = await client.getSieveScriptContent(activeScript.blobId); + set({ rawScript: content }); + + const result = parseScript(content); + + if (result.isOpaque) { + debug.log('Sieve script is opaque (hand-edited)'); + set({ isLoading: false, isOpaque: true, rules: [] }); + } else { + debug.log('Parsed', result.rules.length, 'filter rules'); + set({ isLoading: false, isOpaque: false, rules: result.rules }); + } + } catch (error) { + debug.error('Failed to fetch filters:', error); + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch filters', + }); + } + }, + + saveFilters: async (client) => { + set({ isSaving: true, error: null }); + try { + const { isOpaque, rawScript, rules, activeScriptId } = get(); + + let content: string; + if (isOpaque) { + content = rawScript; + } else { + content = generateScript(rules); + } + + if (activeScriptId) { + await client.updateSieveScript(activeScriptId, content); + await client.activateSieveScript(activeScriptId); + } else { + const script = await client.createSieveScript('filters', content); + await client.activateSieveScript(script.id); + set({ activeScriptId: script.id }); + } + + set({ isSaving: false, rawScript: content }); + debug.log('Filters saved successfully'); + } catch (error) { + debug.error('Failed to save filters:', error); + set({ + isSaving: false, + error: error instanceof Error ? error.message : 'Failed to save filters', + }); + throw error; + } + }, + + validateScript: async (client, content) => { + return client.validateSieveScript(content); + }, + + addRule: (rule) => { + set((state) => ({ rules: [...state.rules, rule] })); + }, + + updateRule: (ruleId, updates) => { + set((state) => ({ + rules: state.rules.map(r => r.id === ruleId ? { ...r, ...updates } : r), + })); + }, + + deleteRule: (ruleId) => { + set((state) => ({ + rules: state.rules.filter(r => r.id !== ruleId), + })); + }, + + reorderRules: (ruleIds) => { + set((state) => { + const ruleMap = new Map(state.rules.map(r => [r.id, r])); + const reordered = ruleIds.map(id => ruleMap.get(id)).filter(Boolean) as FilterRule[]; + return { rules: reordered }; + }); + }, + + toggleRule: (ruleId) => { + set((state) => ({ + rules: state.rules.map(r => + r.id === ruleId ? { ...r, enabled: !r.enabled } : r + ), + })); + }, + + setRawScript: (content) => set({ rawScript: content }), + + resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [] }), + + clearState: () => set({ + rules: [], + isLoading: false, + isSaving: false, + error: null, + isSupported: false, + sieveCapabilities: null, + activeScriptId: null, + isOpaque: false, + rawScript: '', + }), +}));
{t("warning")}
{t("valid")}
{t("invalid")}
+ {err} +
{t("save_warning")}
{t("opaque_warning")}
{t("no_rules")}
+ {rule.name} +