feat: add email filters with JMAP Sieve Scripts (RFC 9661)

Server-side email filtering with visual rule builder and raw Sieve editor.
Conditions (From/To/Subject/Size/Body), actions (Move/Forward/Star/Discard),
auto-save with rollback, drag-and-drop reorder, opaque script reset,
accessibility focus traps, toast validation, and 8-language i18n support.
This commit is contained in:
Matthieu MALVACHE
2026-02-16 23:09:18 +01:00
committed by Matthieu MALVACHE
parent ff65693e26
commit 73612313c0
24 changed files with 3511 additions and 203 deletions
+9
View File
@@ -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
+14 -1
View File
@@ -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)
+5 -1
View File
@@ -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' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <CalendarSettings />}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</div>
</div>
+417
View File
@@ -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<FilterActionType>(["move", "copy", "forward", "reject", "add_label"]);
const ACTIONS_WITH_MAILBOX = new Set<FilterActionType>(["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<FilterCondition[]>(
rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()]
);
const [actions, setActions] = useState<FilterAction[]>(
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<FilterCondition>) => {
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<FilterAction>) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={isEdit ? t("edit_rule") : t("new_rule")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? t("edit_rule") : t("new_rule")}
</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={t("cancel")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-6">
<div>
<label className="text-sm font-medium mb-1 block text-foreground">
{t("rule_name")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("rule_name_placeholder")}
maxLength={200}
autoFocus
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block text-foreground">
{t("match_type")}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => 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")}
</button>
<button
type="button"
onClick={() => 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")}
</button>
</div>
</div>
<div>
<label className="text-sm font-medium mb-2 block text-foreground">
{t("conditions")}
</label>
<div className="space-y-2">
{conditions.map((condition, index) => (
<div key={index} className="flex items-center gap-2 flex-wrap">
<select
value={condition.field}
onChange={(e) =>
updateCondition(index, { field: e.target.value as FilterConditionField })
}
className={selectClass}
aria-label={t("conditions")}
>
{ALL_FIELDS.map((f) => (
<option key={f} value={f}>
{t(`condition_fields.${f}`)}
</option>
))}
</select>
{condition.field === "header" && (
<Input
value={condition.headerName || ""}
onChange={(e) =>
updateCondition(index, { headerName: e.target.value })
}
placeholder={t("header_name")}
className="w-28"
/>
)}
<select
value={condition.comparator}
onChange={(e) =>
updateCondition(index, { comparator: e.target.value as FilterComparator })
}
className={selectClass}
aria-label={t("comparators.contains")}
>
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
(c) => (
<option key={c} value={c}>
{t(`comparators.${c}`)}
</option>
)
)}
</select>
<Input
value={condition.value}
onChange={(e) => 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"}
/>
<button
type="button"
onClick={() => 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")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={() => setConditions((prev) => [...prev, makeEmptyCondition()])}
className="flex items-center gap-1 mt-2 text-sm text-primary hover:underline"
>
<Plus className="w-3.5 h-3.5" />
{t("add_condition")}
</button>
</div>
<div>
<label className="text-sm font-medium mb-2 block text-foreground">
{t("actions")}
</label>
<div className="space-y-2">
{actions.map((action, index) => (
<div key={index} className="flex items-center gap-2 flex-wrap">
<select
value={action.type}
onChange={(e) =>
updateAction(index, { type: e.target.value as FilterActionType })
}
className={selectClass}
aria-label={t("actions")}
>
{ALL_ACTION_TYPES.map((a) => (
<option key={a} value={a}>
{t(`action_types.${a}`)}
</option>
))}
</select>
{ACTIONS_WITH_MAILBOX.has(action.type) && (
<select
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
className={`${selectClass} flex-1 min-w-[140px]`}
aria-label={t("move_to_folder")}
>
<option value="">{t("move_to_folder")}</option>
{mailboxes.map((mb) => (
<option key={mb.id} value={mb.name}>
{mb.name}
</option>
))}
</select>
)}
{action.type === "forward" && (
<Input
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("forward_placeholder")}
type="email"
className="flex-1 min-w-[180px]"
/>
)}
{action.type === "reject" && (
<Input
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("reject_placeholder")}
className="flex-1 min-w-[180px]"
/>
)}
{action.type === "add_label" && (
<Input
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("label_placeholder")}
className="flex-1 min-w-[140px]"
/>
)}
<button
type="button"
onClick={() => 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")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={() => setActions((prev) => [...prev, makeEmptyAction()])}
className="flex items-center gap-1 mt-2 text-sm text-primary hover:underline"
>
<Plus className="w-3.5 h-3.5" />
{t("add_action")}
</button>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="stopProcessing"
checked={stopProcessing}
onChange={(e) => setStopProcessing(e.target.checked)}
className="rounded border-input"
/>
<label htmlFor="stopProcessing" className="text-sm text-foreground">
{t("stop_processing")}
</label>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
{t("save")}
</Button>
</div>
</div>
</div>
);
}
+194
View File
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={t("cancel")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>{t("warning")}</p>
</div>
<div className="flex-1 min-h-0 flex border border-border rounded-md overflow-hidden">
<div
className="w-10 flex-shrink-0 bg-muted border-r border-border py-2 text-right pr-2 select-none overflow-hidden"
aria-hidden="true"
>
{Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className="text-xs text-muted-foreground leading-[1.5rem]"
>
{i + 1}
</div>
))}
</div>
<textarea
ref={textareaRef}
value={script}
onChange={(e) => {
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")}
/>
</div>
{validationResult && (
<div
className={`flex items-start gap-2 p-3 rounded-md text-sm ${
validationResult.isValid
? "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400"
: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400"
}`}
>
{validationResult.isValid ? (
<>
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>{t("valid")}</p>
</>
) : (
<>
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium">{t("invalid")}</p>
{validationResult.errors?.map((err, i) => (
<p key={i} className="mt-1 font-mono text-xs">
{err}
</p>
))}
</div>
</>
)}
</div>
)}
{showSaveWarning && (
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>{t("save_warning")}</p>
</div>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
<Button
variant="outline"
onClick={handleValidate}
disabled={isValidating || !script.trim()}
>
{isValidating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t("validating")}
</>
) : (
t("validate")
)}
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!script.trim()}>
{showSaveWarning ? t("confirm_save") : t("save")}
</Button>
</div>
</div>
</div>
</div>
);
}
+468
View File
@@ -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 (
<span className="text-xs text-muted-foreground truncate">
{conditionSummary}{extra} {actionSummary}
</span>
);
}
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<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
const [showSieveEditor, setShowSieveEditor] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(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 (
<SettingsSection title={t("title")} description={t("description")}>
<div className="text-sm text-muted-foreground py-4">
{t("not_supported")}
</div>
</SettingsSection>
);
}
if (isLoading) {
return (
<SettingsSection title={t("title")} description={t("description")}>
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t("loading")}
</div>
</SettingsSection>
);
}
if (error) {
return (
<SettingsSection title={t("title")} description={t("description")}>
<div className="text-sm text-red-600 dark:text-red-400 py-4">
{t("fetch_error")}
</div>
</SettingsSection>
);
}
return (
<div className="space-y-6">
<SettingsSection title={t("title")} description={t("description")}>
{isOpaque && (
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p>{t("opaque_warning")}</p>
<div className="flex gap-3 mt-2">
<button
type="button"
onClick={() => setShowSieveEditor(true)}
className="text-primary hover:underline font-medium"
>
{t("open_sieve_editor")}
</button>
{showResetConfirm ? (
<span className="flex items-center gap-2">
<span className="text-red-600 dark:text-red-400">{t("reset_warning")}</span>
<button
type="button"
onClick={handleResetToVisual}
className="text-red-600 dark:text-red-400 hover:underline font-medium"
>
{t("confirm_reset")}
</button>
<button
type="button"
onClick={() => setShowResetConfirm(false)}
className="text-muted-foreground hover:underline"
>
{t("cancel")}
</button>
</span>
) : (
<button
type="button"
onClick={handleResetToVisual}
className="text-red-600 dark:text-red-400 hover:underline font-medium flex items-center gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_to_visual")}
</button>
)}
</div>
</div>
</div>
)}
{!isOpaque && rules.length === 0 && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<Filter className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">{t("no_rules")}</p>
</div>
)}
{!isOpaque && rules.length > 0 && (
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
{rules.map((rule, index) => (
<div
key={rule.id}
role="listitem"
draggable
onDragStart={(e) => 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" : ""}`}
>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
aria-label={t("drag_to_reorder")}
>
<GripVertical className="w-4 h-4" />
</div>
<ToggleSwitch
checked={rule.enabled}
onChange={() => handleToggle(rule.id)}
/>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEditingRule(rule);
setShowRuleModal(true);
}
}}
>
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
<RuleSummary rule={rule} />
</div>
{deleteConfirmId === rule.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(rule.id)}
>
{t("confirm_delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
{t("cancel")}
</Button>
</div>
) : (
<button
type="button"
onClick={() => 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")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
)}
</SettingsSection>
<div className="flex items-center justify-between">
<div className="flex gap-2">
{!isOpaque && (
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingRule(undefined);
setShowRuleModal(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
{t("add_rule")}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowSieveEditor(true)}
>
<Code className="w-4 h-4 mr-1" />
{t("raw_editor")}
</Button>
</div>
{isSaving && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t("saving")}
</div>
)}
</div>
{showRuleModal && (
<FilterRuleModal
rule={editingRule}
mailboxes={mailboxes}
onSave={handleSaveRule}
onClose={() => {
setShowRuleModal(false);
setEditingRule(undefined);
}}
/>
)}
{showSieveEditor && (
<SieveEditorModal
content={rawScript}
onSave={handleSaveSieve}
onClose={() => setShowSieveEditor(false)}
onValidate={handleValidate}
/>
)}
</div>
);
}
+242
View File
@@ -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<SieveScript[]> {
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<string> {
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<string> {
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<SieveScript> {
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<void> {
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<void> {
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<void> {
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<void> {
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) {
+55
View File
@@ -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[];
}
+1
View File
@@ -500,6 +500,7 @@ export interface StateChange {
AddressBook?: string;
Calendar?: string;
CalendarEvent?: string;
SieveScript?: string;
};
};
}
+402
View File
@@ -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> = {}): 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);
});
});
+147
View File
@@ -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> = {}): 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);
});
});
});
+169
View File
@@ -0,0 +1,169 @@
import type { FilterRule, FilterCondition, FilterAction, FilterMetadata } from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
const HEADER_MAP: Record<string, string> = {
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<string>();
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');
}
+68
View File
@@ -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<string, unknown>;
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<string, unknown>;
return typeof act.type === 'string';
}
function isValidRule(rule: unknown): rule is FilterRule {
if (!rule || typeof rule !== 'object') return false;
const r = rule as Record<string, unknown>;
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 };
}
+141 -25
View File
@@ -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",
+141 -25
View File
@@ -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",
+141 -25
View File
@@ -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",
+141 -25
View File
@@ -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",
+141 -25
View File
@@ -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",
+141 -25
View File
@@ -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": "キーボードショートカット",
+141 -25
View File
@@ -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",
+141 -25
View File
@@ -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",
+13 -1
View File
@@ -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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
// Clear calendar store state
useCalendarStore.getState().clearState();
// Clear filter store state
useFilterStore.getState().clearState();
},
checkAuth: async () => {
+11
View File
@@ -1022,6 +1022,17 @@ export const useEmailStore = create<EmailStore>((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({
+168
View File
@@ -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<void>;
saveFilters: (client: JMAPClient) => Promise<void>;
validateScript: (client: JMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial<FilterRule>) => 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<FilterStore>()((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: '',
}),
}));