mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins work with team mailboxes: list filters, row badge icons, account info and message conditions
What the team inbox plugin needs, kept general for other plugins: - `api.ui.setListFilters`: keyword filters drawn as chips in the list header of the folders they apply to (by account kind and folder role). A chip the user turns on is ANDed into the folder's query and comes off with one click; a keyword change a filter tests loads the list again (ui:message-list). - Actions get `accounts`, `hasKeyword` and `notKeyword`, so a button shows only on messages of group accounts, or of a keyword state; the click of message-row-actions and reader-actions carries the message's `accountId`. - `api.email.updateKeywords`: several keywords in one change, through the list for a listed message (email:write). - `api.user.getSelf`, `getMailAccounts` and `findPeople`: the user's own account id (Stalwart's principal id), which accounts are group accounts, and people from the directory (account:read). - `api.accountData`: a plugin's own entry in a mail account's shared Inbox metadata on Stalwart 1.0 (email:read / email:write). - Row badges take an `icon`.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { PluginListFilters } from '../plugin-list-filters';
|
||||
import {
|
||||
activeListFilterCondition, clearPluginListFilters, resetListFilters, setPluginListFilters,
|
||||
} from '@/lib/plugin-sandbox/list-filters';
|
||||
|
||||
// The chips plugins' list filters get in the list header (api.ui.setListFilters).
|
||||
// The global next-intl mock returns the key, so labels read as keys.
|
||||
|
||||
const TEAM = { role: 'inbox', accountKind: 'group' as const };
|
||||
|
||||
beforeEach(() => {
|
||||
resetListFilters();
|
||||
setPluginListFilters('team-inbox', 'Team inbox', [
|
||||
{ id: 'mine', label: 'Assigned to me', icon: 'user', hasKeyword: '$assigned:ci', accounts: ['group'], order: 1 },
|
||||
{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'], order: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('PluginListFilters', () => {
|
||||
it('shows the chips of a team folder, off at first', () => {
|
||||
render(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={() => {}} />);
|
||||
const chips = screen.getAllByRole('button');
|
||||
expect(chips.map((c) => c.textContent)).toEqual(['Assigned to me', 'Open']);
|
||||
expect(chips.every((c) => c.getAttribute('aria-pressed') === 'false')).toBe(true);
|
||||
expect(screen.getByRole('group')).toHaveAttribute('aria-label', 'filters');
|
||||
});
|
||||
|
||||
it('shows nothing in the user\'s own folders', () => {
|
||||
const { container } = render(<PluginListFilters mailboxRole="inbox" accountKind="personal" onChange={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('turns a filter on with a click, loads the list, and removes it with the next', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
const active = screen.getByRole('button', { name: 'remove_filter' });
|
||||
expect(active).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(active.querySelector('svg')).not.toBeNull();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(activeListFilterCondition(TEAM)).toEqual({ notKeyword: '$done' });
|
||||
|
||||
fireEvent.click(active);
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(activeListFilterCondition(TEAM)).toBeNull();
|
||||
});
|
||||
|
||||
it('loads the list again when the plugin that offered an active filter goes away', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
onChange.mockClear();
|
||||
|
||||
act(() => clearPluginListFilters('team-inbox'));
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,22 @@ describe('plugin badges in message-list rows', () => {
|
||||
expect(ctx.email).not.toHaveProperty('mailboxIds.inbox');
|
||||
});
|
||||
|
||||
it('draws a badge\'s icon before its label, and none for a name that isn\'t one', async () => {
|
||||
renderHooks.onEmailListItemRender.register('test', async () => [
|
||||
{ key: 'done', label: 'Done', icon: 'check' },
|
||||
{ key: 'bad', label: 'Bad', icon: 'x"><script>' },
|
||||
]);
|
||||
|
||||
const { container } = render(row([makeEmail()]));
|
||||
|
||||
await screen.findByText('Done');
|
||||
const done = container.querySelector('[data-plugin-row-badge="done"]')!;
|
||||
expect(done.querySelector('svg[data-badge-icon="check"]')).not.toBeNull();
|
||||
expect(done).toHaveTextContent('Done');
|
||||
expect(container.querySelector('[data-plugin-row-badge="bad"] svg')).toBeNull();
|
||||
expect(normalizeBadges([{ key: 'bad', label: 'Bad', icon: 'x"><script>' }])[0]).not.toHaveProperty('icon');
|
||||
});
|
||||
|
||||
it('asks the plugin again when it requests a refresh, at most once a second', async () => {
|
||||
let label = 'Open';
|
||||
const handler = vi.fn(async () => [{ key: 'task', label }]);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
@@ -292,7 +292,12 @@ export function EmailContextMenu({
|
||||
|
||||
// The row's plugin buttons (message-row-actions). On phones the long press
|
||||
// opens this menu, so it is where those buttons live there.
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0;
|
||||
const rowPluginTarget = useMessageActionTarget(email);
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", {
|
||||
mailboxRole: currentMailboxRole ?? null,
|
||||
accountKind: rowPluginTarget.accountKind,
|
||||
keywords: rowPluginTarget.keywords,
|
||||
}).length > 0;
|
||||
const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
const rowPluginBlock = !showBatchActions && hasRowPluginActions ? (
|
||||
<>
|
||||
@@ -301,6 +306,7 @@ export function EmailContextMenu({
|
||||
surface="message-row-actions"
|
||||
variant="menu"
|
||||
context={rowPluginContext}
|
||||
target={rowPluginTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={onClose}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
@@ -123,7 +123,10 @@ export function EmailHoverActions({
|
||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||
const isMobile = useIsMobile();
|
||||
// Buttons plugins add to the row (message-row-actions), after the app's own.
|
||||
const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length;
|
||||
const pluginTarget = useMessageActionTarget(email);
|
||||
const pluginActionCount = usePluginActions("message-row-actions", {
|
||||
mailboxRole, accountKind: pluginTarget.accountKind, keywords: pluginTarget.keywords,
|
||||
}).length;
|
||||
const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
|
||||
// The tag quick-action opens the same picker the context menu and the reading
|
||||
@@ -269,6 +272,7 @@ export function EmailHoverActions({
|
||||
key="plugin-actions"
|
||||
surface="message-row-actions"
|
||||
context={pluginContext}
|
||||
target={pluginTarget}
|
||||
mailboxRole={mailboxRole}
|
||||
compact
|
||||
className="flex-nowrap gap-0.5"
|
||||
|
||||
@@ -38,7 +38,8 @@ import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginListFilters } from "./plugin-list-filters";
|
||||
import { PluginActions, useFolderAccountKind } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { unreadSectionRowCount } from "@/lib/unread-first";
|
||||
import { MvListSectionHeader } from "./mv-list-section-header";
|
||||
@@ -532,6 +533,7 @@ export function EmailList({
|
||||
[emails, threadEmailsCache, selectedEmailKeys],
|
||||
);
|
||||
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
|
||||
const folderAccountKind = useFolderAccountKind();
|
||||
// "Remove from this folder" (#615): offered for a selection only when every
|
||||
// selected message is also in another folder.
|
||||
const removeFromFolder = useRemoveFromFolder();
|
||||
@@ -914,6 +916,7 @@ export function EmailList({
|
||||
<PluginActions
|
||||
surface="message-bulk-actions"
|
||||
context={bulkActionContext}
|
||||
accountKind={folderAccountKind}
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
compact
|
||||
overflow={batchNarrow}
|
||||
@@ -976,6 +979,15 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins' keyword filters, in a folder's own list (not search, tags or merged views). */}
|
||||
{!isSearching && !selectedKeyword && !isUnifiedView && !isScheduledView && selectedMailbox && (
|
||||
<PluginListFilters
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
accountKind={folderAccountKind}
|
||||
onChange={() => { if (client) void useEmailStore.getState().fetchEmails(client); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
||||
|
||||
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
|
||||
|
||||
@@ -139,7 +139,7 @@ import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { ConversationEmailBody, type ConversationAttachmentHandler, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
|
||||
import type { TnefAttachment } from "@/lib/tnef";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { RulesPanel, useRulesAvailability } from "./rules-menu";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
@@ -1604,6 +1604,7 @@ export function EmailViewer({
|
||||
const emailIdRef = useRef(email?.id);
|
||||
emailIdRef.current = email?.id;
|
||||
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const readerActionTarget = useMessageActionTarget(email);
|
||||
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
// The user's choice; until they make one, a panel whose plugin asked to
|
||||
// start folded (a chat opened on demand) is folded, the rest open.
|
||||
@@ -3992,6 +3993,7 @@ export function EmailViewer({
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
target={readerActionTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
compact={!showToolbarLabels}
|
||||
/>
|
||||
@@ -4819,6 +4821,7 @@ export function EmailViewer({
|
||||
<PluginPanelEntries
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
target={readerActionTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { iconForName, X } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
activeListFilterKeys,
|
||||
listFilterApplies,
|
||||
listFilterRevision,
|
||||
listFilters,
|
||||
setListFilterActive,
|
||||
subscribeListFilters,
|
||||
} from "@/lib/plugin-sandbox/list-filters";
|
||||
import type { MailAccountKind } from "@/lib/plugin-sandbox/account-kind";
|
||||
|
||||
const NONE = new Set<string>();
|
||||
|
||||
/**
|
||||
* The keyword filters plugins offer for this folder (api.ui.setListFilters),
|
||||
* as chips in the list header. A chip turns its filter on; an active chip
|
||||
* shows a cross and turns it off again. `onChange` loads the list again
|
||||
* whenever the applied filters change, also when a plugin drops one that
|
||||
* was on.
|
||||
*/
|
||||
export function PluginListFilters({
|
||||
mailboxRole,
|
||||
accountKind,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
mailboxRole: string | null;
|
||||
accountKind: MailAccountKind | null;
|
||||
onChange: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("plugin_list");
|
||||
const filters = useSyncExternalStore(subscribeListFilters, listFilters, listFilters);
|
||||
const active = useSyncExternalStore(subscribeListFilters, activeListFilterKeys, () => NONE);
|
||||
const revision = useSyncExternalStore(subscribeListFilters, listFilterRevision, listFilterRevision);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const seenRevision = useRef(revision);
|
||||
useEffect(() => {
|
||||
if (seenRevision.current === revision) return;
|
||||
seenRevision.current = revision;
|
||||
onChangeRef.current();
|
||||
}, [revision]);
|
||||
|
||||
const shown = filters.filter((f) => listFilterApplies(f, { role: mailboxRole, accountKind }));
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("filters")}
|
||||
data-testid="plugin-list-filters"
|
||||
className={cn("px-4 py-1.5 border-b border-border flex items-center gap-2 flex-wrap", className)}
|
||||
>
|
||||
{shown.map((filter) => {
|
||||
const on = active.has(filter.key);
|
||||
const Icon = iconForName(filter.icon);
|
||||
return (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
aria-label={on ? t("remove_filter", { label: filter.label }) : filter.label}
|
||||
title={filter.pluginName}
|
||||
data-plugin-list-filter={filter.key}
|
||||
onClick={() => setListFilterActive(filter.key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-7 max-w-[12rem] px-2.5 rounded-full text-xs border transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
on
|
||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/15"
|
||||
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="w-3.5 h-3.5 flex-shrink-0" aria-hidden />}
|
||||
<span className="truncate font-medium">{filter.label}</span>
|
||||
{on && <X className="w-3 h-3 flex-shrink-0" aria-hidden />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emailSummary } from "@/lib/plugin-projection";
|
||||
import { rowBadgeGeneration, subscribeRowBadges } from "@/lib/plugin-sandbox/row-badges";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { iconForName } from "@/components/icons";
|
||||
import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
|
||||
// Badges plugins add to a message-list row through onEmailListItemRender.
|
||||
@@ -40,6 +41,8 @@ function cacheKey(email: Email, listeners: number, generation: number): string {
|
||||
// variable or a colour name. Anything else (url(), expressions, several
|
||||
// declarations) is dropped and the badge keeps the theme colours.
|
||||
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/+-]+\)|var\(--[a-z0-9-]+\)|[a-z]{3,30})$/i;
|
||||
// A host icon name, as for plugin actions.
|
||||
const ICON_NAME = /^[A-Za-z0-9:-]{1,40}$/;
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- removing control characters is the point
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
|
||||
@@ -67,6 +70,8 @@ export function normalizeBadges(raw: unknown): EmailListBadge[] {
|
||||
if (color && SAFE_COLOR.test(color)) badge.color = color;
|
||||
const title = cleanText(candidate.title, MAX_TITLE_LENGTH);
|
||||
if (title) badge.title = title;
|
||||
const icon = cleanText(candidate.icon, 40);
|
||||
if (icon && ICON_NAME.test(icon)) badge.icon = icon;
|
||||
out.push(badge);
|
||||
}
|
||||
return out;
|
||||
@@ -141,24 +146,30 @@ export function PluginRowBadges({ email, className }: { email: Email; className?
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<span className={cn(TAG_GROUP_CLASS, className)} data-testid="plugin-row-badges">
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge.key}
|
||||
title={badge.title}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[8rem] truncate border",
|
||||
!badge.color && "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
style={badge.color ? {
|
||||
color: badge.color,
|
||||
borderColor: badge.color,
|
||||
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
|
||||
} : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
{badges.map((badge) => {
|
||||
const Icon = iconForName(badge.icon);
|
||||
return (
|
||||
<span
|
||||
key={badge.key}
|
||||
title={badge.title}
|
||||
data-plugin-row-badge={badge.key}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[8rem] truncate border",
|
||||
Icon && "gap-0.5",
|
||||
!badge.color && "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
style={badge.color ? {
|
||||
color: badge.color,
|
||||
borderColor: badge.color,
|
||||
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
|
||||
} : undefined}
|
||||
>
|
||||
{Icon && <Icon className="w-3 h-3 flex-shrink-0" aria-hidden data-badge-icon={badge.icon} />}
|
||||
{Icon ? <span className="truncate">{badge.label}</span> : badge.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { iconForName, MoreHorizontal, Puzzle } from "@/components/icons";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, usePluginActions, type MessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -128,14 +128,18 @@ export function PluginPanelEntries({
|
||||
surface,
|
||||
context,
|
||||
mailboxRole,
|
||||
target,
|
||||
onRan,
|
||||
}: {
|
||||
surface: string;
|
||||
context: Record<string, unknown>;
|
||||
mailboxRole?: string | null;
|
||||
/** The open message (useMessageActionTarget). */
|
||||
target?: MessageActionTarget;
|
||||
onRan?: () => void;
|
||||
}) {
|
||||
const actions = usePluginActions(surface, { mailboxRole });
|
||||
const actions = usePluginActions(surface, { mailboxRole, accountKind: target?.accountKind ?? null, keywords: target?.keywords ?? null });
|
||||
const clickContext = target?.accountId ? { ...context, accountId: target.accountId } : context;
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
@@ -147,7 +151,7 @@ export function PluginPanelEntries({
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
role="menuitem"
|
||||
title={action.pluginName}
|
||||
onClick={() => { onRan?.(); void runAction(action, surface, context); }}
|
||||
onClick={() => { onRan?.(); void runAction(action, surface, clickContext); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
action.danger ? "text-destructive" : "text-foreground",
|
||||
|
||||
@@ -51,7 +51,7 @@ import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
|
||||
@@ -339,7 +339,8 @@ function useConversationPluginProps(email: Email) {
|
||||
const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
|
||||
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
|
||||
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
return { emailProps, idProps };
|
||||
const actionTarget = useMessageActionTarget(email);
|
||||
return { emailProps, idProps, actionTarget };
|
||||
}
|
||||
|
||||
function EmailCard({
|
||||
@@ -490,6 +491,7 @@ function EmailCard({
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={plugin.idProps}
|
||||
target={plugin.actionTarget}
|
||||
mailboxRole={mailboxRole ?? null}
|
||||
overflow
|
||||
className="flex-shrink-0 rounded-md border border-border"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { PluginActions } from '../plugin-actions';
|
||||
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
|
||||
import { PluginActions, useMessageActionTarget } from '../plugin-actions';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -66,3 +71,67 @@ describe('PluginActions', () => {
|
||||
expect(screen.getByRole('menuitem', { name: /Labels/ })).toHaveTextContent('99+');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions for team mailboxes', () => {
|
||||
const TEAM_ACTIONS = [
|
||||
{ id: 'assign', label: 'Assign to me', accounts: ['group' as const] },
|
||||
{ id: 'done', label: 'Mark done', accounts: ['group' as const], notKeyword: '$done' },
|
||||
{ id: 'reopen', label: 'Reopen', accounts: ['group' as const], hasKeyword: '$done' },
|
||||
];
|
||||
|
||||
it("shows them only on a group account's message, by its keywords, and sends its account", async () => {
|
||||
act(() => setPluginActions('team', 'Team inbox', 'message-row-actions', TEAM_ACTIONS));
|
||||
const view = render(
|
||||
<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} variant="menu"
|
||||
target={{ accountId: 'ch', accountKind: 'group', keywords: ['$seen'] }} />,
|
||||
);
|
||||
expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['Assign to me', 'Mark done']);
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Mark done' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('team', 'message-row-actions', 'done', { emailId: 'm1', accountId: 'ch' });
|
||||
|
||||
view.rerender(
|
||||
<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} variant="menu"
|
||||
target={{ accountId: 'ch', accountKind: 'group', keywords: ['$done'] }} />,
|
||||
);
|
||||
expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['Assign to me', 'Reopen']);
|
||||
|
||||
view.rerender(
|
||||
<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} variant="menu"
|
||||
target={{ accountKind: 'personal', keywords: [] }} />,
|
||||
);
|
||||
expect(screen.queryAllByRole('menuitem')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hides them where the surface isn't about a message", () => {
|
||||
act(() => setPluginActions('team', 'Team inbox', 'message-row-actions', TEAM_ACTIONS));
|
||||
const { container } = render(<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("works out a message's account from the open folder and follows the group list as it loads", () => {
|
||||
const client = {
|
||||
getAccountId: () => 'ci',
|
||||
getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null),
|
||||
} as unknown as IJMAPClient;
|
||||
const team = { id: 'ch:a', originalId: 'a', role: 'inbox', isShared: true, accountId: 'ch' } as unknown as Mailbox;
|
||||
useAuthStore.setState({ client });
|
||||
useEmailStore.setState({ mailboxes: [team], selectedMailbox: 'ch:a', viewingAccountId: null });
|
||||
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: [] } });
|
||||
const email = { id: 'm1', keywords: { $seen: true, $done: true } } as unknown as Email;
|
||||
|
||||
const { result } = renderHook(() => useMessageActionTarget(email));
|
||||
expect(result.current).toEqual({ accountId: 'ch', accountKind: 'shared', keywords: ['$done', '$seen'] });
|
||||
|
||||
act(() => usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org'] } }));
|
||||
expect(result.current.accountKind).toBe('group');
|
||||
|
||||
act(() => useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes: [team, { id: 'inbox', role: 'inbox' } as Mailbox] }));
|
||||
expect(result.current).toEqual({ accountKind: 'personal', keywords: ['$done', '$seen'] });
|
||||
|
||||
useAuthStore.setState({ client: null });
|
||||
usePolicyStore.setState({ subject: null });
|
||||
useEmailStore.setState({ mailboxes: [], selectedMailbox: '' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore, useState } from 'react';
|
||||
import { useMemo, useSyncExternalStore, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons';
|
||||
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
|
||||
@@ -12,17 +12,91 @@ import {
|
||||
badgeText,
|
||||
runAction,
|
||||
subscribeActions,
|
||||
type ActionFilter,
|
||||
type RegisteredAction,
|
||||
} from '@/lib/plugin-sandbox/actions';
|
||||
import { mailAccountKind, type MailAccountKind } from '@/lib/plugin-sandbox/account-kind';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountSubjects } from '@/lib/org/account-subjects';
|
||||
|
||||
const EMPTY: RegisteredAction[] = [];
|
||||
|
||||
/** The actions plugins declared for a surface, filtered by folder role or MIME type. */
|
||||
export function usePluginActions(surface: string, filter: { mailboxRole?: string | null; mimeType?: string | null } = {}) {
|
||||
/** The actions plugins declared for a surface, filtered by folder role, MIME type, account and keywords. */
|
||||
export function usePluginActions(surface: string, filter: ActionFilter = {}) {
|
||||
const all = useSyncExternalStore(subscribeActions, () => actionsFor(surface), () => EMPTY);
|
||||
return all.filter((a) => actionApplies(a, filter));
|
||||
}
|
||||
|
||||
/** The message an action surface is about: its account and keywords. */
|
||||
export interface MessageActionTarget {
|
||||
/** The JMAP account the message is in, when it isn't the signed-in account's own. */
|
||||
accountId?: string;
|
||||
accountKind: MailAccountKind | null;
|
||||
keywords: string[] | null;
|
||||
}
|
||||
|
||||
type ActionEmail = Pick<Email, 'id' | 'keywords' | 'sourceAccountId' | 'sourceClientAccountId'>;
|
||||
|
||||
/**
|
||||
* The account and keywords of `email`, for the actions' `accounts`,
|
||||
* `hasKeyword` and `notKeyword` and the click's `accountId`. Follows the
|
||||
* group lists as they load, so a team mailbox's actions appear once the
|
||||
* server said which accounts are groups.
|
||||
*/
|
||||
export function useMessageActionTarget(email: ActionEmail | null | undefined): MessageActionTarget {
|
||||
const subject = usePolicyStore((s) => s.subject);
|
||||
const subjects = useAccountSubjects((s) => s.subjects);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const keywords = email?.keywords;
|
||||
const keywordKey = keywords ? Object.keys(keywords).filter((k) => keywords[k]).sort().join(' ') : '';
|
||||
const sourceAccountId = email?.sourceAccountId;
|
||||
const sourceClientAccountId = email?.sourceClientAccountId;
|
||||
const hasEmail = !!email;
|
||||
return useMemo(() => {
|
||||
if (!hasEmail) return { accountKind: null, keywords: null };
|
||||
const owner = resolveEmailBlobContext({ sourceAccountId, sourceClientAccountId }, client);
|
||||
const own = owner.client?.getAccountId?.();
|
||||
const accountId = owner.accountId && owner.accountId !== own ? owner.accountId : undefined;
|
||||
return {
|
||||
...(accountId ? { accountId } : {}),
|
||||
accountKind: owner.client ? mailAccountKind(owner.client, owner.accountId, owner.clientAccountId) : null,
|
||||
keywords: keywordKey ? keywordKey.split(' ') : [],
|
||||
};
|
||||
// The group lists, the open folder and its mailboxes decide the account
|
||||
// (resolveEmailBlobContext reads them from the stores).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasEmail, keywordKey, sourceAccountId, sourceClientAccountId, client, subject, subjects, selectedMailbox, mailboxes]);
|
||||
}
|
||||
|
||||
/** The account kind of the open folder, for surfaces about the selection (message-bulk-actions). */
|
||||
export function useFolderAccountKind(): MailAccountKind | null {
|
||||
const subject = usePolicyStore((s) => s.subject);
|
||||
const subjects = useAccountSubjects((s) => s.subjects);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
return useMemo(() => {
|
||||
const owner = resolveEmailBlobContext({}, client);
|
||||
return owner.client ? mailAccountKind(owner.client, owner.accountId, owner.clientAccountId) : null;
|
||||
// See useMessageActionTarget.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, subject, subjects, selectedMailbox, mailboxes]);
|
||||
}
|
||||
|
||||
/** The context a message action's click carries: the surface's, plus the message's account. */
|
||||
function withTarget(context: Record<string, unknown>, target: MessageActionTarget | undefined): Record<string, unknown> {
|
||||
return target?.accountId ? { ...context, accountId: target.accountId } : context;
|
||||
}
|
||||
|
||||
function targetFilter(target: MessageActionTarget | undefined, accountKind: MailAccountKind | null | undefined): Pick<ActionFilter, 'accountKind' | 'keywords'> {
|
||||
return { accountKind: target ? target.accountKind : accountKind ?? null, keywords: target?.keywords ?? null };
|
||||
}
|
||||
|
||||
interface PluginActionsProps {
|
||||
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
|
||||
surface: string;
|
||||
@@ -39,18 +113,22 @@ interface PluginActionsProps {
|
||||
overflow?: boolean;
|
||||
/** Called after a click, e.g. to close the menu the entry sits in. */
|
||||
onRan?: () => void;
|
||||
/** The message the surface is about (useMessageActionTarget). */
|
||||
target?: MessageActionTarget;
|
||||
/** Surfaces about the selection: the open folder's account kind. */
|
||||
accountKind?: MailAccountKind | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buttons or menu entries plugins declared with api.ui.setActions. The host
|
||||
* draws them so they match the app; the plugin gets the click.
|
||||
*/
|
||||
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan }: PluginActionsProps) {
|
||||
const actions = usePluginActions(surface, { mailboxRole, mimeType });
|
||||
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan, target, accountKind }: PluginActionsProps) {
|
||||
const actions = usePluginActions(surface, { mailboxRole, mimeType, ...targetFilter(target, accountKind) });
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
if (actions.length === 0) return null;
|
||||
if (overflow && variant === 'buttons') {
|
||||
return <PluginActionsOverflow surface={surface} context={context} mailboxRole={mailboxRole} mimeType={mimeType} className={className} />;
|
||||
return <PluginActionsOverflow surface={surface} context={context} mailboxRole={mailboxRole} mimeType={mimeType} className={className} target={target} accountKind={accountKind} />;
|
||||
}
|
||||
|
||||
const run = async (action: RegisteredAction) => {
|
||||
@@ -58,7 +136,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
|
||||
setBusy(key);
|
||||
try {
|
||||
onRan?.();
|
||||
await runAction(action, surface, context);
|
||||
await runAction(action, surface, withTarget(context, target));
|
||||
} finally {
|
||||
setBusy((current) => (current === key ? null : current));
|
||||
}
|
||||
@@ -122,7 +200,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
|
||||
}
|
||||
|
||||
/** The actions of a surface behind one "more" button, for toolbars without room for them. */
|
||||
function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className }: Pick<PluginActionsProps, 'surface' | 'context' | 'mailboxRole' | 'mimeType' | 'className'>) {
|
||||
function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className, target, accountKind }: Pick<PluginActionsProps, 'surface' | 'context' | 'mailboxRole' | 'mimeType' | 'className' | 'target' | 'accountKind'>) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<true>();
|
||||
return (
|
||||
@@ -148,6 +226,8 @@ function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, classN
|
||||
context={context}
|
||||
mailboxRole={mailboxRole}
|
||||
mimeType={mimeType}
|
||||
target={target}
|
||||
accountKind={accountKind}
|
||||
onRan={closeContextMenu}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import type { Email, Mailbox } from '../jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||||
import { resetPluginDirectory } from '../plugin-sandbox/host-api-account';
|
||||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||||
import { actionApplies, checkActions } from '../plugin-sandbox/actions';
|
||||
import {
|
||||
activeListFilterCondition, clearPluginListFilters, listFilterRevision, listFilters, resetListFilters,
|
||||
setListFilterActive,
|
||||
} from '../plugin-sandbox/list-filters';
|
||||
|
||||
function plugin(perms: string[], id = 'team-inbox'): InstalledPlugin {
|
||||
return {
|
||||
id, name: 'Team inbox', version: '1.0.0', author: 'test', description: '',
|
||||
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
}
|
||||
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
|
||||
|
||||
const ACCOUNTS: Record<string, { name: string; isPersonal: boolean; isReadOnly: boolean }> = {
|
||||
ci: { name: 'org-alice@example.org', isPersonal: true, isReadOnly: false },
|
||||
ch: { name: 'org-team@example.org', isPersonal: false, isReadOnly: false },
|
||||
cx: { name: 'boss@example.org', isPersonal: false, isReadOnly: true },
|
||||
};
|
||||
|
||||
const teamInbox = { id: 'ch:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'ch', totalEmails: 1, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox;
|
||||
const ownInbox = { id: 'a', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox;
|
||||
|
||||
function mail(id: string, over: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id, threadId: `t-${id}`, mailboxIds: { a: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Bob', email: 'bob@example.com' }], subject: id, receivedAt: '2026-10-01T10:00:00Z',
|
||||
preview: '', size: 1, hasAttachment: false, ...over,
|
||||
} as Email;
|
||||
}
|
||||
|
||||
let client: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginDirectory();
|
||||
resetPluginRateLimits();
|
||||
resetListFilters();
|
||||
client = {
|
||||
getAccountId: vi.fn(() => 'ci'),
|
||||
getUsername: vi.fn(() => 'org-alice@example.org'),
|
||||
getAccountInfo: vi.fn((id: string) => (ACCOUNTS[id] ? { ...ACCOUNTS[id], accountCapabilities: {} } : null)),
|
||||
getSharedAccounts: vi.fn(() => Object.keys(ACCOUNTS).map((id) => ({
|
||||
id, name: ACCOUNTS[id].name, isPrimary: id === 'ci',
|
||||
capabilities: { mail: true, sieve: false, calendars: false, contacts: false, filenode: false },
|
||||
}))),
|
||||
supportsPrincipals: vi.fn(() => true),
|
||||
listPrincipals: vi.fn(async () => ({
|
||||
status: 'restricted',
|
||||
principals: [
|
||||
{ id: 'ci', type: 'individual', name: 'org-alice@example.org', description: 'Org Alice', email: 'org-alice@example.org' },
|
||||
{ id: 'ch', type: 'group', name: 'org-team@example.org', description: 'Org Team', email: 'org-team@example.org' },
|
||||
],
|
||||
})),
|
||||
getPrincipals: vi.fn(async () => []),
|
||||
batchUpdateKeywords: vi.fn(async () => undefined),
|
||||
supportsBulwarkMetadata: vi.fn(() => false),
|
||||
getBulwarkMetadata: vi.fn(async () => null),
|
||||
setBulwarkMetadata: vi.fn(async () => undefined),
|
||||
};
|
||||
useAuthStore.setState({ client: client as unknown as IJMAPClient, activeAccountId: 'login1' });
|
||||
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org'] } });
|
||||
useEmailStore.setState({ mailboxes: [ownInbox, teamInbox], selectedMailbox: 'ch:a', emails: [], selectedEmail: null, viewingAccountId: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null, activeAccountId: null });
|
||||
usePolicyStore.setState({ subject: null });
|
||||
useEmailStore.setState({ mailboxes: [], selectedMailbox: '', emails: [], selectedEmail: null });
|
||||
});
|
||||
|
||||
describe('account info for plugins (account:read)', () => {
|
||||
it('is refused without account:read', async () => {
|
||||
await expect(call(plugin([]), 'user.getSelf')).rejects.toThrow(/lacks permission "account:read"/);
|
||||
await expect(call(plugin(['email:read']), 'user.getMailAccounts')).rejects.toThrow(/account:read/);
|
||||
await expect(call(plugin([]), 'user.findPeople', { text: 'bob' })).rejects.toThrow(/account:read/);
|
||||
});
|
||||
|
||||
it('names the user by their own account id, which is their principal id', async () => {
|
||||
await expect(call(plugin(['account:read']), 'user.getSelf')).resolves.toEqual(expect.objectContaining({
|
||||
accountId: 'ci', email: 'org-alice@example.org',
|
||||
}));
|
||||
});
|
||||
|
||||
it('tells group accounts from mailboxes a person shared, and marks the open folder\'s', async () => {
|
||||
const accounts = await call(plugin(['account:read']), 'user.getMailAccounts');
|
||||
expect(accounts).toEqual([
|
||||
{ accountId: 'ci', name: 'org-alice@example.org', kind: 'personal', isReadOnly: false, isOpen: false },
|
||||
{ accountId: 'ch', name: 'org-team@example.org', kind: 'group', isReadOnly: false, isOpen: true },
|
||||
{ accountId: 'cx', name: 'boss@example.org', kind: 'shared', isReadOnly: true, isOpen: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts an account as a group only once the policy subject lists it', async () => {
|
||||
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: [] } });
|
||||
const accounts = await call(plugin(['account:read']), 'user.getMailAccounts') as Array<{ accountId: string; kind: string }>;
|
||||
expect(accounts.find((a) => a.accountId === 'ch')?.kind).toBe('shared');
|
||||
});
|
||||
|
||||
it('looks people up by text or id and says when the directory is off', async () => {
|
||||
const reader = plugin(['account:read']);
|
||||
await expect(call(reader, 'user.findPeople', { text: 'team' })).resolves.toEqual({
|
||||
people: [{ id: 'ch', type: 'group', name: 'Org Team', email: 'org-team@example.org' }],
|
||||
complete: false,
|
||||
});
|
||||
await expect(call(reader, 'user.findPeople', { ids: ['ci', 'nobody'] })).resolves.toEqual({
|
||||
people: [{ id: 'ci', type: 'individual', name: 'Org Alice', email: 'org-alice@example.org' }],
|
||||
complete: false,
|
||||
});
|
||||
// One directory listing serves both lookups.
|
||||
expect(client.listPrincipals).toHaveBeenCalledTimes(1);
|
||||
await expect(call(reader, 'user.findPeople', {})).rejects.toThrow(/text or ids/);
|
||||
await expect(call(reader, 'user.findPeople', { name: 'x' })).rejects.toThrow(/unknown property/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('email.updateKeywords', () => {
|
||||
it('needs email:write and real keywords', async () => {
|
||||
await expect(call(plugin(['email:read']), 'email.updateKeywords', 'm1', { $done: true })).rejects.toThrow(/email:write/);
|
||||
const writer = plugin(['email:write']);
|
||||
await expect(call(writer, 'email.updateKeywords', 'm1', { 'bad@kw': true })).rejects.toThrow(/Invalid JMAP keyword/);
|
||||
await expect(call(writer, 'email.updateKeywords', 'm1', { $done: 'yes' })).rejects.toThrow(/true \(set\) or false/);
|
||||
});
|
||||
|
||||
it('swaps two keywords of a listed team message in one change and shows it in the list', async () => {
|
||||
useEmailStore.setState({ emails: [mail('m1', { keywords: { $seen: true, '$assigned:cj': true } })] });
|
||||
const done = await call(plugin(['email:write']), 'email.updateKeywords', 'm1', { '$assigned:cj': false, '$assigned:ck': true }, 'ch');
|
||||
expect(done).toBe(true);
|
||||
expect(client.batchUpdateKeywords).toHaveBeenCalledTimes(1);
|
||||
expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['m1'], { 'keywords/$assigned:cj': null, 'keywords/$assigned:ck': true }, 'ch');
|
||||
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true, '$assigned:ck': true });
|
||||
});
|
||||
|
||||
it('changes a message that isn\'t listed on the server only, in the named account', async () => {
|
||||
await call(plugin(['email:write']), 'email.updateKeywords', 'other', { $done: true }, 'ch');
|
||||
expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['other'], { 'keywords/$done': true }, 'ch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.setListFilters', () => {
|
||||
it('needs ui:message-list and checks what it gets', async () => {
|
||||
await expect(call(plugin([]), 'ui.setListFilters', [{ id: 'open', label: 'Open', notKeyword: '$done' }])).rejects.toThrow(/ui:message-list/);
|
||||
const lister = plugin(['ui:message-list']);
|
||||
await expect(call(lister, 'ui.setListFilters', [{ id: 'x', label: 'No condition' }])).rejects.toThrow(/hasKeyword or notKeyword/);
|
||||
await expect(call(lister, 'ui.setListFilters', [{ id: 'x', label: 'X', hasKeyword: 'a@b' }])).rejects.toThrow(/must be a keyword/);
|
||||
await expect(call(lister, 'ui.setListFilters', [{ id: 'x', label: 'X', hasKeyword: '$a', accounts: ['team'] }])).rejects.toThrow(/accounts/);
|
||||
await call(lister, 'ui.setListFilters', [{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] }]);
|
||||
expect(listFilters().map((f) => f.key)).toEqual(['team-inbox:open']);
|
||||
});
|
||||
|
||||
it('adds the conditions of the filters that are on, only where they apply', async () => {
|
||||
await call(plugin(['ui:message-list']), 'ui.setListFilters', [
|
||||
{ id: 'mine', label: 'Assigned to me', hasKeyword: '$assigned:ci', accounts: ['group'] },
|
||||
{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] },
|
||||
]);
|
||||
const team = { role: 'inbox', accountKind: 'group' as const };
|
||||
expect(activeListFilterCondition(team)).toBeNull();
|
||||
setListFilterActive('team-inbox:open');
|
||||
expect(activeListFilterCondition(team)).toEqual({ notKeyword: '$done' });
|
||||
setListFilterActive('team-inbox:mine');
|
||||
expect(activeListFilterCondition(team)).toEqual({ operator: 'AND', conditions: [{ hasKeyword: '$assigned:ci' }, { notKeyword: '$done' }] });
|
||||
expect(activeListFilterCondition({ role: 'inbox', accountKind: 'personal' })).toBeNull();
|
||||
setListFilterActive('team-inbox:mine');
|
||||
expect(activeListFilterCondition(team)).toEqual({ notKeyword: '$done' });
|
||||
});
|
||||
|
||||
it('drops a filter that was on when the plugin goes away, and says the list must load again', async () => {
|
||||
await call(plugin(['ui:message-list']), 'ui.setListFilters', [{ id: 'open', label: 'Open', notKeyword: '$done' }]);
|
||||
setListFilterActive('team-inbox:open');
|
||||
const before = listFilterRevision();
|
||||
clearPluginListFilters('team-inbox');
|
||||
expect(listFilterRevision()).toBe(before + 1);
|
||||
expect(activeListFilterCondition({ role: 'inbox', accountKind: 'group' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('action conditions', () => {
|
||||
const [assign, unassign, done, reopen] = checkActions([
|
||||
{ id: 'assign', label: 'Assign to me', accounts: ['group'] },
|
||||
{ id: 'unassign', label: 'Unassign', accounts: ['group'], hasKeyword: '$assigned:*' },
|
||||
{ id: 'done', label: 'Mark done', accounts: ['group'], notKeyword: '$done' },
|
||||
{ id: 'reopen', label: 'Reopen', accounts: ['group'], hasKeyword: '$DONE' },
|
||||
]);
|
||||
|
||||
it('shows team actions only on messages of group accounts', () => {
|
||||
expect(actionApplies(assign, { accountKind: 'group', keywords: [] })).toBe(true);
|
||||
expect(actionApplies(assign, { accountKind: 'personal', keywords: [] })).toBe(false);
|
||||
expect(actionApplies(assign, { accountKind: 'shared', keywords: [] })).toBe(false);
|
||||
// A surface that doesn't know the account hides them.
|
||||
expect(actionApplies(assign, {})).toBe(false);
|
||||
});
|
||||
|
||||
it('follows the message\'s keywords, with a prefix and in any case', () => {
|
||||
expect(actionApplies(unassign, { accountKind: 'group', keywords: ['$seen'] })).toBe(false);
|
||||
expect(actionApplies(unassign, { accountKind: 'group', keywords: ['$assigned:cj'] })).toBe(true);
|
||||
expect(actionApplies(done, { accountKind: 'group', keywords: ['$done'] })).toBe(false);
|
||||
expect(actionApplies(reopen, { accountKind: 'group', keywords: ['$done'] })).toBe(true);
|
||||
expect(actionApplies(done, { accountKind: 'group', keywords: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses conditions that aren\'t keywords or account kinds', () => {
|
||||
expect(() => checkActions([{ id: 'a', label: 'A', hasKeyword: 'a b' }])).toThrow(/hasKeyword/);
|
||||
expect(() => checkActions([{ id: 'a', label: 'A', accounts: [] }])).toThrow(/accounts/);
|
||||
expect(() => checkActions([{ id: 'a', label: 'A', accounts: ['team'] }])).toThrow(/accounts/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountData (shared plugin data in a mail account)', () => {
|
||||
it('needs email:read to read and email:write to change', async () => {
|
||||
await expect(call(plugin([]), 'accountData.get', 'ch')).rejects.toThrow(/email:read/);
|
||||
await expect(call(plugin(['email:read']), 'accountData.set', 'ch', {})).rejects.toThrow(/email:write/);
|
||||
});
|
||||
|
||||
it('says unsupported where the server keeps no metadata', async () => {
|
||||
await expect(call(plugin(['email:read']), 'accountData.get', 'ch')).resolves.toEqual({ supported: false, value: null });
|
||||
await expect(call(plugin(['email:write']), 'accountData.set', 'ch', { a: 1 })).rejects.toThrow(/keeps no shared data/);
|
||||
});
|
||||
|
||||
it('keeps the plugin\'s value under its own entry of the account\'s Inbox', async () => {
|
||||
client.supportsBulwarkMetadata.mockReturnValue(true);
|
||||
client.getBulwarkMetadata.mockResolvedValue({ tags: [1], 'plugin.team-inbox': { names: { cj: 'Bob' } } });
|
||||
await expect(call(plugin(['email:read']), 'accountData.get', 'ch')).resolves.toEqual({ supported: true, value: { names: { cj: 'Bob' } } });
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledWith('ch');
|
||||
await call(plugin(['email:write']), 'accountData.set', 'ch', { names: {} });
|
||||
expect(client.setBulwarkMetadata).toHaveBeenCalledWith('ch', 'plugin.team-inbox', { names: {} });
|
||||
});
|
||||
|
||||
it('refuses an account the user doesn\'t reach and a value over 8 KB', async () => {
|
||||
client.supportsBulwarkMetadata.mockReturnValue(true);
|
||||
await expect(call(plugin(['email:read']), 'accountData.get', 'zz')).rejects.toThrow(/no mail account/);
|
||||
await expect(call(plugin(['email:write']), 'accountData.set', 'ch', 'x'.repeat(9000))).rejects.toThrow(/at most 8192/);
|
||||
expect(client.setBulwarkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// What kind of mail account a message or folder belongs to, for plugins: the
|
||||
// user's own, a group account (a team mailbox the user is a member of) or a
|
||||
// mailbox another user shared. Stalwart names a group account by the group's
|
||||
// address, and the policy subject already lists the addresses of the user's
|
||||
// group accounts, checked against the principal type (lib/admin/policy-subject.ts).
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { subjectForAccount } from '@/lib/org/account-subjects';
|
||||
|
||||
export type MailAccountKind = 'personal' | 'group' | 'shared';
|
||||
|
||||
export const MAIL_ACCOUNT_KINDS: readonly MailAccountKind[] = ['personal', 'group', 'shared'];
|
||||
|
||||
type KindClient = Pick<IJMAPClient, 'getAccountId'> & Partial<Pick<IJMAPClient, 'getAccountInfo'>>;
|
||||
|
||||
/**
|
||||
* The kind of the JMAP account `accountId` reached through `client`, the
|
||||
* client of the login `loginId` (the active login when not given). No
|
||||
* account, or the login's own, is personal. Until the login's group list has
|
||||
* loaded, a group account counts as shared.
|
||||
*/
|
||||
export function mailAccountKind(client: KindClient | null | undefined, accountId: string | null | undefined, loginId?: string | null): MailAccountKind {
|
||||
if (!client || !accountId || accountId === client.getAccountId()) return 'personal';
|
||||
const info = typeof client.getAccountInfo === 'function' ? client.getAccountInfo(accountId) : null;
|
||||
if (!info) return 'shared';
|
||||
if (info.isPersonal) return 'personal';
|
||||
const name = info.name.trim().toLowerCase();
|
||||
const groups = subjectForAccount(loginId)?.groups ?? [];
|
||||
return name && groups.includes(name) ? 'group' : 'shared';
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { slotSpec } from '../plugin-slot-catalog';
|
||||
import { isAllowedPluginCombo } from './shortcuts';
|
||||
import { MAIL_ACCOUNT_KINDS, type MailAccountKind } from './account-kind';
|
||||
|
||||
export interface PluginAction {
|
||||
id: string;
|
||||
@@ -17,6 +18,20 @@ export interface PluginAction {
|
||||
mailboxRoles?: string[];
|
||||
/** Show only for attachments or files of these MIME types (prefix match, "image/"). */
|
||||
mimeTypes?: string[];
|
||||
/**
|
||||
* Show only for messages in these kinds of mail account (message surfaces):
|
||||
* the user's own, a group account (a team mailbox) or a mailbox someone
|
||||
* shared. Where the host can't tell the account, the action is hidden.
|
||||
*/
|
||||
accounts?: MailAccountKind[];
|
||||
/**
|
||||
* Show only when the message has this keyword (message surfaces). A
|
||||
* trailing `*` matches any keyword that starts with the rest
|
||||
* (`"$assigned:*"`). Hidden where there is no single message.
|
||||
*/
|
||||
hasKeyword?: string;
|
||||
/** Show only when the message lacks this keyword; `*` as in hasKeyword. */
|
||||
notKeyword?: string;
|
||||
/** A danger action is drawn in red. */
|
||||
danger?: boolean;
|
||||
/**
|
||||
@@ -50,6 +65,8 @@ const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
|
||||
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
|
||||
const ROLE_RE = /^[a-z]{1,20}$/;
|
||||
const MIME_RE = /^[a-z0-9.+-]+\/[a-z0-9.+*-]*$/i;
|
||||
// A JMAP keyword as plugins may set one (host-api.ts), or its start and `*`.
|
||||
const KEYWORD_PATTERN_RE = /^[a-z0-9$][a-z0-9$_.:-]{0,127}\*?$/i;
|
||||
|
||||
/** Surfaces that take actions, from the slot catalogue. */
|
||||
export function isActionSurface(surface: string): boolean {
|
||||
@@ -89,6 +106,19 @@ export function checkActions(value: unknown): PluginAction[] {
|
||||
}
|
||||
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
|
||||
}
|
||||
if (a.accounts !== undefined) {
|
||||
if (!Array.isArray(a.accounts) || a.accounts.length === 0 || !a.accounts.every((k) => (MAIL_ACCOUNT_KINDS as readonly unknown[]).includes(k))) {
|
||||
throw new Error(`actions[${i}].accounts must be a list of "personal", "group" and "shared"`);
|
||||
}
|
||||
out.accounts = [...new Set(a.accounts as MailAccountKind[])];
|
||||
}
|
||||
for (const field of ['hasKeyword', 'notKeyword'] as const) {
|
||||
if (a[field] === undefined) continue;
|
||||
if (typeof a[field] !== 'string' || !KEYWORD_PATTERN_RE.test(a[field] as string)) {
|
||||
throw new Error(`actions[${i}].${field} must be a keyword, optionally ending in *`);
|
||||
}
|
||||
out[field] = (a[field] as string).toLowerCase();
|
||||
}
|
||||
if (a.danger !== undefined) out.danger = a.danger === true;
|
||||
if (a.checked !== undefined) {
|
||||
if (typeof a.checked !== 'boolean') throw new Error(`actions[${i}].checked must be true or false`);
|
||||
@@ -164,9 +194,39 @@ export function badgeText(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
/** Whether an action applies to a folder role and/or a MIME type. */
|
||||
export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean {
|
||||
/**
|
||||
* What an action's conditions are checked against. A missing folder role or
|
||||
* MIME type isn't checked; a missing account kind or keyword list hides the
|
||||
* actions that name one, since the surface is not about one message.
|
||||
*/
|
||||
export interface ActionFilter {
|
||||
mailboxRole?: string | null;
|
||||
mimeType?: string | null;
|
||||
/** The account kind of the message (or of the open folder, for the selection toolbar). */
|
||||
accountKind?: MailAccountKind | null;
|
||||
/** The message's keywords. */
|
||||
keywords?: readonly string[] | null;
|
||||
}
|
||||
|
||||
/** Whether `keywords` holds `pattern` (exact, or a prefix ending in `*`), case-insensitively. */
|
||||
export function keywordMatches(keywords: readonly string[], pattern: string): boolean {
|
||||
const p = pattern.toLowerCase();
|
||||
if (p.endsWith('*')) {
|
||||
const prefix = p.slice(0, -1);
|
||||
return keywords.some((k) => k.toLowerCase().startsWith(prefix));
|
||||
}
|
||||
return keywords.some((k) => k.toLowerCase() === p);
|
||||
}
|
||||
|
||||
/** Whether an action applies to a folder role, a MIME type, a message's account and keywords. */
|
||||
export function actionApplies(action: PluginAction, ctx: ActionFilter): boolean {
|
||||
if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false;
|
||||
if (action.accounts && (!ctx.accountKind || !action.accounts.includes(ctx.accountKind))) return false;
|
||||
if (action.hasKeyword || action.notKeyword) {
|
||||
if (!ctx.keywords) return false;
|
||||
if (action.hasKeyword && !keywordMatches(ctx.keywords, action.hasKeyword)) return false;
|
||||
if (action.notKeyword && keywordMatches(ctx.keywords, action.notKeyword)) return false;
|
||||
}
|
||||
if (action.mimeTypes && ctx.mimeType !== undefined) {
|
||||
const type = (ctx.mimeType ?? '').toLowerCase();
|
||||
if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Host side of what a plugin may know about the signed-in user and the mail
|
||||
// accounts they reach (permission account:read): their own account and
|
||||
// address, which accounts are team (group) mailboxes, and people from the
|
||||
// server's directory. And a small piece of shared data a plugin may keep in a
|
||||
// mail account (api.accountData, Stalwart 1.0 metadata).
|
||||
//
|
||||
// Stalwart uses the account id as the principal id, so the user's own
|
||||
// account id names them in the directory and in keywords other members see.
|
||||
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { resolveEmailBlobContext } from '@/stores/email-store';
|
||||
import { isValidMetadataKey, MAX_METADATA_ENTRY_BYTES, MetadataTooLargeError, metadataValueBytes } from '../jmap/bulwark-metadata';
|
||||
import { mailAccountKind, type MailAccountKind } from './account-kind';
|
||||
import { readObject, readString, sessionClient, withinRate } from './host-api-input';
|
||||
|
||||
export interface PluginSelfView {
|
||||
/** The user's own JMAP account id, which Stalwart also uses as their principal id. */
|
||||
accountId: string;
|
||||
/** The address the user signed in with. */
|
||||
email: string;
|
||||
/** Their name: the account's display name, else their first identity's, else the address. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PluginMailAccountView {
|
||||
accountId: string;
|
||||
/** The account's name; for a group account the group's address. */
|
||||
name: string;
|
||||
kind: MailAccountKind;
|
||||
isReadOnly: boolean;
|
||||
/** The open folder belongs to this account. */
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface PluginPersonView {
|
||||
id: string;
|
||||
/** "individual", "group", or what else the server says. */
|
||||
type: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function doUserGetSelf(): PluginSelfView {
|
||||
const client = sessionClient('user.getSelf');
|
||||
const email = client.getUsername?.() ?? '';
|
||||
const accountId = client.getAccountId();
|
||||
const { accounts, activeAccountId } = useAccountStore.getState();
|
||||
const active = accounts.find((a) => a.id === activeAccountId);
|
||||
const identity = useIdentityStore.getState().identities.find((i) => i.name);
|
||||
return {
|
||||
accountId,
|
||||
email,
|
||||
name: active?.displayName?.trim() || identity?.name?.trim() || email,
|
||||
};
|
||||
}
|
||||
|
||||
export function doUserGetMailAccounts(): PluginMailAccountView[] {
|
||||
const client = sessionClient('user.getMailAccounts');
|
||||
const own = client.getAccountId();
|
||||
const open = resolveEmailBlobContext({}, client);
|
||||
const openId = open.client === client ? open.accountId || own : null;
|
||||
const shared = typeof client.getSharedAccounts === 'function' ? client.getSharedAccounts() : [];
|
||||
const ids = shared.length > 0 ? shared.filter((a) => a.isPrimary || a.capabilities.mail).map((a) => a.id) : [own];
|
||||
if (!ids.includes(own)) ids.unshift(own);
|
||||
return ids.map((accountId) => {
|
||||
const info = client.getAccountInfo?.(accountId);
|
||||
return {
|
||||
accountId,
|
||||
name: info?.name || (accountId === own ? client.getUsername?.() ?? '' : accountId),
|
||||
kind: mailAccountKind(client, accountId),
|
||||
isReadOnly: info?.isReadOnly === true,
|
||||
isOpen: accountId === openId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Directory ────────────────────────────────────────────────
|
||||
|
||||
/** Most people one user.findPeople call returns. */
|
||||
export const MAX_PLUGIN_PEOPLE = 20;
|
||||
const DIRECTORY_TTL_MS = 5 * 60_000;
|
||||
|
||||
interface DirectoryCache {
|
||||
client: IJMAPClient;
|
||||
at: number;
|
||||
complete: boolean;
|
||||
people: PluginPersonView[];
|
||||
}
|
||||
|
||||
let directory: DirectoryCache | null = null;
|
||||
let directoryLoad: Promise<DirectoryCache> | null = null;
|
||||
|
||||
async function loadDirectory(client: IJMAPClient): Promise<DirectoryCache> {
|
||||
if (directory && directory.client === client && Date.now() - directory.at < DIRECTORY_TTL_MS) return directory;
|
||||
if (directoryLoad) return directoryLoad;
|
||||
directoryLoad = (async () => {
|
||||
let complete = false;
|
||||
let principals: Array<{ id: string; type?: string; name?: string; description?: string | null; email?: string | null }> = [];
|
||||
if (client.supportsPrincipals()) {
|
||||
if (typeof client.listPrincipals === 'function') {
|
||||
const listing = await client.listPrincipals();
|
||||
complete = listing.status === 'ok';
|
||||
principals = listing.principals;
|
||||
} else {
|
||||
principals = await client.getPrincipals();
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
const people = principals.map((p) => ({
|
||||
id: p.id,
|
||||
type: typeof p.type === 'string' ? p.type : 'individual',
|
||||
name: (p.description || p.name || p.email || p.id).trim(),
|
||||
email: (p.email || (p.name?.includes('@') ? p.name : '') || '').trim().toLowerCase(),
|
||||
}));
|
||||
directory = { client, at: Date.now(), complete, people };
|
||||
return directory;
|
||||
})().finally(() => { directoryLoad = null; });
|
||||
return directoryLoad;
|
||||
}
|
||||
|
||||
/**
|
||||
* People from the server's directory: by principal id (`ids`), or whose
|
||||
* name or address contains `text`. `complete` is false when the
|
||||
* administrator turned directory lookups off: then only the people the user
|
||||
* already shares an account with (their own and their groups) are known.
|
||||
*/
|
||||
export async function doUserFindPeople(plugin: InstalledPlugin, query: unknown): Promise<{ people: PluginPersonView[]; complete: boolean }> {
|
||||
const method = 'user.findPeople';
|
||||
const opts = readObject(query, method, 'query', ['text', 'ids']);
|
||||
const client = sessionClient(method);
|
||||
if (!withinRate('directory', plugin.id, 30)) throw new Error(`${method}: too many lookups, try again in a minute`);
|
||||
let ids: Set<string> | null = null;
|
||||
if (opts.ids !== undefined) {
|
||||
if (!Array.isArray(opts.ids) || opts.ids.length > 100 || !opts.ids.every((id) => typeof id === 'string' && id.length > 0 && id.length <= 256)) {
|
||||
throw new Error(`${method}: ids must be a list of up to 100 ids`);
|
||||
}
|
||||
ids = new Set(opts.ids as string[]);
|
||||
}
|
||||
const text = opts.text === undefined ? '' : readString(opts.text, method, 'text', 200, { trim: true }).toLowerCase();
|
||||
if (!ids && !text) throw new Error(`${method}: give text or ids`);
|
||||
const { people, complete } = await loadDirectory(client);
|
||||
const found = people.filter((p) => (!ids || ids.has(p.id))
|
||||
&& (!text || p.name.toLowerCase().includes(text) || p.email.includes(text)));
|
||||
return { people: found.slice(0, MAX_PLUGIN_PEOPLE), complete };
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetPluginDirectory(): void {
|
||||
directory = null;
|
||||
directoryLoad = null;
|
||||
}
|
||||
|
||||
// ─── Shared data in a mail account ────────────────────────────
|
||||
|
||||
/** The metadata key a plugin's data lives under: org.bulwarkmail.plugin.<id>. */
|
||||
export function pluginDataKey(pluginId: string): string | null {
|
||||
const key = `plugin.${pluginId.toLowerCase()}`;
|
||||
return isValidMetadataKey(key) ? key : null;
|
||||
}
|
||||
|
||||
function dataAccount(method: string, accountArg: unknown): { client: IJMAPClient; accountId: string } {
|
||||
const client = sessionClient(method);
|
||||
if (accountArg === undefined) return { client, accountId: client.getAccountId() };
|
||||
const accountId = readString(accountArg, method, 'accountId', 256, { min: 1 });
|
||||
const known = doUserGetMailAccounts().some((a) => a.accountId === accountId);
|
||||
if (!known) throw new Error(`${method}: no mail account "${accountId}"`);
|
||||
return { client, accountId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's value in the account's shared Inbox metadata. `supported` is
|
||||
* false on servers without metadata (Stalwart before 1.0); the value is then
|
||||
* null.
|
||||
*/
|
||||
export async function doAccountDataGet(plugin: InstalledPlugin, accountArg: unknown): Promise<{ supported: boolean; value: unknown }> {
|
||||
const method = 'accountData.get';
|
||||
const { client, accountId } = dataAccount(method, accountArg);
|
||||
const key = pluginDataKey(plugin.id);
|
||||
if (!key || typeof client.getBulwarkMetadata !== 'function' || !client.supportsBulwarkMetadata?.(accountId)) {
|
||||
return { supported: false, value: null };
|
||||
}
|
||||
const entries = await client.getBulwarkMetadata(accountId);
|
||||
if (!entries) return { supported: false, value: null };
|
||||
return { supported: true, value: entries[key] ?? null };
|
||||
}
|
||||
|
||||
/** Replace (or with null remove) the plugin's value in the account's shared Inbox metadata. */
|
||||
export async function doAccountDataSet(plugin: InstalledPlugin, accountArg: unknown, value: unknown): Promise<void> {
|
||||
const method = 'accountData.set';
|
||||
const { client, accountId } = dataAccount(method, accountArg);
|
||||
const key = pluginDataKey(plugin.id);
|
||||
if (!key || typeof client.setBulwarkMetadata !== 'function' || !client.supportsBulwarkMetadata?.(accountId)) {
|
||||
throw new Error(`${method}: this server keeps no shared data for plugins`);
|
||||
}
|
||||
if (value === undefined) throw new Error(`${method}: value is required (null removes it)`);
|
||||
let size: number;
|
||||
try { size = metadataValueBytes(value); } catch { throw new Error(`${method}: value must be JSON`); }
|
||||
if (value !== null && size > MAX_METADATA_ENTRY_BYTES) {
|
||||
throw new Error(`${method}: the value is ${size} bytes; at most ${MAX_METADATA_ENTRY_BYTES} fit`);
|
||||
}
|
||||
if (!withinRate('account-data', plugin.id, 20)) throw new Error(`${method}: too many changes, try again in a minute`);
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, key, value);
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataTooLargeError) throw new Error(`${method}: the server has no room for the value (${error.message})`);
|
||||
throw new Error(`${method}: ${error instanceof Error ? error.message : 'could not save'}`);
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,15 @@ import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch,
|
||||
import { checkActions, isActionSurface, setPluginActions } from './actions';
|
||||
import { checkDayLabels, setDayLabels } from './day-labels';
|
||||
import { refreshRowBadges } from './row-badges';
|
||||
import { checkListFilters, setPluginListFilters } from './list-filters';
|
||||
import { checkPages, setPluginPages } from './pages';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { claimLegacyPluginStorage, pluginStoragePrefix } from './storage-scope';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
|
||||
import { keywordPointer } from '../jmap/patch-pointer';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import {
|
||||
@@ -34,7 +36,7 @@ import { DEFAULT_KEYWORD_SCAN_LIMIT } from '../jmap/client';
|
||||
import { isServerSetKeyword } from '../jmap/server-keywords';
|
||||
import { suggestKeywordColor } from '../keyword-discovery';
|
||||
import { MAX_KEYWORD_LENGTH } from '../keyword-nesting';
|
||||
import { KEYWORD_PREFIX } from '../thread-utils';
|
||||
import { emailKeyFor, KEYWORD_PREFIX } from '../thread-utils';
|
||||
import { DEFAULT_SEARCH_FILTERS, normalizePluginFilter } from '../jmap/search-utils';
|
||||
import { awaitDialog, awaitPrompt, awaitCustomDialog, type PromptField } from './host-dialog';
|
||||
import { fileStorage } from '../plugin-storage';
|
||||
@@ -56,6 +58,7 @@ import {
|
||||
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
|
||||
import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose';
|
||||
import { doNotificationsShow } from './host-api-notify';
|
||||
import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account';
|
||||
import { setSectionTitle } from './section-titles';
|
||||
import { requestPanel } from './panel-requests';
|
||||
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
|
||||
@@ -152,6 +155,14 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'addressbook.create': 'contacts:write',
|
||||
// user
|
||||
'user.getAccounts': 'account:read',
|
||||
// The user's own account (principal id) and address, which mail accounts
|
||||
// are team (group) mailboxes, and people from the server's directory.
|
||||
'user.getSelf': 'account:read',
|
||||
'user.getMailAccounts': 'account:read',
|
||||
'user.findPeople': 'account:read',
|
||||
// A plugin's own value in a mail account's shared Inbox metadata.
|
||||
'accountData.get': 'email:read',
|
||||
'accountData.set': 'email:write',
|
||||
'user.getIdentities': 'identity:read',
|
||||
// The IANA zone the app shows times in (Settings or the browser's).
|
||||
'user.getTimeZone': null,
|
||||
@@ -182,6 +193,7 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
// email keyword mutations
|
||||
'email.setKeyword': 'email:write',
|
||||
'email.removeKeyword': 'email:write',
|
||||
'email.updateKeywords': 'email:write',
|
||||
// Native keyword definitions are a deliberately narrow settings API: a
|
||||
// plugin can read definitions, append missing ones, or reorder the complete
|
||||
// existing set, but cannot overwrite or remove user-managed tags.
|
||||
@@ -221,6 +233,7 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'ui.setActions': null,
|
||||
// Ask the rows again for onEmailListItemRender badges (rate limited).
|
||||
'ui.refreshRowBadges': null,
|
||||
'ui.setListFilters': 'ui:message-list',
|
||||
'ui.setDayLabels': 'ui:calendar-action',
|
||||
'ui.setPages': 'ui:app-page',
|
||||
'ui.setSectionTitle': 'ui:sidebar-widget',
|
||||
@@ -1350,6 +1363,49 @@ function requireClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Most keywords one email.updateKeywords call changes. */
|
||||
export const MAX_PLUGIN_KEYWORD_CHANGES = 20;
|
||||
|
||||
/**
|
||||
* Set (true) and clear (false) several keywords of one message in one
|
||||
* Email/set, so a swap (one assignee for another) never leaves both or
|
||||
* neither. A message listed in the open view, or the open one, changes
|
||||
* through the list as the app's own actions do: the row shows the new
|
||||
* keywords at once and the keyword hooks run. Another message is changed on
|
||||
* the server only. `accountId` names the message's account (a click's
|
||||
* context carries it for team and shared mailboxes); without it the
|
||||
* signed-in account's.
|
||||
*/
|
||||
async function doEmailUpdateKeywords(emailIdArg: unknown, changesArg: unknown, accountArg: unknown): Promise<boolean> {
|
||||
const method = 'email.updateKeywords';
|
||||
const emailId = assertEmailId(emailIdArg, method);
|
||||
if (!isPlainObject(changesArg)) throw new Error(`${method}: changes must be an object of keyword: true | false`);
|
||||
const entries = Object.entries(changesArg);
|
||||
if (entries.length > MAX_PLUGIN_KEYWORD_CHANGES) throw new Error(`${method}: at most ${MAX_PLUGIN_KEYWORD_CHANGES} keywords at once`);
|
||||
const changes: Record<string, boolean> = {};
|
||||
for (const [keyword, value] of entries) {
|
||||
assertPluginKeyword(keyword);
|
||||
if (typeof value !== 'boolean') throw new Error(`${method}: keyword "${keyword}" must be true (set) or false (clear)`);
|
||||
changes[keyword] = value;
|
||||
}
|
||||
const accountId = assertOptionalAccountId(accountArg, method);
|
||||
if (entries.length === 0) return true;
|
||||
const client = requireClient();
|
||||
const own = client.getAccountId();
|
||||
const wanted = accountId && accountId !== own ? accountId : undefined;
|
||||
const state = useEmailStore.getState();
|
||||
const row = [...state.emails, ...(state.selectedEmail ? [state.selectedEmail] : [])].find((email) => {
|
||||
if (email.id !== emailId) return false;
|
||||
const owner = resolveEmailBlobContext(email, client);
|
||||
if (owner.client !== client) return false;
|
||||
return (owner.accountId && owner.accountId !== own ? owner.accountId : undefined) === wanted;
|
||||
});
|
||||
if (row) return state.patchEmailKeywords(client, emailId, changes, emailKeyFor(row));
|
||||
const patch = Object.fromEntries(Object.entries(changes).map(([keyword, value]) => [keywordPointer(keyword), value ? true : null]));
|
||||
await client.batchUpdateKeywords([emailId], patch, wanted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Native keyword definitions ──────────────────────────────
|
||||
|
||||
const MAX_PLUGIN_KEYWORD_DEFINITIONS = 500;
|
||||
@@ -1894,6 +1950,7 @@ export async function dispatchApiCall(
|
||||
return undefined;
|
||||
}
|
||||
case 'ui.refreshRowBadges': refreshRowBadges(); return undefined;
|
||||
case 'ui.setListFilters': setPluginListFilters(plugin.id, plugin.name, checkListFilters(args[0])); return undefined;
|
||||
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
|
||||
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
|
||||
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
|
||||
@@ -1981,6 +2038,11 @@ export async function dispatchApiCall(
|
||||
case 'addressbook.create': return doAddressBookCreate(args[0] as string);
|
||||
|
||||
case 'user.getAccounts': return doUserGetAccounts();
|
||||
case 'user.getSelf': return doUserGetSelf();
|
||||
case 'user.getMailAccounts': return doUserGetMailAccounts();
|
||||
case 'user.findPeople': return doUserFindPeople(plugin, args[0]);
|
||||
case 'accountData.get': return doAccountDataGet(plugin, args[0]);
|
||||
case 'accountData.set': return doAccountDataSet(plugin, args[0], args[1]);
|
||||
case 'user.getIdentities': return doUserGetIdentities();
|
||||
case 'user.getTimeZone': return userTimeZone();
|
||||
case 'app.getInfo': return doAppGetInfo();
|
||||
@@ -2097,6 +2159,7 @@ export async function dispatchApiCall(
|
||||
await requireClient().setKeyword(emailId, keyword, accountId);
|
||||
return undefined;
|
||||
}
|
||||
case 'email.updateKeywords': return doEmailUpdateKeywords(args[0], args[1], args[2]);
|
||||
case 'email.removeKeyword': {
|
||||
const emailId = assertEmailId(args[0], 'email.removeKeyword');
|
||||
const keyword = assertPluginKeyword(args[1]);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Keyword filters plugins offer for the message list (`api.ui.setListFilters`).
|
||||
// The host draws each as a chip in the list header of the folders it applies
|
||||
// to; a chip the user turns on adds its keyword condition to the folder's
|
||||
// query (ANDed with the folder, a category tab and the other chips) and can
|
||||
// be removed again with one click. Which chips are on lasts until the page is
|
||||
// reloaded, across folders: a chip that doesn't apply to a folder is neither
|
||||
// shown nor applied there.
|
||||
|
||||
import type { MailAccountKind } from './account-kind';
|
||||
import { MAIL_ACCOUNT_KINDS } from './account-kind';
|
||||
|
||||
export interface PluginListFilter {
|
||||
id: string;
|
||||
/** The chip's text, at most 40 characters. */
|
||||
label: string;
|
||||
/** Name of a host icon (see components/icons). */
|
||||
icon?: string;
|
||||
/** Only messages with this keyword. */
|
||||
hasKeyword?: string;
|
||||
/** Only messages without this keyword. */
|
||||
notKeyword?: string;
|
||||
/** Only in folders of these kinds of mail account (personal, group, shared). */
|
||||
accounts?: MailAccountKind[];
|
||||
/** Only in folders with these roles, e.g. ["inbox"]. */
|
||||
mailboxRoles?: string[];
|
||||
/** Lower comes first; default 100. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface RegisteredListFilter extends PluginListFilter {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
/** `<pluginId>:<id>`, unique across plugins. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const MAX_LIST_FILTERS_PER_PLUGIN = 4;
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
|
||||
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
|
||||
const ROLE_RE = /^[a-z]{1,20}$/;
|
||||
// The keyword rule of the plugin API (host-api.ts PLUGIN_KEYWORD_RE).
|
||||
const KEYWORD_RE = /^[a-z0-9$][a-z0-9$_.:-]{0,127}$/i;
|
||||
|
||||
/** Check and copy what a plugin sent; throws with a message the plugin sees. */
|
||||
export function checkListFilters(value: unknown): PluginListFilter[] {
|
||||
if (!Array.isArray(value)) throw new Error('filters must be a list');
|
||||
if (value.length > MAX_LIST_FILTERS_PER_PLUGIN) throw new Error(`At most ${MAX_LIST_FILTERS_PER_PLUGIN} filters per plugin`);
|
||||
const seen = new Set<string>();
|
||||
return value.map((raw, i) => {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`filters[${i}] must be an object`);
|
||||
const f = raw as Record<string, unknown>;
|
||||
if (typeof f.id !== 'string' || !ID_RE.test(f.id)) throw new Error(`filters[${i}].id is invalid`);
|
||||
if (seen.has(f.id)) throw new Error(`filters[${i}].id is used twice`);
|
||||
seen.add(f.id);
|
||||
if (typeof f.label !== 'string' || !f.label.trim() || f.label.trim().length > 40) throw new Error(`filters[${i}].label is required (max 40 characters)`);
|
||||
const out: PluginListFilter = { id: f.id, label: f.label.trim() };
|
||||
for (const field of ['hasKeyword', 'notKeyword'] as const) {
|
||||
if (f[field] === undefined) continue;
|
||||
if (typeof f[field] !== 'string' || !KEYWORD_RE.test(f[field] as string)) throw new Error(`filters[${i}].${field} must be a keyword`);
|
||||
out[field] = (f[field] as string).toLowerCase();
|
||||
}
|
||||
if (!out.hasKeyword && !out.notKeyword) throw new Error(`filters[${i}] needs hasKeyword or notKeyword`);
|
||||
if (f.icon !== undefined) {
|
||||
if (typeof f.icon !== 'string' || !ICON_RE.test(f.icon)) throw new Error(`filters[${i}].icon is invalid`);
|
||||
out.icon = f.icon;
|
||||
}
|
||||
if (f.accounts !== undefined) {
|
||||
if (!Array.isArray(f.accounts) || f.accounts.length === 0 || !f.accounts.every((k) => (MAIL_ACCOUNT_KINDS as readonly unknown[]).includes(k))) {
|
||||
throw new Error(`filters[${i}].accounts must be a list of "personal", "group" and "shared"`);
|
||||
}
|
||||
out.accounts = [...new Set(f.accounts as MailAccountKind[])];
|
||||
}
|
||||
if (f.mailboxRoles !== undefined) {
|
||||
if (!Array.isArray(f.mailboxRoles) || f.mailboxRoles.length > 10 || !f.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) {
|
||||
throw new Error(`filters[${i}].mailboxRoles must be a list of folder roles`);
|
||||
}
|
||||
out.mailboxRoles = [...f.mailboxRoles] as string[];
|
||||
}
|
||||
if (f.order !== undefined) {
|
||||
if (typeof f.order !== 'number' || !Number.isFinite(f.order)) throw new Error(`filters[${i}].order must be a number`);
|
||||
out.order = f.order;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────
|
||||
|
||||
const registry = new Map<string, RegisteredListFilter[]>();
|
||||
const active = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: RegisteredListFilter[] | null = null;
|
||||
let activeSnapshot: ReadonlySet<string> = new Set();
|
||||
/** Moves on whenever the set of applied filters changes, so the list loads again. */
|
||||
let revision = 0;
|
||||
|
||||
function changed(activeChanged: boolean): void {
|
||||
snapshot = null;
|
||||
if (activeChanged) {
|
||||
activeSnapshot = new Set(active);
|
||||
revision += 1;
|
||||
}
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeListFilters(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
export function setPluginListFilters(pluginId: string, pluginName: string, filters: PluginListFilter[]): void {
|
||||
const previous = registry.get(pluginId) ?? [];
|
||||
if (filters.length === 0) registry.delete(pluginId);
|
||||
else registry.set(pluginId, filters.map((f) => ({ ...f, pluginId, pluginName, key: `${pluginId}:${f.id}` })));
|
||||
// A filter that is on and changed its condition or went away loads the list again.
|
||||
let activeChanged = false;
|
||||
for (const key of [...active]) {
|
||||
if (!key.startsWith(`${pluginId}:`)) continue;
|
||||
const before = previous.find((f) => f.key === key);
|
||||
const after = registry.get(pluginId)?.find((f) => f.key === key);
|
||||
if (!after) {
|
||||
active.delete(key);
|
||||
activeChanged = true;
|
||||
} else if (!before || before.hasKeyword !== after.hasKeyword || before.notKeyword !== after.notKeyword) {
|
||||
activeChanged = true;
|
||||
}
|
||||
}
|
||||
changed(activeChanged);
|
||||
}
|
||||
|
||||
/** Drop everything a plugin offered (it was turned off or removed). */
|
||||
export function clearPluginListFilters(pluginId: string): void {
|
||||
if (!registry.delete(pluginId)) return;
|
||||
let activeChanged = false;
|
||||
for (const key of [...active]) {
|
||||
if (key.startsWith(`${pluginId}:`)) {
|
||||
active.delete(key);
|
||||
activeChanged = true;
|
||||
}
|
||||
}
|
||||
changed(activeChanged);
|
||||
}
|
||||
|
||||
function snapshotFilters(): RegisteredListFilter[] {
|
||||
if (snapshot) return snapshot;
|
||||
const out: RegisteredListFilter[] = [];
|
||||
for (const filters of registry.values()) out.push(...filters);
|
||||
out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
|
||||
snapshot = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every offered filter, in order; the same array until something changes. */
|
||||
export function listFilters(): RegisteredListFilter[] {
|
||||
return snapshotFilters();
|
||||
}
|
||||
|
||||
/** The keys of the filters that are on; the same set until one is turned on or off. */
|
||||
export function activeListFilterKeys(): ReadonlySet<string> {
|
||||
return activeSnapshot;
|
||||
}
|
||||
|
||||
export function listFilterRevision(): number {
|
||||
return revision;
|
||||
}
|
||||
|
||||
/** Turn a filter on or off (toggle without `on`). */
|
||||
export function setListFilterActive(key: string, on?: boolean): void {
|
||||
if (!snapshotFilters().some((f) => f.key === key)) return;
|
||||
const next = on ?? !active.has(key);
|
||||
if (next === active.has(key)) return;
|
||||
if (next) active.add(key);
|
||||
else active.delete(key);
|
||||
changed(true);
|
||||
}
|
||||
|
||||
/** The folder a filter is checked against. */
|
||||
export interface ListFilterFolder {
|
||||
role?: string | null;
|
||||
accountKind: MailAccountKind | null;
|
||||
}
|
||||
|
||||
export function listFilterApplies(filter: PluginListFilter, folder: ListFilterFolder): boolean {
|
||||
if (filter.mailboxRoles && !filter.mailboxRoles.includes(folder.role ?? '')) return false;
|
||||
if (filter.accounts && (!folder.accountKind || !filter.accounts.includes(folder.accountKind))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The JMAP condition of the filters that are on and apply to `folder`, or null. */
|
||||
export function activeListFilterCondition(folder: ListFilterFolder): Record<string, unknown> | null {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
for (const filter of snapshotFilters()) {
|
||||
if (!active.has(filter.key) || !listFilterApplies(filter, folder)) continue;
|
||||
if (filter.hasKeyword) conditions.push({ hasKeyword: filter.hasKeyword });
|
||||
if (filter.notKeyword) conditions.push({ notKeyword: filter.notKeyword });
|
||||
}
|
||||
if (conditions.length === 0) return null;
|
||||
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
/** The keywords the filters that are on and apply to `folder` test, lowercase. */
|
||||
export function activeListFilterKeywords(folder: ListFilterFolder): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const filter of snapshotFilters()) {
|
||||
if (!active.has(filter.key) || !listFilterApplies(filter, folder)) continue;
|
||||
if (filter.hasKeyword) out.add(filter.hasKeyword);
|
||||
if (filter.notKeyword) out.add(filter.notKeyword);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetListFilters(): void {
|
||||
registry.clear();
|
||||
active.clear();
|
||||
changed(true);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { registerShortcuts } from './shortcuts';
|
||||
import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions';
|
||||
import { clearPluginActions, setActionDispatcher } from './actions';
|
||||
import { clearDayLabels } from './day-labels';
|
||||
import { clearPluginListFilters } from './list-filters';
|
||||
import { clearPluginPages } from './pages';
|
||||
import { clearSectionTitle } from './section-titles';
|
||||
import { cancelPluginProxyRequests } from './proxy-client';
|
||||
@@ -376,9 +377,10 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
// Drop any message-list category tabs the plugin registered so the strip
|
||||
// disappears (and the inbox unfilters) the moment the plugin is disabled.
|
||||
try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ }
|
||||
// Same for the buttons, day labels and pages it declared, and anything
|
||||
// still running through the proxy for it.
|
||||
// Same for the buttons, list filters, day labels and pages it declared,
|
||||
// and anything still running through the proxy for it.
|
||||
clearPluginActions(pluginId);
|
||||
clearPluginListFilters(pluginId);
|
||||
clearDayLabels(pluginId);
|
||||
clearPluginPages(pluginId);
|
||||
clearSectionTitle(pluginId);
|
||||
|
||||
@@ -427,6 +427,30 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
},
|
||||
user: {
|
||||
getAccounts: () => callApi('user.getAccounts', []),
|
||||
/**
|
||||
* The signed-in user: their own JMAP account id (Stalwart's principal
|
||||
* id), address and name. Needs account:read.
|
||||
*/
|
||||
getSelf: () => callApi('user.getSelf', []) as Promise<{ accountId: string; email: string; name: string }>,
|
||||
/**
|
||||
* Every mail account of the signed-in login: their own ('personal'),
|
||||
* team mailboxes they are a member of ('group') and mailboxes others
|
||||
* shared with them ('shared'); `isOpen` marks the open folder's.
|
||||
* Needs account:read.
|
||||
*/
|
||||
getMailAccounts: () => callApi('user.getMailAccounts', []) as Promise<Array<{
|
||||
accountId: string; name: string; kind: 'personal' | 'group' | 'shared'; isReadOnly: boolean; isOpen: boolean;
|
||||
}>>,
|
||||
/**
|
||||
* People from the server's directory, by principal id or by a part of
|
||||
* their name or address (at most 20). `complete` is false when the
|
||||
* administrator turned directory lookups off; only the people the user
|
||||
* shares an account with are known then. Needs account:read.
|
||||
*/
|
||||
findPeople: (query: { text?: string; ids?: string[] }) =>
|
||||
callApi('user.findPeople', [query], NETWORK_API_TIMEOUT_MS) as Promise<{
|
||||
people: Array<{ id: string; type: string; name: string; email: string }>; complete: boolean;
|
||||
}>,
|
||||
getIdentities: () => callApi('user.getIdentities', []),
|
||||
/** The IANA time zone the app shows times in (the user's setting, else the browser's). */
|
||||
getTimeZone: () => callApi('user.getTimeZone', []) as Promise<string>,
|
||||
@@ -614,8 +638,21 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
* palette, ...). Replaces what this plugin set there before; [] removes
|
||||
* them. Clicks arrive in the `onAction({ surface, actionId, context })` hook.
|
||||
*/
|
||||
setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) =>
|
||||
setActions: (surface: string, actions: Array<{
|
||||
id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean;
|
||||
accounts?: Array<'personal' | 'group' | 'shared'>; hasKeyword?: string; notKeyword?: string;
|
||||
}>) =>
|
||||
callApi('ui.setActions', [surface, actions]) as Promise<void>,
|
||||
/**
|
||||
* Keyword filters for the message list, drawn as chips in the list
|
||||
* header of the folders they apply to. A chip the user turns on adds
|
||||
* its condition to the folder's query; replaces this plugin's earlier
|
||||
* filters, [] removes them. Needs ui:message-list.
|
||||
*/
|
||||
setListFilters: (filters: Array<{
|
||||
id: string; label: string; icon?: string; hasKeyword?: string; notKeyword?: string;
|
||||
accounts?: Array<'personal' | 'group' | 'shared'>; mailboxRoles?: string[]; order?: number;
|
||||
}>) => callApi('ui.setListFilters', [filters]) as Promise<void>,
|
||||
/**
|
||||
* Ask every visible message row for its onEmailListItemRender badges
|
||||
* again, e.g. after a task was created elsewhere. At most once a second.
|
||||
@@ -711,6 +748,25 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
callApi('email.setKeyword', [emailId, keyword, accountId]) as Promise<void>,
|
||||
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
|
||||
callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise<void>,
|
||||
/**
|
||||
* Set (true) and clear (false) up to 20 keywords of a message in one
|
||||
* change, e.g. `{ '$assigned:b': true, '$assigned:c': false }`. A
|
||||
* message in the open list shows the change at once. Resolves to false
|
||||
* when another plugin's onBeforeEmailKeywordChange stopped it.
|
||||
*/
|
||||
updateKeywords: (emailId: string, changes: Record<string, boolean>, accountId?: string) =>
|
||||
callApi('email.updateKeywords', [emailId, changes, accountId]) as Promise<boolean>,
|
||||
},
|
||||
// A small JSON value (at most 8 KB) the plugin keeps in a mail account's
|
||||
// Inbox, shared with everyone who can open that account (Stalwart 1.0
|
||||
// metadata). get needs email:read, set email:write.
|
||||
accountData: {
|
||||
/** `supported` is false on servers without metadata; `value` is then null. */
|
||||
get: (accountId?: string) =>
|
||||
callApi('accountData.get', [accountId], NETWORK_API_TIMEOUT_MS) as Promise<{ supported: boolean; value: unknown }>,
|
||||
/** Replace the value; null removes it. Throws where `get` says unsupported. */
|
||||
set: (accountId: string | undefined, value: unknown) =>
|
||||
callApi('accountData.set', [accountId, value], NETWORK_API_TIMEOUT_MS) as Promise<void>,
|
||||
},
|
||||
// The composer (permission: compose:open). Opens it filled in; the user
|
||||
// reviews the message and decides whether to send it.
|
||||
|
||||
@@ -58,7 +58,7 @@ export const SLOT_CATALOG = {
|
||||
// ─── Message list ──────────────────────────────────────────
|
||||
'message-list-header': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null }, 'above the message list'),
|
||||
'message-list-empty': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null, searching: null }, 'in an empty message list, below the "no messages" text'),
|
||||
'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read' }, 'hover buttons on a message row, and the row menu on phones', true),
|
||||
'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read', accountId: 'email:read' }, 'hover buttons on a message row, and its right-click menu (the row menu on phones); the click\'s `accountId` names the message\'s account when it is not the user\'s own (a team or shared mailbox)', true),
|
||||
'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true),
|
||||
'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'),
|
||||
'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true),
|
||||
@@ -71,7 +71,7 @@ export const SLOT_CATALOG = {
|
||||
'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'),
|
||||
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
|
||||
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read', deviceType: null }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
|
||||
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
|
||||
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read', accountId: 'email:read' }, 'buttons in the open message\'s toolbar (its More menu on phones); `accountId` as on message-row-actions', true),
|
||||
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read', focusRequest: null }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text }); `focusRequest` counts up when the plugin opened the panel with api.ui.openPanel'),
|
||||
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
|
||||
'attachment-actions': S('reader', 'frame', ['email:read'], { attachment: null }, 'buttons on each attachment', true),
|
||||
|
||||
@@ -908,6 +908,8 @@ export interface EmailListBadge {
|
||||
color?: string;
|
||||
/** Tooltip / aria-label */
|
||||
title?: string;
|
||||
/** Name of a host icon (see components/icons) shown before the label, e.g. "check" or "user". */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// ─── Message-list category tabs (Gmail-style inbox tabs) ─────
|
||||
|
||||
@@ -4412,7 +4412,7 @@
|
||||
},
|
||||
"account_read": {
|
||||
"title": "Ihre Konten sehen",
|
||||
"body": "Die von Ihnen hinzugefügten Konten mit ihren Adressen und Servern sehen."
|
||||
"body": "Die von Ihnen hinzugefügten Konten mit ihren Adressen und Servern sehen, erkennen, welche Postfächer Team-Postfächer sind, und Personen im Verzeichnis Ihrer Organisation nachschlagen."
|
||||
},
|
||||
"http_post": {
|
||||
"title": "Den Webmail-Server für Sie nutzen",
|
||||
@@ -4576,6 +4576,10 @@
|
||||
"error_save": "Speichern fehlgeschlagen: {error}"
|
||||
}
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filter",
|
||||
"remove_filter": "Filter {label} entfernen"
|
||||
},
|
||||
"global_search": {
|
||||
"title": "Alles durchsuchen",
|
||||
"placeholder": "Mail, Kontakte, Kalender, Dateien durchsuchen...",
|
||||
|
||||
@@ -4412,7 +4412,7 @@
|
||||
},
|
||||
"account_read": {
|
||||
"title": "See your accounts",
|
||||
"body": "See the accounts you added, with their addresses and servers."
|
||||
"body": "See the accounts you added with their addresses and servers, which mail accounts are team mailboxes, and look up people in your organisation's directory."
|
||||
},
|
||||
"http_post": {
|
||||
"title": "Use the webmail server for you",
|
||||
@@ -4576,6 +4576,10 @@
|
||||
"error_save": "Could not save: {error}"
|
||||
}
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filters",
|
||||
"remove_filter": "Remove filter {label}"
|
||||
},
|
||||
"global_search": {
|
||||
"title": "Search everything",
|
||||
"placeholder": "Search mail, contacts, calendar, files...",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { usePolicyStore } from '../policy-store';
|
||||
import { useMessageListTabsStore } from '../message-list-tabs-store';
|
||||
import { resetListFilters, setListFilterActive, setPluginListFilters } from '@/lib/plugin-sandbox/list-filters';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Plugins' list filters (api.ui.setListFilters): a filter the user turned on
|
||||
// is part of the folder's query, only in the folders it applies to, and
|
||||
// leaves it again when turned off.
|
||||
|
||||
const teamInbox = { id: 'ch:a', originalId: 'a', role: 'inbox', isShared: true, accountId: 'ch' } as unknown as Mailbox;
|
||||
const ownInbox = { id: 'a', role: 'inbox', isShared: false } as Mailbox;
|
||||
|
||||
function email(id: string, keywords: Record<string, boolean> = {}): Email {
|
||||
return {
|
||||
id, threadId: `thread-${id}`, mailboxIds: { a: true }, keywords,
|
||||
subject: id, receivedAt: '2026-10-01T00:00:00Z', from: [{ email: 'sender@example.com' }], to: [],
|
||||
preview: '', size: 1, hasAttachment: false,
|
||||
} as Email;
|
||||
}
|
||||
|
||||
// The auth store clears the policy subject while nobody is signed in, so the
|
||||
// group list goes in after the client.
|
||||
function signIn(c: IJMAPClient) {
|
||||
useAuthStore.setState({ client: c });
|
||||
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org'] } });
|
||||
}
|
||||
|
||||
function client(rows: Email[] = []) {
|
||||
return {
|
||||
getEmails: vi.fn(async () => ({ emails: rows, total: rows.length, hasMore: false, state: 's' })),
|
||||
getThreads: vi.fn(async () => []),
|
||||
getAccountId: () => 'ci',
|
||||
getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null),
|
||||
batchUpdateKeywords: vi.fn(async () => undefined),
|
||||
} as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetListFilters();
|
||||
useAuthStore.setState(useAuthStore.getInitialState());
|
||||
useMessageListTabsStore.setState(useMessageListTabsStore.getInitialState());
|
||||
useSettingsStore.setState({
|
||||
emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox',
|
||||
notificationMutes: { accounts: [], mailboxes: {} },
|
||||
});
|
||||
useEmailStore.setState({ ...useEmailStore.getInitialState(), selectedMailbox: 'ch:a', mailboxes: [ownInbox, teamInbox] });
|
||||
setPluginListFilters('team-inbox', 'Team inbox', [
|
||||
{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] },
|
||||
{ id: 'mine', label: 'Assigned to me', hasKeyword: '$assigned:ci', accounts: ['group'] },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetListFilters();
|
||||
usePolicyStore.setState({ subject: null });
|
||||
});
|
||||
|
||||
describe('plugin list filters in the folder query', () => {
|
||||
it('queries a team folder with the filters that are on, and without them once removed', async () => {
|
||||
const c = client();
|
||||
signIn(c);
|
||||
await useEmailStore.getState().fetchEmails(c, 'ch:a');
|
||||
expect(vi.mocked(c.getEmails).mock.calls[0][6]).toBeUndefined();
|
||||
|
||||
setListFilterActive('team-inbox:open');
|
||||
setListFilterActive('team-inbox:mine');
|
||||
await useEmailStore.getState().fetchEmails(c, 'ch:a');
|
||||
expect(vi.mocked(c.getEmails).mock.calls[1][0]).toBe('a');
|
||||
expect(vi.mocked(c.getEmails).mock.calls[1][1]).toBe('ch');
|
||||
expect(vi.mocked(c.getEmails).mock.calls[1][6]).toEqual({
|
||||
operator: 'AND', conditions: [{ hasKeyword: '$assigned:ci' }, { notKeyword: '$done' }],
|
||||
});
|
||||
|
||||
setListFilterActive('team-inbox:mine', false);
|
||||
await useEmailStore.getState().fetchEmails(c, 'ch:a');
|
||||
expect(vi.mocked(c.getEmails).mock.calls[2][6]).toEqual({ notKeyword: '$done' });
|
||||
});
|
||||
|
||||
it('leaves the user\'s own folders alone', async () => {
|
||||
const c = client();
|
||||
signIn(c);
|
||||
setListFilterActive('team-inbox:open');
|
||||
useEmailStore.setState({ selectedMailbox: 'a' });
|
||||
await useEmailStore.getState().fetchEmails(c, 'a');
|
||||
expect(vi.mocked(c.getEmails).mock.calls[0][6]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads the list again when a message changes a keyword an active filter tests', async () => {
|
||||
const c = client([email('m1')]);
|
||||
signIn(c);
|
||||
setListFilterActive('team-inbox:open');
|
||||
await useEmailStore.getState().fetchEmails(c, 'ch:a');
|
||||
const calls = vi.mocked(c.getEmails).mock.calls.length;
|
||||
await useEmailStore.getState().patchEmailKeywords(c, 'm1', { $done: true });
|
||||
await vi.waitFor(() => expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(calls));
|
||||
expect(vi.mocked(c.getEmails).mock.calls.at(-1)?.[6]).toEqual({ notKeyword: '$done' });
|
||||
});
|
||||
});
|
||||
+32
-6
@@ -30,6 +30,8 @@ import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderL
|
||||
import { keywordPointer } from "@/lib/jmap/patch-pointer";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
||||
import { activeListFilterCondition, activeListFilterKeywords, listFilters, type ListFilterFolder } from "@/lib/plugin-sandbox/list-filters";
|
||||
import { mailAccountKind } from "@/lib/plugin-sandbox/account-kind";
|
||||
import { SendCancelledCleanupError } from "@/lib/send-failure-message";
|
||||
import type { StorageQuota } from "@/lib/quota";
|
||||
import { isArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
@@ -797,22 +799,43 @@ function unreadFirstFor(
|
||||
}
|
||||
|
||||
/**
|
||||
* What a folder's query ANDs in: the open category tab's filter, and read
|
||||
* mail only in the "Unread first" inbox, whose unread mail is the section's.
|
||||
* The keyword filters plugins offer for the list (api.ui.setListFilters)
|
||||
* that the user turned on and that apply to this folder.
|
||||
*/
|
||||
function pluginListFilter(mailbox: Mailbox | undefined): Record<string, unknown> | null {
|
||||
if (!mailbox || listFilters().length === 0) return null;
|
||||
return activeListFilterCondition(pluginFilterFolder(mailbox));
|
||||
}
|
||||
|
||||
/** A folder as the plugins' list filters see it: its role and its account's kind. */
|
||||
function pluginFilterFolder(mailbox: Mailbox): ListFilterFolder {
|
||||
const auth = useAuthStore.getState();
|
||||
const viewing = useEmailStore.getState().viewingAccountId;
|
||||
const client = viewing ? auth.getClientForAccount(viewing) : auth.client;
|
||||
return {
|
||||
role: mailbox.role ?? null,
|
||||
accountKind: client ? mailAccountKind(client, mailbox.isShared ? mailbox.accountId : undefined, viewing ?? auth.activeAccountId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a folder's query ANDs in: the open category tab's filter, the
|
||||
* plugins' list filters the user turned on, and read mail only in the
|
||||
* "Unread first" inbox, whose unread mail is the section's.
|
||||
*/
|
||||
function folderListFilter(
|
||||
mailbox: Mailbox | undefined,
|
||||
s: Pick<EmailStore, 'selectedKeyword' | 'searchQuery' | 'searchFilters' | 'isUnifiedView'>,
|
||||
): Record<string, unknown> | null {
|
||||
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
||||
return andFilters(categoryFilter, unreadFirstFor(mailbox, s) ? READ_FILTER : null);
|
||||
return andFilters(andFilters(categoryFilter, pluginListFilter(mailbox)), unreadFirstFor(mailbox, s) ? READ_FILTER : null);
|
||||
}
|
||||
|
||||
/** The newest unread mail of the folder, for the "Unread first" inbox. */
|
||||
async function loadUnreadSection(client: IJMAPClient, mailbox: Mailbox, get: StoreGet): Promise<{ emails: Email[]; total: number }> {
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox.role);
|
||||
const categoryFilter = andFilters(useMessageListTabsStore.getState().getCategoryFilter(mailbox.role), pluginListFilter(mailbox));
|
||||
const result = await effectiveClient.getEmails(
|
||||
mailbox.originalId || mailbox.id,
|
||||
accountId,
|
||||
@@ -847,13 +870,16 @@ function applyUnreadSection(get: StoreGet, set: StoreSet, mailboxId: string, loa
|
||||
* the changed keywords (#718). The change moves the row at once, possibly past
|
||||
* the end of what is loaded, but nothing moves the rows from beyond it up -
|
||||
* reading the top unread mail over and over ran out of unread mail after the
|
||||
* first page while more waited on the server.
|
||||
* first page while more waited on the server. The same for a keyword a
|
||||
* plugin's list filter tests: a message marked done leaves an "Open" list.
|
||||
*/
|
||||
function refillAfterKeywordChange(get: StoreGet, client: IJMAPClient, changed: Iterable<string>): void {
|
||||
const s = get();
|
||||
if (s.isUnifiedView || s.isScheduledView || s.selectedKeyword || s.searchQuery || !isFilterEmpty(s.searchFilters)) return;
|
||||
const sortKeywords = orderKeywords(s.listOrder);
|
||||
if (![...changed].some((keyword) => sortKeywords.includes(keyword))) return;
|
||||
const mailbox = listFilters().length > 0 ? resolveActionMailboxes().find((mb) => mb.id === s.selectedMailbox) : undefined;
|
||||
const filtered = mailbox ? activeListFilterKeywords(pluginFilterFolder(mailbox)) : null;
|
||||
if (![...changed].some((keyword) => sortKeywords.includes(keyword) || !!filtered?.has(keyword.toLowerCase()))) return;
|
||||
void s.refreshCurrentMailbox(client);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user