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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user