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
+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>
);
}