"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 { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; import type { FilterRule } from "@/lib/jmap/sieve-types"; import type { Mailbox } from "@/lib/jmap/types"; import { forwardsAround, inRunOrder, worstCaseForwards } from "@/lib/filters/forward-limit"; import { isPeriodBoundary, periodStatus, supportsPeriods } from "@/lib/sieve/period"; import { formatDateTime } from "@/lib/utils"; import { useVacationStore } from "@/stores/vacation-store"; import { useManagedAccountStore } from "@/stores/managed-account-store"; import { Plus, GripVertical, X, Code, AlertTriangle, Loader2, Filter, RotateCcw, PalmtreeIcon, Lock, } from "@/components/icons"; function isReadonlyRule(r: FilterRule): boolean { return r.origin === "external" || r.origin === "opaque"; } /** * The rule's period ("5 Oct 2026, 08:00 – …") and, for a rule that is on, * where now falls in it; null without a period. */ function usePeriodLabel(rule: FilterRule): { range: string; status: string | null } | null { const t = useTranslations("settings.filters"); const timeFormat = useSettingsStore((s) => s.timeFormat); const status = periodStatus(rule); if (!status) return null; const format = (boundary: string | undefined) => isPeriodBoundary(boundary) ? formatDateTime(boundary, timeFormat, { day: "numeric", month: "short", year: "numeric" }) : "…"; return { range: `${format(rule.activeFrom)} – ${format(rule.activeUntil)}`, // A rule that is off does nothing in any period; "active" next to it // would say otherwise. status: rule.enabled ? t(`period_status_${status}`) : null, }; } export function RuleSummary({ rule }: { rule: FilterRule }) { const t = useTranslations("settings.filters"); const period = usePeriodLabel(rule); const conditions = rule.conditions.slice(0, 2).map((c) => { const field = t(`condition_fields.${c.field}`); // "All messages" says it all: no comparator, no value. if (c.field === "all") return field; const comparator = t(`comparators.${c.comparator}`); // has_any is a no-value test ("attachment is present"); appending // `""` would look broken in the summary line. if (c.field === "attachment" && c.comparator === "has_any") { return `${field} ${comparator}`; } // Multi-value conditions render as "a" / "b" / "c" with the locale's // OR-glue between items so the line still reads as natural language. const valueStr = Array.isArray(c.value) ? c.value.map((v) => `"${v}"`).join(` ${t("or")} `) : `"${c.value}"`; return `${field} ${comparator} ${valueStr}`; }); const joiner = rule.matchType === "all" ? t("and") : t("or"); const extra = rule.conditions.length > 2 ? ` (+${rule.conditions.length - 2})` : ""; const actions = rule.actions.slice(0, 2).map((a) => { const action = t(`action_types.${a.type}`); return a.value ? `${action} "${a.value}"` : action; }); return (
{conditions.map((cond, i) => ( {i > 0 && {joiner} } {cond} ))} {extra} → {actions.map((act, i) => ( {i > 0 && ", "} {act} ))} {period && · {period.range}{period.status && ` (${period.status})`}}
); } export function VisualRuleSummary({ rule }: { rule: FilterRule }) { const t = useTranslations("settings.filters"); const period = usePeriodLabel(rule); const joiner = rule.matchType === "all" ? t("and") : t("or"); const matchLabel = rule.matchType === "all" ? t("match_all_conditions") : t("match_any_condition"); return (
{t("if")} {rule.conditions.map((c, i) => { const field = t(`condition_fields.${c.field}`); // "All messages" says it all: no comparator, no value. const everyMessage = c.field === "all"; const comparator = everyMessage ? null : t(`comparators.${c.comparator}`); return ( {i > 0 && ( {joiner} )} {field} {!everyMessage && {comparator}} {!everyMessage && !(c.field === "attachment" && c.comparator === "has_any") && ( // The values of one condition stay in its chip, where "or" // cannot be mistaken for the joiner between conditions. They // wrap between each other under the first value, the "or" // leading the next line; a single value too long for any // line breaks inside itself instead of leaving the card. {(Array.isArray(c.value) ? c.value : [c.value]).map((v, k) => ( {k > 0 && ( {t("or")} )} “{v}” ))} )} ); })} ({matchLabel})
{t("then")} {rule.actions.map((a, i) => { const action = t(`action_types.${a.type}`); return ( {i > 0 && ( › )} {action} {a.value && “{a.value}”} ); })}
{period && (
{t("period_title")} {period.range} {period.status && ( ({period.status}) )}
)}
); } export function FilterSettings() { const t = useTranslations("settings.filters"); const tNotifications = useTranslations("notifications"); const { client } = useAuthStore(); const storeMailboxes = useEmailStore((s) => s.mailboxes); const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const expandedFilterView = useSettingsStore((s) => s.expandedFilterView); const updateSetting = useSettingsStore((s) => s.updateSetting); const { rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings, includeVacation, vacationForward, sieveCapabilities, selectAccount, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule, setRawScript, resetToVisualBuilder, validateScript, } = useFilterStore(); // Scoped to a shared/group account when the settings panel is managing one. const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); const isPrimaryAccount = !managedAccountId; // Folders offered as "move to" targets in the rule editor must belong to the // account whose filters we're editing. For a shared account, fetch that // account's mailboxes (the email store only holds the active account's). They // are this account's own folders within its Sieve context, so present them as // non-shared — otherwise the rule modal filters them out (it drops isShared // mailboxes, which are normally other accounts' folders merged into the view). const [scopedMailboxes, setScopedMailboxes] = useState([]); useEffect(() => { if (!client || !managedAccountId) { setScopedMailboxes([]); return; } let cancelled = false; void client .getMailboxes(managedAccountId) .then((mbs) => { if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false }))); }) .catch(() => { if (!cancelled) setScopedMailboxes([]); }); return () => { cancelled = true; }; }, [client, managedAccountId]); // The "move to" folder list for the primary account comes from the email // store, which is normally populated when the mail view mounts. When the app // is opened or refreshed directly on Settings (the mail view never mounted), // that store is empty, leaving the rule editor's folder dropdown blank. Fetch // mailboxes on demand here so Filters never depends on having visited Inbox // first. fetchMailboxes guards against transient empty results and selecting // an inbox, so it's safe to call independently; a ref keeps it to one attempt // per client. const primaryFetchClientRef = useRef(null); useEffect(() => { if (managedAccountId || !client || storeMailboxes.length > 0) return; if (primaryFetchClientRef.current === client) return; primaryFetchClientRef.current = client; void fetchMailboxes(client); }, [client, managedAccountId, storeMailboxes.length, fetchMailboxes]); const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes; const vacationStoreEnabled = useVacationStore((s) => s.isEnabled); // Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the // "vacation active" banner only applies when editing the personal account. const vacationEnabled = isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled || includeVacation); const [editingRule, setEditingRule] = useState(); const [showRuleModal, setShowRuleModal] = useState(false); const [showSieveEditor, setShowSieveEditor] = useState(false); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); const [dragOverIndex, setDragOverIndex] = useState(null); const draggedIndexRef = useRef(null); // A message can collect only so many forwards on this server. The rules run // in the order listed, behind the out of office forwarding, and one that // stops ends the script for its messages, so the order counts (see // lib/filters/forward-limit.ts). const redirectLimit = sieveCapabilities?.maxNumberRedirects; const runOrder = inRunOrder(rules, vacationForward); const tooManyForwards = typeof redirectLimit === "number" && redirectLimit > 0 && worstCaseForwards(runOrder) > redirectLimit; // A limit of 0: the server runs no redirect, so the rules' forwards do nothing. const forwardsIgnored = redirectLimit === 0 && worstCaseForwards(rules) > 0; // The edited rule where it is; a new one goes below Bulwark's own rules. const editedIndex = editingRule ? rules.findIndex((r) => r.id === editingRule.id) : -1; const forwardsOfEdited = forwardsAround( runOrder, runOrder.length - rules.length + (editedIndex >= 0 ? editedIndex : rules.filter((r) => !isReadonlyRule(r)).length), editedIndex >= 0, ); useEffect(() => { if (client && isSupported) { // Always pass a concrete account id (managed shared account, or the // primary Sieve account) so switching never falls back to a stale // previously-selected account. void selectAccount(client, managedAccountId ?? client.getSieveAccountId()); } }, [client, isSupported, managedAccountId, selectAccount]); const handleToggle = useCallback( async (ruleId: string) => { toggleRule(ruleId); if (client) { try { await saveFilters(client); } catch { toggleRule(ruleId); toast.error(tNotifications("filters_save_failed")); } } }, [client, toggleRule, saveFilters, tNotifications] ); const handleDelete = useCallback( async (ruleId: string) => { const deletedRule = rules.find((r) => r.id === ruleId); deleteRule(ruleId); setDeleteConfirmId(null); if (client) { try { await saveFilters(client); toast.success(tNotifications("filters_deleted")); } catch { if (deletedRule) addRule(deletedRule); toast.error(tNotifications("filters_save_failed")); } } }, [client, rules, deleteRule, addRule, saveFilters, tNotifications] ); const handleSaveRule = useCallback( async (rule: FilterRule) => { const previousRules = [...rules]; if (editingRule) { updateRule(rule.id, rule); } else { addRule(rule); } setShowRuleModal(false); setEditingRule(undefined); if (client) { try { await saveFilters(client); } catch { useFilterStore.setState({ rules: previousRules }); toast.error(tNotifications("filters_save_failed")); } } }, [editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications] ); const handleSaveSieve = useCallback( async (content: string) => { setRawScript(content); useFilterStore.setState({ isOpaque: true, rules: [] }); if (client) { try { await saveFilters(client); toast.success(tNotifications("filters_saved")); setShowSieveEditor(false); } catch { toast.error(tNotifications("filters_save_failed")); } } }, [client, setRawScript, saveFilters, tNotifications] ); const handleResetToVisual = useCallback(async () => { if (!showResetConfirm) { setShowResetConfirm(true); return; } resetToVisualBuilder(); setShowResetConfirm(false); if (client) { try { await saveFilters(client); toast.success(tNotifications("filters_saved")); } catch { toast.error(tNotifications("filters_save_failed")); } } }, [showResetConfirm, resetToVisualBuilder, client, saveFilters, tNotifications]); const handleValidate = useCallback( async (content: string) => { if (!client) return { isValid: false, errors: ["No client"] }; return validateScript(client, content); }, [client, validateScript] ); const handleDragStart = useCallback( (e: React.DragEvent, index: number) => { draggedIndexRef.current = index; e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(index)); }, [] ); const handleDragOver = useCallback( (e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOverIndex(index); }, [] ); const handleDrop = useCallback( async (e: React.DragEvent, dropIndex: number) => { e.preventDefault(); setDragOverIndex(null); const fromIndex = draggedIndexRef.current; if (fromIndex === null || fromIndex === dropIndex) return; const previousOrder = rules.map((r) => r.id); const newOrder = [...previousOrder]; const [moved] = newOrder.splice(fromIndex, 1); newOrder.splice(dropIndex, 0, moved); reorderRules(newOrder); if (client) { try { await saveFilters(client); } catch { reorderRules(previousOrder); toast.error(tNotifications("filters_save_failed")); } } }, [rules, reorderRules, client, saveFilters, tNotifications] ); const handleDragEnd = useCallback(() => { draggedIndexRef.current = null; setDragOverIndex(null); }, []); if (!isSupported) { return (
{t("not_supported")}
); } // A reload in the background (another device or tab changed the script) // must not take away a dialog the user is typing in: while one is open, // the page keeps what it has. const editing = showRuleModal || showSieveEditor; if (isLoading && !editing) { return (
{t("loading")}
); } if (error && !editing) { return (
{t("fetch_error")}
); } return (
{isOpaque && (

{t("opaque_warning")}

{showResetConfirm ? ( {t("reset_warning")} ) : ( )}
)} {!isOpaque && vacationEnabled && ( )} {!isOpaque && rules.length === 0 && !vacationEnabled && (

{t("no_rules")}

)} {!isOpaque && forwardsIgnored && (

{t("forward_unavailable")}

)} {!isOpaque && tooManyForwards && (

{t("forward_limit", { count: redirectLimit })}

)} {!isOpaque && rules.length > 0 && (
{rules.map((rule, index) => { const readonly = isReadonlyRule(rule); if (readonly) { const label = rule.originLabel || t("origin_external"); const tooltip = t("managed_by_tooltip", { source: label }); const hasStructuredSummary = rule.origin === "external" && rule.conditions.length > 0 && rule.actions.length > 0; return (

{rule.name}

{label}
{hasStructuredSummary ? ( expandedFilterView ? ( ) : ( ) ) : rule.rawBlock ? (
                          {rule.rawBlock.trim()}
                        
) : null}
); } return (
handleDragStart(e, index)} onDragOver={(e) => handleDragOver(e, index)} onDrop={(e) => handleDrop(e, index)} onDragEnd={handleDragEnd} className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${ dragOverIndex === index ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50" } ${!rule.enabled ? "opacity-60" : ""}`} >
handleToggle(rule.id)} />
{ setEditingRule(rule); setShowRuleModal(true); }} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setEditingRule(rule); setShowRuleModal(true); } }} >

{rule.name}

{expandedFilterView ? ( ) : ( )}
{deleteConfirmId === rule.id ? (
) : ( )}
); })}
)}
{!isOpaque && ( )}
{isSaving && (
{t("saving")}
)} {!isOpaque && rules.length > 0 && (
{t("expanded_view")} updateSetting("expandedFilterView", v)} />
)}
{showRuleModal && ( { setShowRuleModal(false); setEditingRule(undefined); }} /> )} {showSieveEditor && ( setShowSieveEditor(false)} onValidate={handleValidate} /> )}
); }