"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): MailboxNode[] { return nodes.reduce((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; // ── 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 (
{model.targetIds.has(node.id) ? ( { model.moveTo(kind, node); onDone(); }} /> ) : (
{label}
)} {node.children.length > 0 &&
{render(node.children)}
}
); }); return ( <> {render(model.tree)} { 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 (
{locked && (
{t("rules.opaque_hint")}
)} {hasSenders && ( )} {hasSenders && subject.domain && ( )} {subject.listId && ( )} {hasSenders && ( act(() => model.run({ kind: "mark_read" }))} /> )} {hasSenders && model.tags.length > 0 && ( {model.tags.map((tag) => ( ))} )} {hasSenders && ( act(() => model.junk && model.run({ kind: "block", junk: toRuleMailbox(target.mailboxes, model.junk) }))} /> )} {(hasSenders || subject.listId) && } act(model.createRule)} /> act(model.manageRules)} />
); } /** * 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 ( {target && } ); } // ── 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 ( ); } 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("root"); const { locked, subject, senders } = model; const hasSenders = senders.length > 0; const act = (fn: () => void) => { fn(); onDone(); }; const moveLabels: Record = { 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 = ( setView("root")} /> ); if (view === "tag") { return ( <> {back}
{model.tags.map((tag) => ( ))} ); } 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 (
{model.targetIds.has(node.id) ? ( act(() => model.moveTo(kind, node))} /> ) : (
0 ? { paddingInlineStart: `${(variant === "mobile" ? 1 : 0.75) + depth}rem` } : undefined} > {label}
)} {node.children.length > 0 && render(node.children, depth + 1)}
); }); return ( <> {back}
{render(model.tree)}
act(() => model.moveToNewFolder(kind))} /> ); } return ( <> {locked && (
{t("rules.opaque_hint")}
)} {hasSenders && ( setView("move_sender")} /> )} {hasSenders && subject.domain && ( setView("move_domain")} /> )} {subject.listId && ( setView("move_list")} /> )} {hasSenders && ( act(() => model.run({ kind: "mark_read" }))} /> )} {hasSenders && model.tags.length > 0 && ( setView("tag")} /> )} {hasSenders && ( act(() => model.junk && model.run({ kind: "block", junk: toRuleMailbox(target.mailboxes, model.junk) }))} /> )} {(hasSenders || subject.listId) &&
} act(model.createRule)} /> 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 ; } // ── 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({ 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) => (
{t(key)}
); return (
{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 ( { void runRuleOnFolder(target, rule, mailbox.id, folderName, text); onDone(); }} /> ); })} { void openFilterSettings(target, (href) => router.push(href)); onDone(); }} />
); } /** * 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 ( ); }