diff --git a/components/email/__tests__/email-list-plugin-filters.test.tsx b/components/email/__tests__/email-list-plugin-filters.test.tsx
new file mode 100644
index 000000000..4e9c534b0
--- /dev/null
+++ b/components/email/__tests__/email-list-plugin-filters.test.tsx
@@ -0,0 +1,84 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { EmailList } from '../email-list';
+import { useEmailStore } from '@/stores/email-store';
+import { resetListFilters, setPluginListFilters } from '@/lib/plugin-sandbox/list-filters';
+import { confirmGroups, resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// The plugins' list filter chips in the message list: shown in a team
+// folder's own list, loading the list again when one is turned on, and not
+// in a search, a tag view or the merged views.
+
+const client = vi.hoisted(() => ({
+ getAccountId: () => 'ci',
+ getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null),
+ getSharedAccounts: () => [
+ { id: 'ci', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } },
+ { id: 'ch', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } },
+ ],
+ getPrincipalTypes: async () => ({ ch: 'group' }),
+}));
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+vi.mock('@/stores/auth-store', async () => {
+ const { create } = await import('zustand');
+ return {
+ useAuthStore: create(() => ({
+ client, identities: [], username: null, activeAccountId: 'acct-1',
+ getClientForAccount: () => client, getAllConnectedClients: () => new Map(),
+ })),
+ };
+});
+vi.mock('@/stores/account-store', () => {
+ const state = { getAccountById: () => undefined };
+ return {
+ useAccountStore: Object.assign(
+ (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ { getState: () => state },
+ ),
+ };
+});
+
+const teamInbox = { id: 'ch:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'ch', totalEmails: 0, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox;
+const initialEmailState = useEmailStore.getState();
+const fetchEmails = vi.fn(async () => {});
+
+beforeEach(async () => {
+ resetListFilters();
+ resetConfirmedGroups();
+ fetchEmails.mockClear();
+ useEmailStore.setState(initialEmailState, true);
+ useEmailStore.setState({ selectedMailbox: 'ch:a', mailboxes: [teamInbox], fetchEmails });
+ setPluginListFilters('team-inbox', 'Team inbox', [{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] }]);
+ await confirmGroups(client as never);
+});
+
+describe('plugin list filters in the message list', () => {
+ it('shows the chip in a team folder and loads the list when it is turned on and off', async () => {
+ render();
+ const chip = await screen.findByRole('button', { name: 'Open' });
+ expect(fetchEmails).not.toHaveBeenCalled();
+ act(() => { fireEvent.click(chip); });
+ expect(chip).toHaveAttribute('aria-pressed', 'true');
+ expect(fetchEmails).toHaveBeenCalledTimes(1);
+ expect(fetchEmails).toHaveBeenCalledWith(client);
+ act(() => { fireEvent.click(chip); });
+ expect(fetchEmails).toHaveBeenCalledTimes(2);
+ });
+
+ it.each([
+ ['a search', { searchQuery: 'invoice' }],
+ ['a tag view', { selectedKeyword: 'work' }],
+ ['the merged views', { isUnifiedView: true }],
+ ])('leaves %s without chips', async (_name, state) => {
+ useEmailStore.setState(state);
+ render();
+ await act(async () => {});
+ expect(screen.queryByTestId('plugin-list-filters')).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/plugin-list-filters.test.tsx b/components/email/__tests__/plugin-list-filters.test.tsx
new file mode 100644
index 000000000..99be089c9
--- /dev/null
+++ b/components/email/__tests__/plugin-list-filters.test.tsx
@@ -0,0 +1,64 @@
+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( {}} />);
+ 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( {}} />);
+ 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();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Open' }));
+ // The name stays the label; pressed says it's on, the tooltip how to remove it.
+ const active = screen.getByRole('button', { name: 'Open' });
+ expect(active).toHaveAttribute('aria-pressed', 'true');
+ expect(active).toHaveAttribute('title', 'remove_filter');
+ 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();
+ fireEvent.click(screen.getByRole('button', { name: 'Open' }));
+ onChange.mockClear();
+
+ act(() => clearPluginListFilters('team-inbox'));
+ expect(screen.queryByRole('group')).toBeNull();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx
index 149d9a559..5db64285b 100644
--- a/components/email/__tests__/plugin-row-badges.test.tsx
+++ b/components/email/__tests__/plugin-row-badges.test.tsx
@@ -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">