mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
794 lines
30 KiB
TypeScript
794 lines
30 KiB
TypeScript
"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 { IconButton } from "@/components/ui/icon-button";
|
||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||
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,
|
||
Trash2,
|
||
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 (
|
||
<div className="text-xs text-muted-foreground break-words">
|
||
<span className="inline">
|
||
{conditions.map((cond, i) => (
|
||
<span key={i}>
|
||
{i > 0 && <span className="italic opacity-70"> {joiner} </span>}
|
||
{cond}
|
||
</span>
|
||
))}
|
||
{extra}
|
||
</span>
|
||
<span className="mx-1 opacity-50">→</span>
|
||
<span className="inline">
|
||
{actions.map((act, i) => (
|
||
<span key={i}>
|
||
{i > 0 && ", "}
|
||
{act}
|
||
</span>
|
||
))}
|
||
</span>
|
||
{period && <span className="ms-1">· {period.range}{period.status && ` (${period.status})`}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="mt-1.5 space-y-1 text-xs">
|
||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider text-blue-500 dark:text-blue-400">
|
||
{t("if")}
|
||
</span>
|
||
{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 (
|
||
<span key={i} className="contents">
|
||
{i > 0 && (
|
||
<span className="text-[10px] text-muted-foreground/70 italic">{joiner}</span>
|
||
)}
|
||
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||
<span className="shrink-0 font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||
{!everyMessage && <span className="shrink-0 text-muted-foreground">{comparator}</span>}
|
||
{!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.
|
||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-1 text-foreground">
|
||
{(Array.isArray(c.value) ? c.value : [c.value]).map((v, k) => (
|
||
<span key={k} className="min-w-0 wrap-anywhere">
|
||
{k > 0 && (
|
||
<span className="text-muted-foreground/70 italic">{t("or")} </span>
|
||
)}
|
||
“{v}”
|
||
</span>
|
||
))}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</span>
|
||
);
|
||
})}
|
||
<span className="text-[10px] text-muted-foreground/60 italic">({matchLabel})</span>
|
||
</div>
|
||
|
||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider text-emerald-500 dark:text-emerald-400">
|
||
{t("then")}
|
||
</span>
|
||
{rule.actions.map((a, i) => {
|
||
const action = t(`action_types.${a.type}`);
|
||
return (
|
||
<span key={i} className="contents">
|
||
{i > 0 && (
|
||
<span className="text-muted-foreground/50">›</span>
|
||
)}
|
||
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||
<span className="shrink-0 font-medium text-emerald-600 dark:text-emerald-400">{action}</span>
|
||
{a.value && <span className="min-w-0 wrap-anywhere text-muted-foreground">“{a.value}”</span>}
|
||
</span>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{period && (
|
||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider text-violet-500 dark:text-violet-400">
|
||
{t("period_title")}
|
||
</span>
|
||
<span className="min-w-0 max-w-full px-1.5 py-px rounded-sm bg-muted/60 text-foreground wrap-anywhere">
|
||
{period.range}
|
||
</span>
|
||
{period.status && (
|
||
<span className="text-[10px] text-muted-foreground/60 italic">({period.status})</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<Mailbox[]>([]);
|
||
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<typeof client | null>(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<FilterRule | undefined>();
|
||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||
const [showSieveEditor, setShowSieveEditor] = useState(false);
|
||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||
const draggedIndexRef = useRef<number | null>(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]
|
||
);
|
||
|
||
// A rule can be put back as it was, so it goes at once with an Undo (R3).
|
||
const handleDelete = useCallback(
|
||
async (ruleId: string) => {
|
||
const before = useFilterStore.getState().rules;
|
||
const deletedRule = before.find((r) => r.id === ruleId);
|
||
if (!deletedRule) return;
|
||
deleteRule(ruleId);
|
||
if (!client) return;
|
||
const putBack = () => useFilterStore.setState({ rules: before });
|
||
try {
|
||
await saveFilters(client);
|
||
} catch {
|
||
putBack();
|
||
toast.error(tNotifications("filters_save_failed"));
|
||
return;
|
||
}
|
||
toast.success(tNotifications("filters_deleted"), {
|
||
action: {
|
||
label: tNotifications("rule_undo"),
|
||
onClick: () => {
|
||
if (useFilterStore.getState().rules.some((r) => r.id === ruleId)) return;
|
||
const current = useFilterStore.getState().rules;
|
||
putBack();
|
||
saveFilters(client).catch(() => {
|
||
useFilterStore.setState({ rules: current });
|
||
toast.error(tNotifications("filters_save_failed"));
|
||
});
|
||
},
|
||
},
|
||
});
|
||
},
|
||
[client, deleteRule, 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 { dialogProps: confirmProps, confirm } = useConfirmDialog();
|
||
// The script typed in the raw editor is thrown away, so it asks first (R3).
|
||
const handleResetToVisual = useCallback(async () => {
|
||
const ok = await confirm({
|
||
title: t("reset_title"),
|
||
message: t("reset_warning"),
|
||
confirmText: t("confirm_reset"),
|
||
variant: "destructive",
|
||
});
|
||
if (!ok) return;
|
||
resetToVisualBuilder();
|
||
if (client) {
|
||
try {
|
||
await saveFilters(client);
|
||
toast.success(tNotifications("filters_saved"));
|
||
} catch {
|
||
toast.error(tNotifications("filters_save_failed"));
|
||
}
|
||
}
|
||
}, [confirm, t, 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>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<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 && !editing) {
|
||
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 id="rules" 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>
|
||
<button
|
||
type="button"
|
||
onClick={() => void 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 && vacationEnabled && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
try { localStorage.setItem('settings-active-tab', 'vacation'); } catch { /* ignore */ }
|
||
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: 'vacation' }));
|
||
}}
|
||
className="flex items-center gap-3 w-full p-3 rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-start"
|
||
>
|
||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/40">
|
||
<PalmtreeIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-green-700 dark:text-green-400">
|
||
{t("vacation_active")}
|
||
</p>
|
||
<p className="text-xs text-green-600/70 dark:text-green-400/70">
|
||
{t("vacation_active_description")}
|
||
</p>
|
||
</div>
|
||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||
{t("vacation_configure")} →
|
||
</span>
|
||
</button>
|
||
)}
|
||
|
||
{!isOpaque && rules.length === 0 && !vacationEnabled && (
|
||
<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 && forwardsIgnored && (
|
||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||
{t("forward_unavailable")}
|
||
</p>
|
||
)}
|
||
|
||
{!isOpaque && tooManyForwards && (
|
||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||
{t("forward_limit", { count: redirectLimit })}
|
||
</p>
|
||
)}
|
||
|
||
{!isOpaque && rules.length > 0 && (
|
||
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
|
||
{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 (
|
||
<div
|
||
key={rule.id}
|
||
role="listitem"
|
||
className="flex items-start gap-3 p-3 rounded-md border border-border"
|
||
title={tooltip}
|
||
>
|
||
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
|
||
<Lock className="w-4 h-4" />
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<p className="text-sm font-medium text-foreground truncate">
|
||
{rule.name}
|
||
</p>
|
||
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[10px]">
|
||
{label}
|
||
</span>
|
||
</div>
|
||
{hasStructuredSummary ? (
|
||
expandedFilterView ? (
|
||
<VisualRuleSummary rule={rule} />
|
||
) : (
|
||
<RuleSummary rule={rule} />
|
||
)
|
||
) : rule.rawBlock ? (
|
||
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
|
||
{rule.rawBlock.trim()}
|
||
</pre>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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-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" : ""}`}
|
||
>
|
||
<div
|
||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||
aria-label={t("drag_to_reorder")}
|
||
>
|
||
<GripVertical className="w-4 h-4" />
|
||
</div>
|
||
|
||
<div className="pt-0.5">
|
||
<ToggleSwitch
|
||
checked={rule.enabled}
|
||
onChange={() => handleToggle(rule.id)}
|
||
ariaLabel={rule.name}
|
||
/>
|
||
</div>
|
||
|
||
<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>
|
||
{expandedFilterView ? (
|
||
<VisualRuleSummary rule={rule} />
|
||
) : (
|
||
<RuleSummary rule={rule} />
|
||
)}
|
||
</div>
|
||
|
||
<IconButton
|
||
label={t("delete_rule")}
|
||
onClick={() => void handleDelete(rule.id)}
|
||
className="text-muted-foreground hover:text-destructive"
|
||
>
|
||
<Trash2 />
|
||
</IconButton>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</SettingsSection>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex gap-2">
|
||
{!isOpaque && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
data-search-label={t("add_rule")}
|
||
onClick={() => {
|
||
setEditingRule(undefined);
|
||
setShowRuleModal(true);
|
||
}}
|
||
>
|
||
<Plus className="w-4 h-4 me-1" />
|
||
{t("add_rule")}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
data-search-label={t("raw_editor")}
|
||
onClick={() => setShowSieveEditor(true)}
|
||
>
|
||
<Code className="w-4 h-4 me-1" />
|
||
{t("raw_editor")}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{isSaving && (
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
{t("saving")}
|
||
</div>
|
||
)}
|
||
{!isOpaque && rules.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground">{t("expanded_view")}</span>
|
||
<ToggleSwitch
|
||
checked={expandedFilterView}
|
||
onChange={(v) => updateSetting("expandedFilterView", v)}
|
||
ariaLabel={t("expanded_view")}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{showRuleModal && (
|
||
<FilterRuleModal
|
||
rule={editingRule}
|
||
mailboxes={mailboxes}
|
||
maxRedirects={sieveCapabilities?.maxNumberRedirects}
|
||
periodsSupported={supportsPeriods(sieveCapabilities?.sieveExtensions)}
|
||
forwardsBefore={forwardsOfEdited.before}
|
||
forwardsAfter={forwardsOfEdited.after}
|
||
onSave={handleSaveRule}
|
||
onClose={() => {
|
||
setShowRuleModal(false);
|
||
setEditingRule(undefined);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{showSieveEditor && (
|
||
<SieveEditorModal
|
||
content={rawScript}
|
||
onSave={handleSaveSieve}
|
||
onClose={() => setShowSieveEditor(false)}
|
||
onValidate={handleValidate}
|
||
/>
|
||
)}
|
||
<ConfirmDialog {...confirmProps} />
|
||
</div>
|
||
);
|
||
}
|