{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})
);
}
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 [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]
);
// 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);
},
[]
);
// Dragging and the Move up / Move down buttons both end here.
const moveRule = useCallback(
async (fromIndex: number, dropIndex: number) => {
if (fromIndex === dropIndex || dropIndex < 0 || dropIndex >= rules.length) 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 handleDrop = useCallback(
async (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
setDragOverIndex(null);
const fromIndex = draggedIndexRef.current;
if (fromIndex === null) return;
await moveRule(fromIndex, dropIndex);
},
[moveRule]
);
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 (
{/* The order decides which rule runs first: also by keyboard. */}
void moveRule(from, to)}
/>
void handleDelete(rule.id)}
className="text-muted-foreground hover:text-destructive"
>