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:
Linus Rath
2026-10-28 06:39:57 +01:00
parent daa1cccdb2
commit a69d7eee33
26 changed files with 1442 additions and 56 deletions
@@ -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 }]);
+8 -2
View File
@@ -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}
/>
+6 -2
View File
@@ -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"
+13 -1
View File
@@ -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} />
+4 -1
View File
@@ -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)}
/>
+88
View File
@@ -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>
);
}
+29 -18
View File
@@ -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>
);
}
+7 -3
View File
@@ -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: '' });
});
});
+88 -8
View File
@@ -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>
+249
View File
@@ -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();
});
});
+30
View File
@@ -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';
}
+62 -2
View File
@@ -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;
+211
View File
@@ -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'}`);
}
}
+65 -2
View File
@@ -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]);
+219
View File
@@ -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);
}
+4 -2
View File
@@ -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);
+57 -1
View File
@@ -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.
+2 -2
View File
@@ -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),
+2
View File
@@ -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) ─────
+5 -1
View File
@@ -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...",
+5 -1
View File
@@ -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
View File
@@ -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);
}