mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
818 lines
27 KiB
TypeScript
818 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import type { Email, Mailbox } from "@/lib/jmap/types";
|
|
import { ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
|
import {
|
|
Archive,
|
|
Ban,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
File,
|
|
Filter,
|
|
Folder,
|
|
FolderInput,
|
|
FolderPlus,
|
|
Inbox,
|
|
List,
|
|
MailOpen,
|
|
Play,
|
|
Plus,
|
|
Send,
|
|
Settings,
|
|
Tag,
|
|
Trash2,
|
|
} from "@/components/icons";
|
|
import { useRouter } from "@/i18n/navigation";
|
|
import { buildMailboxTree, cn, generateUUID, type MailboxNode } from "@/lib/utils";
|
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useFilterStore } from "@/stores/filter-store";
|
|
import { useQuickRuleStore, type FiltersStatus } from "@/stores/quick-rule-store";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
|
import {
|
|
buildPrefillRule,
|
|
buildSuggestions,
|
|
collectSenders,
|
|
findJunkMailbox,
|
|
ruleTargetMailboxIds,
|
|
rulesMenuAvailability,
|
|
sharedDomain,
|
|
sharedListId,
|
|
toRuleMailbox,
|
|
type QuickRulePreset,
|
|
type QuickRuleSubject,
|
|
type RuleMailbox,
|
|
type RulesMenuAvailability,
|
|
} from "@/lib/filters/quick-rules";
|
|
import {
|
|
getOwnAddresses,
|
|
knownListId,
|
|
loadListIds,
|
|
resolveMailboxRuleTarget,
|
|
resolveQuickRuleTarget,
|
|
sourceMailboxOf,
|
|
type QuickRuleTarget,
|
|
} from "@/lib/filters/quick-rule-target";
|
|
import {
|
|
ensureFiltersStatus,
|
|
openFilterSettings,
|
|
runPresetRule,
|
|
runRuleOnFolder,
|
|
type QuickRuleText,
|
|
} from "@/lib/filters/quick-rule-flow";
|
|
import { readAccountFilters } from "@/lib/filters/account-filters";
|
|
import { retroactiveSupport } from "@/lib/filters/retroactive";
|
|
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
|
|
|
/** Translators for the rule flows, which outlive the menu that starts them. */
|
|
export function useQuickRuleText(): QuickRuleText {
|
|
const tNotifications = useTranslations("notifications");
|
|
const tFilters = useTranslations("settings.filters");
|
|
const tMenu = useTranslations("context_menu");
|
|
return useMemo(() => ({
|
|
notifications: (key, values) => tNotifications(key, values),
|
|
filters: (key, values) => tFilters(key, values),
|
|
menu: (key, values) => tMenu(key, values),
|
|
}), [tNotifications, tFilters, tMenu]);
|
|
}
|
|
|
|
/**
|
|
* Whether messages get a Rules entry, and for which account. Every message
|
|
* of a selection has to belong to the same account; shared/group accounts
|
|
* and accounts without Sieve get none.
|
|
*/
|
|
export function useRulesAvailability(emails: Email[]): { availability: RulesMenuAvailability; target: QuickRuleTarget | null } {
|
|
// The auth client is read by the resolution; a login change re-resolves.
|
|
const client = useAuthStore((s) => s.client);
|
|
return useMemo(() => {
|
|
if (!client) return { availability: "hidden", target: null };
|
|
const targets = emails.map((email) => resolveQuickRuleTarget(email));
|
|
const availability = rulesMenuAvailability(
|
|
targets.map((t) => t && { key: t.key, shared: t.shared, supportsSieve: t.supportsSieve }),
|
|
);
|
|
return { availability, target: availability === "available" ? targets[0] : null };
|
|
}, [emails, client]);
|
|
}
|
|
|
|
/** Whether the account's script can take a rule; the filter store answers for the account it holds. */
|
|
function useFiltersStatus(target: QuickRuleTarget): FiltersStatus {
|
|
const authClient = useAuthStore((s) => s.client);
|
|
const selectedAccountId = useFilterStore((s) => s.selectedAccountId);
|
|
const isSupported = useFilterStore((s) => s.isSupported);
|
|
const isLoading = useFilterStore((s) => s.isLoading);
|
|
const error = useFilterStore((s) => s.error);
|
|
const isOpaque = useFilterStore((s) => s.isOpaque);
|
|
const cached = useQuickRuleStore((s) => s.statuses[target.key]?.status);
|
|
const storeHolds = authClient === target.client
|
|
&& selectedAccountId === target.sieveAccountId
|
|
&& isSupported && !isLoading && !error;
|
|
|
|
useEffect(() => {
|
|
if (!storeHolds) ensureFiltersStatus(target);
|
|
}, [target, storeHolds]);
|
|
|
|
if (storeHolds) return isOpaque ? "opaque" : "ready";
|
|
return cached ?? "loading";
|
|
}
|
|
|
|
function filterTree(nodes: MailboxNode[], targetIds: Set<string>): MailboxNode[] {
|
|
return nodes.reduce<MailboxNode[]>((acc, node) => {
|
|
const children = filterTree(node.children, targetIds);
|
|
if (targetIds.has(node.id) || children.length > 0) acc.push({ ...node, children });
|
|
return acc;
|
|
}, []);
|
|
}
|
|
|
|
const mailboxIcon = (role?: string) => {
|
|
switch (role) {
|
|
case "inbox": return Inbox;
|
|
case "sent": return Send;
|
|
case "drafts": return File;
|
|
case "trash": return Trash2;
|
|
case "archive": return Archive;
|
|
default: return Folder;
|
|
}
|
|
};
|
|
|
|
function truncate(value: string, max = 36): string {
|
|
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
}
|
|
|
|
type MovePresetKind = "move_sender" | "move_domain" | "move_list";
|
|
|
|
/** Everything the Rules menu shows and does, for one message or a selection. */
|
|
function useRulesModel(anchor: Email, senderEmails: Email[], target: QuickRuleTarget) {
|
|
const text = useQuickRuleText();
|
|
const tMenu = useTranslations("context_menu");
|
|
const router = useRouter();
|
|
const { tagName, tagColor } = useKeywordFormat();
|
|
const emailKeywords = useSettingsStore((s) => s.emailKeywords);
|
|
const status = useFiltersStatus(target);
|
|
|
|
const own = useMemo(() => getOwnAddresses(), []);
|
|
const senders = useMemo(() => collectSenders(senderEmails, own), [senderEmails, own]);
|
|
|
|
// Rows carry no headers, so the List-Id is asked for when the menu opens.
|
|
const [listIds, setListIds] = useState(() => senderEmails.map((e) => knownListId(target, e)));
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setListIds(senderEmails.map((e) => knownListId(target, e)));
|
|
loadListIds(target, senderEmails)
|
|
.then(() => { if (!cancelled) setListIds(senderEmails.map((e) => knownListId(target, e))); })
|
|
.catch(() => { /* no list preset then */ });
|
|
return () => { cancelled = true; };
|
|
}, [target, senderEmails]);
|
|
|
|
const subject: QuickRuleSubject = useMemo(() => ({
|
|
senders,
|
|
domain: sharedDomain(senders),
|
|
listId: sharedListId(listIds),
|
|
}), [senders, listIds]);
|
|
|
|
const targetIds = useMemo(() => ruleTargetMailboxIds(target.mailboxes), [target]);
|
|
const tree = useMemo(() => filterTree(buildMailboxTree(target.mailboxes), targetIds), [target, targetIds]);
|
|
const junk = useMemo(() => findJunkMailbox(target.mailboxes), [target]);
|
|
const sourceMailboxId = useMemo(() => sourceMailboxOf(anchor, target)?.id ?? null, [anchor, target]);
|
|
|
|
const senderLabel = senders.length === 1
|
|
? truncate(senders[0].name || senders[0].email)
|
|
: tMenu("rules.senders_count", { count: senders.length });
|
|
const senderTitle = senders.map((s) => s.email).join(", ");
|
|
|
|
const run = (preset: QuickRulePreset) => {
|
|
void runPresetRule({ target, preset, subject, sourceMailboxId, text });
|
|
};
|
|
const moveTo = (kind: MovePresetKind, mailbox: Mailbox) => {
|
|
run({ kind, mailbox: toRuleMailbox(target.mailboxes, mailbox) });
|
|
};
|
|
const moveToNewFolder = (kind: MovePresetKind) => {
|
|
useQuickRuleStore.getState().requestNewFolder({
|
|
target,
|
|
subject,
|
|
sourceMailboxId,
|
|
preset: (mailbox: RuleMailbox) => ({ kind, mailbox }),
|
|
});
|
|
};
|
|
const createRule = () => {
|
|
// A thread row's own reply stands aside for the message it answered.
|
|
const source = senderEmails.length === 1 ? senderEmails[0] : anchor;
|
|
useQuickRuleStore.getState().openEditor({
|
|
mode: "prefill",
|
|
target,
|
|
rule: buildPrefillRule(subject, text.filters, generateUUID()),
|
|
suggestions: buildSuggestions(source, subject, own, text.filters),
|
|
sourceMailboxId,
|
|
});
|
|
};
|
|
const manageRules = () => {
|
|
void openFilterSettings(target, (href) => router.push(href));
|
|
};
|
|
|
|
return {
|
|
status,
|
|
locked: status === "opaque",
|
|
subject,
|
|
senders,
|
|
senderLabel,
|
|
senderTitle,
|
|
tree,
|
|
targetIds,
|
|
junk,
|
|
tags: emailKeywords.map((kw) => ({ id: kw.id, name: tagName(kw.id), dot: tagColor(kw.id).dot })),
|
|
run,
|
|
moveTo,
|
|
moveToNewFolder,
|
|
createRule,
|
|
manageRules,
|
|
};
|
|
}
|
|
|
|
type RulesModel = ReturnType<typeof useRulesModel>;
|
|
|
|
// ── Right-click menu ─────────────────────────────────────────────────────
|
|
|
|
function ContextFolderItems({
|
|
model,
|
|
kind,
|
|
onDone,
|
|
}: {
|
|
model: RulesModel;
|
|
kind: MovePresetKind;
|
|
onDone: () => void;
|
|
}) {
|
|
const tSidebar = useTranslations("sidebar");
|
|
const tFolderMenu = useTranslations("mailbox_context_menu");
|
|
const render = (nodes: MailboxNode[]): React.ReactNode => nodes.map((node) => {
|
|
const Icon = mailboxIcon(node.role);
|
|
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
|
return (
|
|
<div key={node.id}>
|
|
{model.targetIds.has(node.id) ? (
|
|
<ContextMenuItem
|
|
icon={Icon}
|
|
label={label}
|
|
testId={`rules:${kind}:${node.id}`}
|
|
onClick={() => { model.moveTo(kind, node); onDone(); }}
|
|
/>
|
|
) : (
|
|
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
|
<span>{label}</span>
|
|
</div>
|
|
)}
|
|
{node.children.length > 0 && <div className="ps-4">{render(node.children)}</div>}
|
|
</div>
|
|
);
|
|
});
|
|
return (
|
|
<>
|
|
{render(model.tree)}
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
icon={FolderPlus}
|
|
label={tFolderMenu("new_folder")}
|
|
testId={`rules:${kind}:new-folder`}
|
|
onClick={() => { model.moveToNewFolder(kind); onDone(); }}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ContextRulesContent({
|
|
anchor,
|
|
senderEmails,
|
|
target,
|
|
onDone,
|
|
}: {
|
|
anchor: Email;
|
|
senderEmails: Email[];
|
|
target: QuickRuleTarget;
|
|
onDone: () => void;
|
|
}) {
|
|
const t = useTranslations("context_menu");
|
|
const model = useRulesModel(anchor, senderEmails, target);
|
|
const { locked, subject, senders } = model;
|
|
const hasSenders = senders.length > 0;
|
|
const act = (fn: () => void) => { fn(); onDone(); };
|
|
|
|
return (
|
|
<div className="max-w-[22rem]">
|
|
{locked && (
|
|
<div className="px-3 py-1.5 text-xs text-muted-foreground" data-testid="rules:opaque-hint">
|
|
{t("rules.opaque_hint")}
|
|
</div>
|
|
)}
|
|
{hasSenders && (
|
|
<ContextMenuSubMenu
|
|
icon={FolderInput}
|
|
label={t("rules.move_from", { sender: model.senderLabel })}
|
|
title={model.senderTitle}
|
|
disabled={locked}
|
|
testId="rules:move_sender"
|
|
>
|
|
<ContextFolderItems model={model} kind="move_sender" onDone={onDone} />
|
|
</ContextMenuSubMenu>
|
|
)}
|
|
{hasSenders && subject.domain && (
|
|
<ContextMenuSubMenu
|
|
icon={FolderInput}
|
|
label={t("rules.move_from_domain", { domain: subject.domain })}
|
|
disabled={locked}
|
|
testId="rules:move_domain"
|
|
>
|
|
<ContextFolderItems model={model} kind="move_domain" onDone={onDone} />
|
|
</ContextMenuSubMenu>
|
|
)}
|
|
{subject.listId && (
|
|
<ContextMenuSubMenu
|
|
icon={List}
|
|
label={t("rules.move_from_list")}
|
|
title={subject.listId}
|
|
disabled={locked}
|
|
testId="rules:move_list"
|
|
>
|
|
<ContextFolderItems model={model} kind="move_list" onDone={onDone} />
|
|
</ContextMenuSubMenu>
|
|
)}
|
|
{hasSenders && (
|
|
<ContextMenuItem
|
|
icon={MailOpen}
|
|
label={t("rules.mark_read_from", { sender: model.senderLabel })}
|
|
title={model.senderTitle}
|
|
disabled={locked}
|
|
testId="rules:mark_read"
|
|
onClick={() => act(() => model.run({ kind: "mark_read" }))}
|
|
/>
|
|
)}
|
|
{hasSenders && model.tags.length > 0 && (
|
|
<ContextMenuSubMenu icon={Tag} label={t("rules.tag_with")} disabled={locked} testId="rules:tag">
|
|
{model.tags.map((tag) => (
|
|
<button
|
|
key={tag.id}
|
|
role="menuitem"
|
|
type="button"
|
|
data-testid={`rules:tag:${tag.id}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
act(() => model.run({ kind: "tag", tagId: tag.id, tagName: tag.name }));
|
|
}}
|
|
className="w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted focus:outline-none focus:bg-muted"
|
|
>
|
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", tag.dot)} />
|
|
<span className="flex-1 truncate">{tag.name}</span>
|
|
</button>
|
|
))}
|
|
</ContextMenuSubMenu>
|
|
)}
|
|
{hasSenders && (
|
|
<ContextMenuItem
|
|
icon={Ban}
|
|
label={t("rules.block", { count: senders.length })}
|
|
title={model.senderTitle}
|
|
disabled={locked || !model.junk}
|
|
hint={model.junk ? undefined : t("rules.no_junk")}
|
|
destructive
|
|
testId="rules:block"
|
|
onClick={() => act(() => model.junk && model.run({ kind: "block", junk: toRuleMailbox(target.mailboxes, model.junk) }))}
|
|
/>
|
|
)}
|
|
{(hasSenders || subject.listId) && <ContextMenuSeparator />}
|
|
<ContextMenuItem
|
|
icon={Plus}
|
|
label={t("rules.create")}
|
|
disabled={locked}
|
|
testId="rules:create"
|
|
onClick={() => act(model.createRule)}
|
|
/>
|
|
<ContextMenuItem
|
|
icon={Settings}
|
|
label={t("rules.manage")}
|
|
testId="rules:manage"
|
|
onClick={() => act(model.manageRules)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The "Rules" entry of the message list's right-click menu: one-click rules
|
|
* for the sender(s) of `senderEmails`, "Create rule…" and "Manage rules".
|
|
* `anchor` is the message the menu was opened on; its folder is where
|
|
* "apply to existing messages" runs.
|
|
*/
|
|
export function RulesContextSubMenu({
|
|
anchor,
|
|
senderEmails,
|
|
onClose,
|
|
}: {
|
|
anchor: Email;
|
|
senderEmails: Email[];
|
|
onClose: () => void;
|
|
}) {
|
|
const t = useTranslations("context_menu");
|
|
const accountEmails = useMemo(
|
|
() => (senderEmails.some((e) => e.id === anchor.id) ? senderEmails : [anchor, ...senderEmails]),
|
|
[anchor, senderEmails],
|
|
);
|
|
const { availability, target } = useRulesAvailability(accountEmails);
|
|
if (availability === "hidden") return null;
|
|
return (
|
|
<ContextMenuSubMenu
|
|
icon={Filter}
|
|
label={t("rules.title")}
|
|
testId="ctx-rules"
|
|
disabled={availability === "cross_account"}
|
|
hint={availability === "cross_account" ? t("rules.cross_account") : undefined}
|
|
>
|
|
{target && <ContextRulesContent anchor={anchor} senderEmails={senderEmails} target={target} onDone={onClose} />}
|
|
</ContextMenuSubMenu>
|
|
);
|
|
}
|
|
|
|
// ── Reading pane ⋮ menu ──────────────────────────────────────────────────
|
|
|
|
type PanelView = "root" | MovePresetKind | "tag";
|
|
|
|
function PanelButton({
|
|
variant,
|
|
icon: Icon,
|
|
label,
|
|
title,
|
|
hint,
|
|
disabled,
|
|
destructive,
|
|
chevron,
|
|
depth = 0,
|
|
testId,
|
|
onClick,
|
|
}: {
|
|
variant: "desktop" | "mobile";
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
label: string;
|
|
title?: string;
|
|
hint?: string;
|
|
disabled?: boolean;
|
|
destructive?: boolean;
|
|
chevron?: boolean;
|
|
depth?: number;
|
|
testId?: string;
|
|
onClick: () => void;
|
|
}) {
|
|
const mobile = variant === "mobile";
|
|
return (
|
|
<button
|
|
role="menuitem"
|
|
type="button"
|
|
disabled={disabled}
|
|
title={title}
|
|
data-testid={testId}
|
|
aria-haspopup={chevron ? "menu" : undefined}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"w-full text-sm text-start flex items-center",
|
|
mobile ? "px-4 py-3 min-h-[44px] gap-3" : "px-3 py-1.5 gap-2",
|
|
disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-muted",
|
|
destructive && !disabled ? "text-destructive" : "text-foreground",
|
|
)}
|
|
style={depth > 0 ? { paddingInlineStart: `${(mobile ? 1 : 0.75) + depth}rem` } : undefined}
|
|
>
|
|
<Icon className={cn("flex-shrink-0", mobile ? "w-5 h-5" : "w-4 h-4")} />
|
|
<span className="flex-1 min-w-0">
|
|
<span className="block truncate">{label}</span>
|
|
{hint && <span className="block text-xs text-muted-foreground whitespace-normal">{hint}</span>}
|
|
</span>
|
|
{chevron && <ChevronRight className={cn("text-muted-foreground flex-shrink-0", mobile ? "w-4 h-4" : "w-3 h-3")} />}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PanelContent({
|
|
email,
|
|
target,
|
|
variant,
|
|
onDone,
|
|
}: {
|
|
email: Email;
|
|
target: QuickRuleTarget;
|
|
variant: "desktop" | "mobile";
|
|
onDone: () => void;
|
|
}) {
|
|
const t = useTranslations("context_menu");
|
|
const tSidebar = useTranslations("sidebar");
|
|
const tFolderMenu = useTranslations("mailbox_context_menu");
|
|
const senderEmails = useMemo(() => [email], [email]);
|
|
const model = useRulesModel(email, senderEmails, target);
|
|
const [view, setView] = useState<PanelView>("root");
|
|
const { locked, subject, senders } = model;
|
|
const hasSenders = senders.length > 0;
|
|
const act = (fn: () => void) => { fn(); onDone(); };
|
|
|
|
const moveLabels: Record<MovePresetKind, string> = {
|
|
move_sender: t("rules.move_from", { sender: model.senderLabel }),
|
|
move_domain: t("rules.move_from_domain", { domain: subject.domain ?? "" }),
|
|
move_list: t("rules.move_from_list"),
|
|
};
|
|
|
|
if (view !== "root") {
|
|
const back = (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={ChevronLeft}
|
|
label={view === "tag" ? t("rules.tag_with") : moveLabels[view]}
|
|
onClick={() => setView("root")}
|
|
/>
|
|
);
|
|
if (view === "tag") {
|
|
return (
|
|
<>
|
|
{back}
|
|
<div className="h-px bg-border my-1" />
|
|
{model.tags.map((tag) => (
|
|
<button
|
|
key={tag.id}
|
|
role="menuitem"
|
|
type="button"
|
|
data-testid={`rules:tag:${tag.id}`}
|
|
onClick={() => act(() => model.run({ kind: "tag", tagId: tag.id, tagName: tag.name }))}
|
|
className={cn(
|
|
"w-full text-sm text-start flex items-center hover:bg-muted text-foreground",
|
|
variant === "mobile" ? "px-4 py-3 min-h-[44px] gap-3" : "px-3 py-1.5 gap-2",
|
|
)}
|
|
>
|
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", tag.dot)} />
|
|
<span className="flex-1 truncate">{tag.name}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
const kind = view;
|
|
const render = (nodes: MailboxNode[], depth = 0): React.ReactNode => nodes.map((node) => {
|
|
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
|
const Icon = mailboxIcon(node.role);
|
|
return (
|
|
<div key={node.id}>
|
|
{model.targetIds.has(node.id) ? (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={Icon}
|
|
label={label}
|
|
depth={depth}
|
|
testId={`rules:${kind}:${node.id}`}
|
|
onClick={() => act(() => model.moveTo(kind, node))}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
"text-sm flex items-center text-muted-foreground",
|
|
variant === "mobile" ? "px-4 py-3 min-h-[44px] gap-3" : "px-3 py-1.5 gap-2",
|
|
)}
|
|
style={depth > 0 ? { paddingInlineStart: `${(variant === "mobile" ? 1 : 0.75) + depth}rem` } : undefined}
|
|
>
|
|
<Icon className={cn("flex-shrink-0", variant === "mobile" ? "w-5 h-5" : "w-4 h-4")} />
|
|
<span className="truncate">{label}</span>
|
|
</div>
|
|
)}
|
|
{node.children.length > 0 && render(node.children, depth + 1)}
|
|
</div>
|
|
);
|
|
});
|
|
return (
|
|
<>
|
|
{back}
|
|
<div className="h-px bg-border my-1" />
|
|
{render(model.tree)}
|
|
<div className="h-px bg-border my-1" />
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={FolderPlus}
|
|
label={tFolderMenu("new_folder")}
|
|
testId={`rules:${kind}:new-folder`}
|
|
onClick={() => act(() => model.moveToNewFolder(kind))}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{locked && (
|
|
<div
|
|
className={cn("text-xs text-muted-foreground", variant === "mobile" ? "px-4 py-2" : "px-3 py-1.5")}
|
|
data-testid="rules:opaque-hint"
|
|
>
|
|
{t("rules.opaque_hint")}
|
|
</div>
|
|
)}
|
|
{hasSenders && (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={FolderInput}
|
|
label={moveLabels.move_sender}
|
|
title={model.senderTitle}
|
|
disabled={locked}
|
|
chevron
|
|
testId="rules:move_sender"
|
|
onClick={() => setView("move_sender")}
|
|
/>
|
|
)}
|
|
{hasSenders && subject.domain && (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={FolderInput}
|
|
label={moveLabels.move_domain}
|
|
disabled={locked}
|
|
chevron
|
|
testId="rules:move_domain"
|
|
onClick={() => setView("move_domain")}
|
|
/>
|
|
)}
|
|
{subject.listId && (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={List}
|
|
label={moveLabels.move_list}
|
|
title={subject.listId}
|
|
disabled={locked}
|
|
chevron
|
|
testId="rules:move_list"
|
|
onClick={() => setView("move_list")}
|
|
/>
|
|
)}
|
|
{hasSenders && (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={MailOpen}
|
|
label={t("rules.mark_read_from", { sender: model.senderLabel })}
|
|
title={model.senderTitle}
|
|
disabled={locked}
|
|
testId="rules:mark_read"
|
|
onClick={() => act(() => model.run({ kind: "mark_read" }))}
|
|
/>
|
|
)}
|
|
{hasSenders && model.tags.length > 0 && (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={Tag}
|
|
label={t("rules.tag_with")}
|
|
disabled={locked}
|
|
chevron
|
|
testId="rules:tag"
|
|
onClick={() => setView("tag")}
|
|
/>
|
|
)}
|
|
{hasSenders && (
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={Ban}
|
|
label={t("rules.block", { count: senders.length })}
|
|
title={model.senderTitle}
|
|
hint={model.junk ? undefined : t("rules.no_junk")}
|
|
disabled={locked || !model.junk}
|
|
destructive
|
|
testId="rules:block"
|
|
onClick={() => act(() => model.junk && model.run({ kind: "block", junk: toRuleMailbox(target.mailboxes, model.junk) }))}
|
|
/>
|
|
)}
|
|
{(hasSenders || subject.listId) && <div className="h-px bg-border my-1" />}
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={Plus}
|
|
label={t("rules.create")}
|
|
disabled={locked}
|
|
testId="rules:create"
|
|
onClick={() => act(model.createRule)}
|
|
/>
|
|
<PanelButton
|
|
variant={variant}
|
|
icon={Settings}
|
|
label={t("rules.manage")}
|
|
testId="rules:manage"
|
|
onClick={() => act(model.manageRules)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The Rules sub-view of the reading pane's More menu, for the open message.
|
|
* Nested lists (the folders, the tags) replace the view with a back row, so
|
|
* the same component serves the desktop flyout and the mobile panel.
|
|
*/
|
|
export function RulesPanel({
|
|
email,
|
|
target,
|
|
variant,
|
|
onDone,
|
|
}: {
|
|
email: Email;
|
|
target: QuickRuleTarget;
|
|
variant: "desktop" | "mobile";
|
|
onDone: () => void;
|
|
}) {
|
|
return <PanelContent key={email.id} email={email} target={target} variant={variant} onDone={onDone} />;
|
|
}
|
|
|
|
// ── Folder menu ──────────────────────────────────────────────────────────
|
|
|
|
type FolderRules =
|
|
| { state: "loading" }
|
|
| { state: "error" }
|
|
| { state: "opaque" }
|
|
| { state: "ready"; rules: FilterRule[] };
|
|
|
|
function FolderRulesContent({
|
|
mailbox,
|
|
target,
|
|
onDone,
|
|
}: {
|
|
mailbox: Mailbox;
|
|
target: QuickRuleTarget;
|
|
onDone: () => void;
|
|
}) {
|
|
const t = useTranslations("context_menu");
|
|
const tFilters = useTranslations("settings.filters");
|
|
const tSidebar = useTranslations("sidebar");
|
|
const text = useQuickRuleText();
|
|
const router = useRouter();
|
|
const [rules, setRules] = useState<FolderRules>({ state: "loading" });
|
|
|
|
// Read from the server: the filter store only holds the rules while
|
|
// Settings has loaded them, and may hold another account's.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
readAccountFilters(target.client, target.sieveAccountId)
|
|
.then((filters) => {
|
|
if (cancelled) return;
|
|
setRules(filters.parsed.isOpaque ? { state: "opaque" } : { state: "ready", rules: filters.parsed.rules });
|
|
})
|
|
.catch(() => { if (!cancelled) setRules({ state: "error" }); });
|
|
return () => { cancelled = true; };
|
|
}, [target]);
|
|
|
|
const folderName = localizeMailboxName(mailbox.role, mailbox.name, (k) => tSidebar(`mailboxes.${k}`));
|
|
const note = (key: string, testId: string) => (
|
|
<div className="px-3 py-1.5 text-xs text-muted-foreground" data-testid={testId}>{t(key)}</div>
|
|
);
|
|
|
|
return (
|
|
<div className="max-w-[22rem]">
|
|
{rules.state === "loading" && note("rules.loading", "folder-rules:loading")}
|
|
{rules.state === "error" && note("rules.load_failed", "folder-rules:error")}
|
|
{rules.state === "opaque" && note("rules.opaque_hint", "rules:opaque-hint")}
|
|
{rules.state === "ready" && rules.rules.length === 0 && note("rules.none", "folder-rules:empty")}
|
|
{rules.state === "ready" && rules.rules.map((rule) => {
|
|
const support = retroactiveSupport(rule);
|
|
return (
|
|
<ContextMenuItem
|
|
key={rule.id}
|
|
icon={Play}
|
|
label={rule.name}
|
|
title={support.ok ? undefined : tFilters(support.reason === "period" ? "apply_existing_period" : "apply_existing_unsupported")}
|
|
hint={support.ok ? undefined : t("rules.new_mail_only")}
|
|
disabled={!support.ok}
|
|
testId={`folder-rules:run:${rule.id}`}
|
|
onClick={() => {
|
|
void runRuleOnFolder(target, rule, mailbox.id, folderName, text);
|
|
onDone();
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
icon={Settings}
|
|
label={t("rules.manage")}
|
|
testId="folder-rules:manage"
|
|
onClick={() => {
|
|
void openFilterSettings(target, (href) => router.push(href));
|
|
onDone();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The "Rules" entry of a folder's right-click menu: runs one of the account's
|
|
* saved rules over the mail already in the folder (#1166). Hidden for shared
|
|
* folders and accounts without Sieve.
|
|
*/
|
|
export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onClose: () => void }) {
|
|
const t = useTranslations("context_menu");
|
|
const client = useAuthStore((s) => s.client);
|
|
const target = useMemo(
|
|
() => (client && !mailbox.isShared ? resolveMailboxRuleTarget(mailbox) : null),
|
|
[client, mailbox],
|
|
);
|
|
if (!target || !target.supportsSieve) return null;
|
|
return (
|
|
<ContextMenuSubMenu icon={Filter} label={t("rules.title")} testId="mailbox-rules">
|
|
<FolderRulesContent mailbox={mailbox} target={target} onDone={onClose} />
|
|
</ContextMenuSubMenu>
|
|
);
|
|
}
|