mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Merge branch 'feat/org-teaminbox' into feat/org-features
This commit is contained in:
@@ -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(<EmailList emails={[]} />);
|
||||
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(<EmailList emails={[]} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('plugin-list-filters')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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(<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' }));
|
||||
// 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(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
onChange.mockClear();
|
||||
|
||||
act(() => clearPluginListFilters('team-inbox'));
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,22 @@ describe('plugin badges in message-list rows', () => {
|
||||
expect(ctx.email).not.toHaveProperty('mailboxIds.inbox');
|
||||
});
|
||||
|
||||
it('draws a badge\'s icon before its label, and none for a name that isn\'t one', async () => {
|
||||
renderHooks.onEmailListItemRender.register('test', async () => [
|
||||
{ key: 'done', label: 'Done', icon: 'check' },
|
||||
{ key: 'bad', label: 'Bad', icon: 'x"><script>' },
|
||||
]);
|
||||
|
||||
const { container } = render(row([makeEmail()]));
|
||||
|
||||
await screen.findByText('Done');
|
||||
const done = container.querySelector('[data-plugin-row-badge="done"]')!;
|
||||
expect(done.querySelector('svg[data-badge-icon="check"]')).not.toBeNull();
|
||||
expect(done).toHaveTextContent('Done');
|
||||
expect(container.querySelector('[data-plugin-row-badge="bad"] svg')).toBeNull();
|
||||
expect(normalizeBadges([{ key: 'bad', label: 'Bad', icon: 'x"><script>' }])[0]).not.toHaveProperty('icon');
|
||||
});
|
||||
|
||||
it('asks the plugin again when it requests a refresh, at most once a second', async () => {
|
||||
let label = 'Open';
|
||||
const handler = vi.fn(async () => [{ key: 'task', label }]);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
@@ -292,7 +292,12 @@ export function EmailContextMenu({
|
||||
|
||||
// The row's plugin buttons (message-row-actions). On phones the long press
|
||||
// opens this menu, so it is where those buttons live there.
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0;
|
||||
const rowPluginTarget = useMessageActionTarget(email);
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", {
|
||||
mailboxRole: currentMailboxRole ?? null,
|
||||
accountKind: rowPluginTarget.accountKind,
|
||||
keywords: rowPluginTarget.keywords,
|
||||
}).length > 0;
|
||||
const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
const rowPluginBlock = !showBatchActions && hasRowPluginActions ? (
|
||||
<>
|
||||
@@ -301,6 +306,7 @@ export function EmailContextMenu({
|
||||
surface="message-row-actions"
|
||||
variant="menu"
|
||||
context={rowPluginContext}
|
||||
target={rowPluginTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={onClose}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
@@ -123,7 +123,10 @@ export function EmailHoverActions({
|
||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||
const isMobile = useIsMobile();
|
||||
// Buttons plugins add to the row (message-row-actions), after the app's own.
|
||||
const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length;
|
||||
const pluginTarget = useMessageActionTarget(email);
|
||||
const pluginActionCount = usePluginActions("message-row-actions", {
|
||||
mailboxRole, accountKind: pluginTarget.accountKind, keywords: pluginTarget.keywords,
|
||||
}).length;
|
||||
const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
|
||||
// The tag quick-action opens the same picker the context menu and the reading
|
||||
@@ -269,6 +272,7 @@ export function EmailHoverActions({
|
||||
key="plugin-actions"
|
||||
surface="message-row-actions"
|
||||
context={pluginContext}
|
||||
target={pluginTarget}
|
||||
mailboxRole={mailboxRole}
|
||||
compact
|
||||
className="flex-nowrap gap-0.5"
|
||||
|
||||
@@ -38,7 +38,8 @@ import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginListFilters } from "./plugin-list-filters";
|
||||
import { PluginActions, useFolderAccountKind } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { unreadSectionRowCount } from "@/lib/unread-first";
|
||||
import { MvListSectionHeader } from "./mv-list-section-header";
|
||||
@@ -532,6 +533,7 @@ export function EmailList({
|
||||
[emails, threadEmailsCache, selectedEmailKeys],
|
||||
);
|
||||
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
|
||||
const folderAccountKind = useFolderAccountKind();
|
||||
// "Remove from this folder" (#615): offered for a selection only when every
|
||||
// selected message is also in another folder.
|
||||
const removeFromFolder = useRemoveFromFolder();
|
||||
@@ -914,6 +916,7 @@ export function EmailList({
|
||||
<PluginActions
|
||||
surface="message-bulk-actions"
|
||||
context={bulkActionContext}
|
||||
accountKind={folderAccountKind}
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
compact
|
||||
overflow={batchNarrow}
|
||||
@@ -976,6 +979,15 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins' keyword filters, in a folder's own list (not search, tags or merged views). */}
|
||||
{!isSearching && !selectedKeyword && !isUnifiedView && !isScheduledView && selectedMailbox && (
|
||||
<PluginListFilters
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
accountKind={folderAccountKind}
|
||||
onChange={() => { if (client) void useEmailStore.getState().fetchEmails(client); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
||||
|
||||
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
|
||||
|
||||
@@ -139,7 +139,7 @@ import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { ConversationEmailBody, type ConversationAttachmentHandler, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
|
||||
import type { TnefAttachment } from "@/lib/tnef";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { RulesPanel, useRulesAvailability } from "./rules-menu";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
@@ -1604,6 +1604,7 @@ export function EmailViewer({
|
||||
const emailIdRef = useRef(email?.id);
|
||||
emailIdRef.current = email?.id;
|
||||
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const readerActionTarget = useMessageActionTarget(email);
|
||||
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
// The user's choice; until they make one, a panel whose plugin asked to
|
||||
// start folded (a chat opened on demand) is folded, the rest open.
|
||||
@@ -3992,6 +3993,7 @@ export function EmailViewer({
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
target={readerActionTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
compact={!showToolbarLabels}
|
||||
/>
|
||||
@@ -4819,6 +4821,7 @@ export function EmailViewer({
|
||||
<PluginPanelEntries
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
target={readerActionTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"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
|
||||
* is pressed, 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}
|
||||
// The name stays the filter's label; aria-pressed says it is on,
|
||||
// and the tooltip says a click removes it.
|
||||
title={on ? t("remove_filter", { label: filter.label }) : 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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import type { EmailListBadge } from "@/lib/plugin-types";
|
||||
import { renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emailSummary } from "@/lib/plugin-projection";
|
||||
import { rowBadgeGeneration, subscribeRowBadges } from "@/lib/plugin-sandbox/row-badges";
|
||||
import { PLUGIN_ICON_RE } from "@/lib/plugin-sandbox/actions";
|
||||
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.
|
||||
@@ -67,6 +69,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 && PLUGIN_ICON_RE.test(icon)) badge.icon = icon;
|
||||
out.push(badge);
|
||||
}
|
||||
return out;
|
||||
@@ -141,24 +145,30 @@ export function PluginRowBadges({ email, className }: { email: Email; className?
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<span className={cn(TAG_GROUP_CLASS, className)} data-testid="plugin-row-badges">
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge.key}
|
||||
title={badge.title}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[8rem] truncate border",
|
||||
!badge.color && "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
style={badge.color ? {
|
||||
color: badge.color,
|
||||
borderColor: badge.color,
|
||||
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
|
||||
} : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
{badges.map((badge) => {
|
||||
const Icon = iconForName(badge.icon);
|
||||
return (
|
||||
<span
|
||||
key={badge.key}
|
||||
title={badge.title}
|
||||
data-plugin-row-badge={badge.key}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[8rem] truncate border",
|
||||
Icon && "gap-0.5",
|
||||
!badge.color && "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
style={badge.color ? {
|
||||
color: badge.color,
|
||||
borderColor: badge.color,
|
||||
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
|
||||
} : undefined}
|
||||
>
|
||||
{Icon && <Icon className="w-3 h-3 flex-shrink-0" aria-hidden data-badge-icon={badge.icon} />}
|
||||
{Icon ? <span className="truncate">{badge.label}</span> : badge.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { iconForName, MoreHorizontal, Puzzle } from "@/components/icons";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, usePluginActions, type MessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -128,14 +128,18 @@ export function PluginPanelEntries({
|
||||
surface,
|
||||
context,
|
||||
mailboxRole,
|
||||
target,
|
||||
onRan,
|
||||
}: {
|
||||
surface: string;
|
||||
context: Record<string, unknown>;
|
||||
mailboxRole?: string | null;
|
||||
/** The open message (useMessageActionTarget). */
|
||||
target?: MessageActionTarget;
|
||||
onRan?: () => void;
|
||||
}) {
|
||||
const actions = usePluginActions(surface, { mailboxRole });
|
||||
const actions = usePluginActions(surface, { mailboxRole, accountKind: target?.accountKind ?? null, keywords: target?.keywords ?? null });
|
||||
const clickContext = target?.accountId ? { ...context, accountId: target.accountId } : context;
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
@@ -147,7 +151,7 @@ export function PluginPanelEntries({
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
role="menuitem"
|
||||
title={action.pluginName}
|
||||
onClick={() => { onRan?.(); void runAction(action, surface, context); }}
|
||||
onClick={() => { onRan?.(); void runAction(action, surface, clickContext); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
action.danger ? "text-destructive" : "text-foreground",
|
||||
|
||||
@@ -51,7 +51,7 @@ import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
|
||||
@@ -339,7 +339,8 @@ function useConversationPluginProps(email: Email) {
|
||||
const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
|
||||
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
|
||||
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
return { emailProps, idProps };
|
||||
const actionTarget = useMessageActionTarget(email);
|
||||
return { emailProps, idProps, actionTarget };
|
||||
}
|
||||
|
||||
function EmailCard({
|
||||
@@ -490,6 +491,7 @@ function EmailCard({
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={plugin.idProps}
|
||||
target={plugin.actionTarget}
|
||||
mailboxRole={mailboxRole ?? null}
|
||||
overflow
|
||||
className="flex-shrink-0 rounded-md border border-border"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { iconForName } from '../named';
|
||||
|
||||
describe('icons by name', () => {
|
||||
it('draws the team inbox plugin\'s icons at once, without loading the whole set', () => {
|
||||
for (const name of ['user', 'check', 'user-check', 'user-x', 'circle-check', 'circle-dashed', 'arrow-back-up', 'user-plus']) {
|
||||
const Icon = iconForName(name)!;
|
||||
const { container, unmount } = render(<Icon data-testid="icon" />);
|
||||
// A curated icon draws its paths straight away; the lazy blank has none.
|
||||
expect(container.querySelector('svg path'), name).not.toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the caller\'s attributes on the blank shown while an icon loads', () => {
|
||||
const Icon = iconForName('brand-mastodon')!;
|
||||
const { container } = render(<Icon data-testid="lazy" data-kind="x" className="w-3" size={12} />);
|
||||
const svg = container.querySelector('svg')!;
|
||||
expect(svg.getAttribute('data-testid')).toBe('lazy');
|
||||
expect(svg.getAttribute('data-kind')).toBe('x');
|
||||
expect(svg.getAttribute('class')).toBe('w-3');
|
||||
expect(svg.getAttribute('width')).toBe('12');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,13 @@
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import type { TablerIcon } from '@tabler/icons-react';
|
||||
import {
|
||||
IconArrowBackUp,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCircleDashed,
|
||||
IconUser,
|
||||
IconUserCheck,
|
||||
IconUserX,
|
||||
IconAddressBook,
|
||||
IconAperture,
|
||||
IconApple,
|
||||
@@ -177,6 +184,15 @@ export const TABLER_ICON_PREFIX = 'tabler:';
|
||||
export const POPULAR_ICON_NAMES: readonly string[] = ["world","rss","radio","microphone-2","message-circle","message","messages","phone","video","device-computer-camera","headphones","microphone","file-text","file-spreadsheet","notebook","book","clipboard-list","checklist","square-check","layout-kanban","brand-trello","pencil","edit","code","terminal","braces","bug","database","server","cpu","server-2","device-desktop","device-laptop","device-mobile","device-tablet","wifi","cloud","cloud-download","cloud-upload","users","user-plus","user-circle","address-book","man","heart","thumb-up","star","award","trophy","crown","photo","camera","movie","music","player-play","device-tv","brand-youtube","palette","brush","map","map-pin","navigation","compass","home","building","building-bank","building-store","building-warehouse","currency-dollar","currency-euro","credit-card","wallet","receipt","pig-money","trending-up","chart-bar","chart-column","chart-line","chart-pie","shield","shield-check","lock","lock-open","key","fingerprint","eye","flask-2","atom","dna","microscope","stethoscope","heartbeat","pill","vaccine","thermometer","sun","moon","sun-wind","snowflake","bolt","flame","christmas-tree","flower","leaf","mountain","ripple","tool","hammer","scissors","ruler","magnet","package","gift","box","archive","car","bike","bus","train","plane","ship","rocket","coffee","glass","beer","pizza","apple","cake","soup","device-gamepad-2","dice-5","puzzle","sparkles","wand","robot","brain","bulb","bookmark","flag","bell","clock","stopwatch","link","external-link","qrcode","scan","layout-grid","stack","aperture","circle-dot","target","crosshair"];
|
||||
|
||||
const CURATED: Record<string, TablerIcon> = {
|
||||
// Plugin badges, actions and list filters (team inbox), so a list of rows
|
||||
// never waits for the whole set.
|
||||
'arrow-back-up': IconArrowBackUp,
|
||||
'check': IconCheck,
|
||||
'circle-check': IconCircleCheck,
|
||||
'circle-dashed': IconCircleDashed,
|
||||
'user': IconUser,
|
||||
'user-check': IconUserCheck,
|
||||
'user-x': IconUserX,
|
||||
'address-book': IconAddressBook,
|
||||
'aperture': IconAperture,
|
||||
'apple': IconApple,
|
||||
@@ -403,8 +419,11 @@ function lazyIcon(name: string): TablerIcon {
|
||||
};
|
||||
}, [Resolved]);
|
||||
if (!Resolved) {
|
||||
const size = props.size ?? 24;
|
||||
return <svg ref={ref} width={size} height={size} className={props.className} aria-hidden="true" />;
|
||||
// A blank of the same size, with the caller's attributes (test ids,
|
||||
// aria, data-*), until the set has loaded.
|
||||
const { size = 24, stroke: _stroke, title: _title, ...rest } = props;
|
||||
void _stroke; void _title;
|
||||
return <svg ref={ref} aria-hidden="true" {...rest} width={size} height={size} />;
|
||||
}
|
||||
return <Resolved ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 { resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind';
|
||||
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 +72,72 @@ 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 server's word on groups", async () => {
|
||||
let answer!: (types: Record<string, string>) => void;
|
||||
const client = {
|
||||
getAccountId: () => 'ci',
|
||||
getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null),
|
||||
getSharedAccounts: () => [{ id: 'ci', isPrimary: true }, { id: 'ch', isPrimary: false }],
|
||||
getPrincipalTypes: () => new Promise((resolve) => { answer = resolve; }),
|
||||
} as unknown as IJMAPClient;
|
||||
resetConfirmedGroups();
|
||||
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 });
|
||||
// The policy subject counting it a group is not enough.
|
||||
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org'] } });
|
||||
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'] });
|
||||
|
||||
await act(async () => { answer({ ch: 'group' }); });
|
||||
expect(result.current.accountKind).toBe('group');
|
||||
|
||||
act(() => useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes: [team, { id: 'inbox', role: 'inbox' } as Mailbox] }));
|
||||
expect(result.current).toEqual({ accountKind: 'personal', keywords: ['$done', '$seen'] });
|
||||
|
||||
useAuthStore.setState({ client: null });
|
||||
usePolicyStore.setState({ subject: null });
|
||||
useEmailStore.setState({ mailboxes: [], selectedMailbox: '' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore, useState } from 'react';
|
||||
import { useMemo, useSyncExternalStore, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons';
|
||||
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
|
||||
@@ -12,17 +12,87 @@ import {
|
||||
badgeText,
|
||||
runAction,
|
||||
subscribeActions,
|
||||
type ActionFilter,
|
||||
type RegisteredAction,
|
||||
} from '@/lib/plugin-sandbox/actions';
|
||||
import { mailAccountKind, useConfirmedGroups, 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';
|
||||
|
||||
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
|
||||
* server's answer on which accounts are groups, so a team mailbox's
|
||||
* actions appear once it is in.
|
||||
*/
|
||||
export function useMessageActionTarget(email: ActionEmail | null | undefined): MessageActionTarget {
|
||||
const groups = useConfirmedGroups((s) => s.revision);
|
||||
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) : 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, groups, selectedMailbox, mailboxes]);
|
||||
}
|
||||
|
||||
/** The account kind of the open folder, for surfaces about the selection (message-bulk-actions). */
|
||||
export function useFolderAccountKind(): MailAccountKind | null {
|
||||
const groups = useConfirmedGroups((s) => s.revision);
|
||||
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) : null;
|
||||
// See useMessageActionTarget.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, groups, 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 +109,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 +132,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 +196,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 +222,8 @@ function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, classN
|
||||
context={context}
|
||||
mailboxRole={mailboxRole}
|
||||
mimeType={mimeType}
|
||||
target={target}
|
||||
accountKind={accountKind}
|
||||
onRan={closeContextMenu}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { emailItem, expandSharedFolders, folderRow } from './helpers/app';
|
||||
import {
|
||||
ORG,
|
||||
ORG_PASSWORD,
|
||||
ORG_SERVER,
|
||||
adminContext,
|
||||
getPolicy,
|
||||
putPolicy,
|
||||
requireOrgEnv,
|
||||
shot,
|
||||
userPage,
|
||||
} from './helpers/org';
|
||||
|
||||
/**
|
||||
* The team inbox plugin (organisation feature 10) through the real UI on a
|
||||
* VM 106 test server: installed from a local build through the admin, then
|
||||
* org-alice assigns a message of the org-team group to org-bob, bob finds it
|
||||
* under "Assigned to me" and marks it done, which takes it out of "Open".
|
||||
* carol, who is in no group, and alice's own mailbox get no team actions.
|
||||
*
|
||||
* IT_TEAM_INBOX_ZIP names the zip (default: the plugins worktree's build).
|
||||
* The test messages are created in the group's Inbox over JMAP and
|
||||
* destroyed at the end, keywords and all; the plugin is removed and the
|
||||
* policy put back.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 240000 });
|
||||
|
||||
const ZIP = process.env.IT_TEAM_INBOX_ZIP ?? 'C:/Users/info/worktrees/plugins-org-teaminbox/team-inbox/team-inbox.zip';
|
||||
const RUN = Date.now().toString(36);
|
||||
const SELF = `Team inbox bob takes it ${RUN}`;
|
||||
const FOR_BOB = `Team inbox for bob ${RUN}`;
|
||||
const OTHER = `Team inbox nobody ${RUN}`;
|
||||
const OWN = `Team inbox alice own ${RUN}`;
|
||||
|
||||
let original: Record<string, unknown>;
|
||||
const created: Array<{ account: string; accountId: string; id: string; subject: string }> = [];
|
||||
|
||||
/** A received-looking message, created by `account` in the Inbox of the account named `accountName`. */
|
||||
async function createMessage(account: string, accountName: string, subject: string): Promise<void> {
|
||||
const client = await JmapClient.connect(account, ORG_PASSWORD);
|
||||
const accountId = accountName === account ? client.accountId : client.accountIdByName(accountName);
|
||||
const inbox = await client.mailboxByRole('inbox', accountId);
|
||||
if (!inbox) throw new Error(`${accountName}: no Inbox`);
|
||||
const r = await client.request([
|
||||
['Email/set', {
|
||||
accountId,
|
||||
create: {
|
||||
m: {
|
||||
mailboxIds: { [inbox.id]: true },
|
||||
keywords: {},
|
||||
from: [{ name: 'Customer', email: 'customer@example.net' }],
|
||||
to: [{ email: accountName }],
|
||||
subject,
|
||||
receivedAt: new Date().toISOString(),
|
||||
bodyValues: { b: { value: `A message for the team inbox spec (${RUN}).` } },
|
||||
textBody: [{ partId: 'b', type: 'text/plain' }],
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const id = r.methodResponses?.[0]?.[1]?.created?.m?.id;
|
||||
if (!id) throw new Error(`could not create "${subject}": ${JSON.stringify(r.methodResponses?.[0])}`);
|
||||
created.push({ account, accountId, id, subject });
|
||||
}
|
||||
|
||||
/** The keywords of a test message, as the server has them. */
|
||||
async function keywordsOf(subject: string): Promise<string[]> {
|
||||
const entry = created.find((c) => c.subject === subject);
|
||||
if (!entry) return [];
|
||||
const client = await JmapClient.connect(entry.account, ORG_PASSWORD);
|
||||
const r = await client.request([['Email/get', { accountId: entry.accountId, ids: [entry.id], properties: ['keywords'] }, '0']]);
|
||||
return Object.keys(r.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {});
|
||||
}
|
||||
|
||||
async function openTeamInbox(page: Page): Promise<void> {
|
||||
await expandSharedFolders(page, ORG.team);
|
||||
await folderRow(page, { role: 'inbox', shared: true }).first().click();
|
||||
}
|
||||
|
||||
function filterChip(page: Page, name: string): Locator {
|
||||
return page.locator('[data-testid="plugin-list-filters"] button', { hasText: name });
|
||||
}
|
||||
|
||||
/** Right-click a row and wait for the menu to carry the plugin's entries. */
|
||||
async function rowMenu(page: Page, subject: string): Promise<void> {
|
||||
const row = emailItem(page, subject).first();
|
||||
await row.waitFor({ state: 'visible', timeout: 30000 });
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
await row.click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Reply', exact: true }).first().waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 20000 });
|
||||
}
|
||||
|
||||
/** The plugin runs once its background frame is there and it has offered its filters. */
|
||||
async function pluginReady(page: Page): Promise<void> {
|
||||
await expect(page.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 60000 });
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
test.setTimeout(300000);
|
||||
test.setTimeout(300000);
|
||||
requireOrgEnv();
|
||||
test.skip(!existsSync(ZIP), `build the plugin first: ${ZIP}`);
|
||||
const admin = await adminContext(browser);
|
||||
original = await getPolicy(admin);
|
||||
// next dev compiles the sandbox page on first use, which can outlast the plugin's init timeout.
|
||||
await admin.request.get('/plugin-sandbox', { timeout: 120000 });
|
||||
await admin.request.delete('/api/admin/plugins', { data: { id: 'team-inbox' } });
|
||||
const installed = await admin.request.post('/api/admin/plugins', {
|
||||
multipart: { file: { name: 'team-inbox.zip', mimeType: 'application/zip', buffer: readFileSync(ZIP) } },
|
||||
});
|
||||
expect(installed.ok(), await installed.text()).toBe(true);
|
||||
const features = { ...(original.features as Record<string, unknown>), pluginsEnabled: true };
|
||||
const forced = [...new Set([...((original.forceEnabledPlugins as string[] | undefined) ?? []), 'team-inbox'])];
|
||||
await putPolicy(admin, { ...original, features, forceEnabledPlugins: forced });
|
||||
await admin.close();
|
||||
|
||||
await createMessage(ORG.alice, ORG.team, OTHER);
|
||||
await createMessage(ORG.alice, ORG.team, SELF);
|
||||
await createMessage(ORG.alice, ORG.team, FOR_BOB);
|
||||
await createMessage(ORG.alice, ORG.alice, OWN);
|
||||
});
|
||||
|
||||
const METADATA = 'urn:ietf:params:jmap:metadata';
|
||||
const NAMES_ENTRY = 'org.bulwarkmail.plugin.team-inbox';
|
||||
|
||||
/** The plugin's names in the team Inbox's shared metadata (only v1.0.0 keeps them). */
|
||||
async function sharedNames(): Promise<Record<string, unknown> | null> {
|
||||
const client = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
|
||||
const accountId = client.accountIdByName(ORG.team);
|
||||
const inbox = await client.mailboxByRole('inbox', accountId);
|
||||
const r = await client.request([['Mailbox/get', { accountId, ids: [inbox!.id], properties: ['id', 'metadata'] }, '0']],
|
||||
['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', METADATA]);
|
||||
return r.methodResponses?.[0]?.[1]?.list?.[0]?.metadata?.[NAMES_ENTRY] ?? null;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
test.setTimeout(180000);
|
||||
for (const entry of created) {
|
||||
const client = await JmapClient.connect(entry.account, ORG_PASSWORD);
|
||||
await client.request([['Email/set', { accountId: entry.accountId, destroy: [entry.id] }, '0']]).catch(() => {});
|
||||
}
|
||||
// The names the plugin kept in the team Inbox go too (other features keep
|
||||
// their own entries there).
|
||||
if (ORG_SERVER === '1.0.0') {
|
||||
const client = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
|
||||
const accountId = client.accountIdByName(ORG.team);
|
||||
const inbox = await client.mailboxByRole('inbox', accountId);
|
||||
if (inbox) {
|
||||
await client.request([['Mailbox/set', { accountId, update: { [inbox.id]: { [`metadata/${NAMES_ENTRY}`]: null } } }, '0']],
|
||||
['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', METADATA]).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!original) return;
|
||||
const admin = await adminContext(browser);
|
||||
await putPolicy(admin, original);
|
||||
await admin.request.delete('/api/admin/plugins', { data: { id: 'team-inbox' } });
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
test('bob takes a message himself from the row menu', async ({ browser }) => {
|
||||
const bob = await userPage(browser, ORG.bob);
|
||||
await pluginReady(bob);
|
||||
await openTeamInbox(bob);
|
||||
await expect(filterChip(bob, 'Assigned to me')).toBeVisible({ timeout: 30000 });
|
||||
await expect(filterChip(bob, 'Open')).toBeVisible();
|
||||
|
||||
await rowMenu(bob, SELF);
|
||||
await expect(bob.getByRole('menuitem', { name: 'Mark done' })).toBeVisible();
|
||||
await expect(bob.getByRole('menuitem', { name: 'Reopen' })).toHaveCount(0);
|
||||
await expect(bob.getByRole('menuitem', { name: 'Unassign' })).toHaveCount(0);
|
||||
await bob.getByRole('menuitem', { name: 'Assign to me' }).click();
|
||||
await expect(emailItem(bob, SELF).first().locator('[data-plugin-row-badge="team-inbox-assignee"]')).toHaveText('Me', { timeout: 20000 });
|
||||
await expect.poll(() => keywordsOf(SELF)).toContain(`$assigned:${await principalId(ORG.bob)}`);
|
||||
await bob.context().close();
|
||||
});
|
||||
|
||||
async function principalId(email: string): Promise<string> {
|
||||
return (await JmapClient.connect(email, ORG_PASSWORD)).accountId;
|
||||
}
|
||||
|
||||
test('alice assigns a team message to bob', async ({ browser }) => {
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
await pluginReady(alice);
|
||||
await openTeamInbox(alice);
|
||||
await expect(emailItem(alice, SELF).first().locator('[data-plugin-row-badge="team-inbox-assignee"]')).toBeVisible({ timeout: 30000 });
|
||||
await shot(alice, 'teaminbox-list-badges');
|
||||
|
||||
await rowMenu(alice, FOR_BOB);
|
||||
await alice.getByRole('menuitem', { name: 'Assign to…' }).click();
|
||||
const dialog = alice.locator('[data-plugin-dialog="team-inbox"]');
|
||||
await expect(dialog).toBeVisible({ timeout: 30000 });
|
||||
const frame = dialog.frameLocator('iframe').first();
|
||||
// Bob is known by name where the server has metadata or a directory, else
|
||||
// by his id, as the team member messages were assigned to lately.
|
||||
const bobId = await principalId(ORG.bob);
|
||||
const bobRow = frame.getByRole('listitem').filter({ hasText: new RegExp(`org-bob@example\\.org|Id ${bobId}$`) }).first();
|
||||
await expect(bobRow).toBeVisible({ timeout: 30000 });
|
||||
await expect(frame.getByRole('listitem').filter({ hasText: 'You' })).toHaveCount(1);
|
||||
await shot(alice, 'teaminbox-assign-dialog');
|
||||
await bobRow.click();
|
||||
await expect(dialog).toHaveCount(0, { timeout: 20000 });
|
||||
|
||||
await expect.poll(() => keywordsOf(FOR_BOB), { timeout: 20000 }).toContain(`$assigned:${bobId}`);
|
||||
const forBobBadge = emailItem(alice, FOR_BOB).first().locator('[data-plugin-row-badge="team-inbox-assignee"]');
|
||||
await expect(forBobBadge).toBeVisible({ timeout: 20000 });
|
||||
// The badge names bob where the server shares names (metadata on v1.0.0,
|
||||
// or a directory); without either it says the assignee isn't known.
|
||||
if (ORG_SERVER === '1.0.0') {
|
||||
await expect(forBobBadge).toHaveAttribute('title', `Assigned to Org Bob <${ORG.bob}>`);
|
||||
await expect(forBobBadge).toHaveText('Org');
|
||||
} else {
|
||||
await expect(forBobBadge).toHaveAttribute('title', /Assigned to (Org Bob|a team member whose name Bulwark doesn't know)/);
|
||||
}
|
||||
|
||||
// Alice's own mailbox gets no team actions and no filters.
|
||||
await folderRow(alice, { role: 'inbox', shared: false }).first().click();
|
||||
await expect(emailItem(alice, OWN).first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(alice.locator('[data-testid="plugin-list-filters"]')).toHaveCount(0);
|
||||
await rowMenu(alice, OWN);
|
||||
await expect(alice.getByRole('menuitem', { name: 'Assign to me' })).toHaveCount(0);
|
||||
await expect(alice.getByRole('menuitem', { name: 'Mark done' })).toHaveCount(0);
|
||||
await alice.context().close();
|
||||
});
|
||||
|
||||
test('on Stalwart v1.0.0 the names are shared in the team Inbox\'s metadata', async () => {
|
||||
test.skip(ORG_SERVER !== '1.0.0', 'only the v1.0.0 release keeps metadata (facts §2)');
|
||||
const names = await sharedNames() as { people?: Record<string, { n: string; e: string }> } | null;
|
||||
const [aliceId, bobId] = [await principalId(ORG.alice), await principalId(ORG.bob)];
|
||||
expect(names?.people?.[aliceId]?.e).toBe(ORG.alice);
|
||||
expect(names?.people?.[bobId]?.e).toBe(ORG.bob);
|
||||
});
|
||||
|
||||
test('bob finds it under "Assigned to me" and marking it done takes it out of "Open"', async ({ browser }) => {
|
||||
const bob = await userPage(browser, ORG.bob);
|
||||
await pluginReady(bob);
|
||||
await openTeamInbox(bob);
|
||||
await expect(emailItem(bob, OTHER).first()).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await filterChip(bob, 'Assigned to me').click();
|
||||
await expect(filterChip(bob, 'Assigned to me')).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(emailItem(bob, FOR_BOB).first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(emailItem(bob, SELF).first()).toBeVisible();
|
||||
await expect(emailItem(bob, OTHER)).toHaveCount(0);
|
||||
await shot(bob, 'teaminbox-assigned-to-me');
|
||||
|
||||
// Done from the reader toolbar.
|
||||
await emailItem(bob, FOR_BOB).first().click();
|
||||
const markDone = bob.locator('[data-plugin-actions="reader-actions"]').getByRole('button', { name: 'Mark done' });
|
||||
await expect(markDone).toBeVisible({ timeout: 30000 });
|
||||
await markDone.click();
|
||||
await expect(bob.locator('[data-plugin-actions="reader-actions"]').getByRole('button', { name: 'Reopen' })).toBeVisible({ timeout: 20000 });
|
||||
await expect.poll(() => keywordsOf(FOR_BOB), { timeout: 20000 }).toContain('$done');
|
||||
await expect(emailItem(bob, FOR_BOB).first().locator('[data-plugin-row-badge="team-inbox-done"]')).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await filterChip(bob, 'Open').click();
|
||||
await expect(emailItem(bob, FOR_BOB)).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(emailItem(bob, SELF).first()).toBeVisible();
|
||||
await shot(bob, 'teaminbox-open-filter');
|
||||
|
||||
// Off again: the chip's second click removes the filter.
|
||||
await filterChip(bob, 'Open').click();
|
||||
await filterChip(bob, 'Assigned to me').click();
|
||||
await expect(emailItem(bob, OTHER).first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(emailItem(bob, FOR_BOB).first()).toBeVisible();
|
||||
await bob.context().close();
|
||||
});
|
||||
|
||||
test('carol, in no group, gets no team actions or filters', async ({ browser }) => {
|
||||
await createMessage(ORG.carol, ORG.carol, `Team inbox carol ${RUN}`);
|
||||
const carol = await userPage(browser, ORG.carol);
|
||||
await pluginReady(carol);
|
||||
await expect(emailItem(carol, `Team inbox carol ${RUN}`).first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(carol.locator('[data-testid="plugin-list-filters"]')).toHaveCount(0);
|
||||
await rowMenu(carol, `Team inbox carol ${RUN}`);
|
||||
await expect(carol.getByRole('menuitem', { name: 'Assign to me' })).toHaveCount(0);
|
||||
await expect(carol.getByRole('menuitem', { name: 'Assign to…' })).toHaveCount(0);
|
||||
await carol.context().close();
|
||||
});
|
||||
|
||||
test('on a phone the filters fit and the actions are in the message\'s More menu', async ({ browser }) => {
|
||||
const bob = await userPage(browser, ORG.bob, true);
|
||||
await pluginReady(bob);
|
||||
// The folder list is behind the header's menu button on phones.
|
||||
await bob.locator('[data-mobile-mail-header] button').first().click();
|
||||
await openTeamInbox(bob);
|
||||
const filters = bob.locator('[data-testid="plugin-list-filters"]');
|
||||
await expect(filters).toBeVisible({ timeout: 30000 });
|
||||
for (const chip of await filters.getByRole('button').all()) {
|
||||
const box = await chip.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390.5);
|
||||
}
|
||||
expect(await bob.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
await shot(bob, 'teaminbox-phone-filters');
|
||||
|
||||
await emailItem(bob, OTHER).first().click();
|
||||
await bob.getByRole('button', { name: 'More actions' }).last().click();
|
||||
await expect(bob.getByRole('menuitem', { name: 'Assign to me' })).toBeVisible({ timeout: 20000 });
|
||||
await expect(bob.getByRole('menuitem', { name: 'Mark done' })).toBeVisible();
|
||||
await shot(bob, 'teaminbox-phone-more');
|
||||
await bob.context().close();
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
console.log(`team inbox spec ran on ${ORG_SERVER}`);
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
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 { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||||
import { resetConfirmedGroups } from '../plugin-sandbox/account-kind';
|
||||
import { pluginDeclaresNetwork } from '../outgoing-hosts';
|
||||
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(() => {
|
||||
resetConfirmedGroups();
|
||||
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 () => []),
|
||||
getPrincipalTypes: vi.fn(async () => ({ ch: 'group', cx: 'individual' })),
|
||||
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, threadEmailsCache: new Map() });
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
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('says group only for accounts the server confirmed, whatever the policy subject says', async () => {
|
||||
// The policy subject (above) lists the team; the server can't say.
|
||||
client.getPrincipalTypes.mockRejectedValue(new Error('Principal/get failed'));
|
||||
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');
|
||||
expect(accounts.find((a) => a.accountId === 'cx')?.kind).toBe('shared');
|
||||
});
|
||||
|
||||
it('counts a person\'s shared mailbox as shared even when the subject calls it a group', async () => {
|
||||
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org', 'boss@example.org'] } });
|
||||
const accounts = await call(plugin(['account:read']), 'user.getMailAccounts') as Array<{ accountId: string; kind: string }>;
|
||||
expect(accounts.find((a) => a.accountId === 'cx')?.kind).toBe('shared');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the directory for plugins (directory:read)', () => {
|
||||
const finder = () => plugin(['directory:read']);
|
||||
|
||||
it('needs directory:read, which account:read doesn\'t give and isn\'t network access', async () => {
|
||||
await expect(call(plugin(['account:read']), 'user.findPeople', { text: 'team' })).rejects.toThrow(/lacks permission "directory:read"/);
|
||||
// Installed by a user who didn't agree to it yet: refused.
|
||||
const unconsented = { ...finder(), managed: false, grantedPermissions: ['account:read'] };
|
||||
await expect(call(unconsented, 'user.findPeople', { text: 'team' })).rejects.toThrow(/directory:read/);
|
||||
expect(pluginDeclaresNetwork({ permissions: ['email:read', 'email:write', 'account:read', 'directory:read', 'ui:message-list', 'ui:toolbar'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('looks people up by text or id and says when the directory is off', async () => {
|
||||
await expect(call(finder(), 'user.findPeople', { text: 'team' })).resolves.toEqual({
|
||||
people: [{ id: 'ch', type: 'group', name: 'Org Team', email: 'org-team@example.org' }],
|
||||
complete: false,
|
||||
});
|
||||
await expect(call(finder(), '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(finder(), 'user.findPeople', {})).rejects.toThrow(/text or ids/);
|
||||
await expect(call(finder(), 'user.findPeople', { name: 'x' })).rejects.toThrow(/unknown property/);
|
||||
});
|
||||
|
||||
it('wants at least 3 characters of text and at most 20 ids', async () => {
|
||||
await expect(call(finder(), 'user.findPeople', { text: 'or' })).rejects.toThrow(/at least 3 characters/);
|
||||
const ids = Array.from({ length: 21 }, (_, i) => `p${i}`);
|
||||
await expect(call(finder(), 'user.findPeople', { ids })).rejects.toThrow(/1 to 20 ids/);
|
||||
await expect(call(finder(), 'user.findPeople', { ids: ids.slice(0, 20) })).resolves.toMatchObject({ people: [] });
|
||||
});
|
||||
|
||||
it('answers at most 20 people and 10 lookups a minute', async () => {
|
||||
client.listPrincipals.mockResolvedValue({
|
||||
status: 'ok',
|
||||
principals: Array.from({ length: 30 }, (_, i) => ({ id: `p${i}`, type: 'individual', name: `org-p${i}@example.org`, email: `org-p${i}@example.org` })),
|
||||
});
|
||||
const first = await call(finder(), 'user.findPeople', { text: 'org-p' }) as { people: unknown[]; complete: boolean };
|
||||
expect(first.people).toHaveLength(20);
|
||||
expect(first.complete).toBe(true);
|
||||
for (let i = 1; i < 10; i++) await call(finder(), 'user.findPeople', { text: 'org-p' });
|
||||
await expect(call(finder(), 'user.findPeople', { text: 'org-p' })).rejects.toThrow(/too many lookups/);
|
||||
});
|
||||
|
||||
it('asks the directory of the account the call names, and only the login\'s own accounts', async () => {
|
||||
await call(finder(), 'user.findPeople', { text: 'team', accountId: 'ch' });
|
||||
expect(client.listPrincipals).toHaveBeenCalledWith('ch');
|
||||
await expect(call(finder(), 'user.findPeople', { text: 'team', accountId: 'elsewhere' })).rejects.toThrow(/no mail account/);
|
||||
});
|
||||
|
||||
it('keeps one load in flight per client', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const listing = (id: string) => async () => {
|
||||
await gate;
|
||||
return { status: 'ok', principals: [{ id, type: 'individual', name: `${id}@example.org`, email: `${id}@example.org` }] };
|
||||
};
|
||||
client.listPrincipals.mockImplementation(listing('from-first'));
|
||||
const firstA = call(finder(), 'user.findPeople', { text: 'example' });
|
||||
const firstB = call(finder(), 'user.findPeople', { text: 'example' });
|
||||
const other = { ...client, listPrincipals: vi.fn(listing('from-second')) };
|
||||
useAuthStore.setState({ client: other as unknown as IJMAPClient });
|
||||
const second = call(finder(), 'user.findPeople', { text: 'example' });
|
||||
release();
|
||||
const [a, b, c] = await Promise.all([firstA, firstB, second]) as Array<{ people: Array<{ id: string }> }>;
|
||||
expect(client.listPrincipals).toHaveBeenCalledTimes(1);
|
||||
expect(other.listPrincipals).toHaveBeenCalledTimes(1);
|
||||
expect([a.people[0].id, b.people[0].id, c.people[0].id]).toEqual(['from-first', 'from-first', 'from-second']);
|
||||
});
|
||||
});
|
||||
|
||||
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('refuses the keywords that change how the app and the server treat a message', async () => {
|
||||
const writer = plugin(['email:write']);
|
||||
for (const keyword of ['$draft', '$junk', '$NotJunk', '$phishing']) {
|
||||
await expect(call(writer, 'email.updateKeywords', 'm1', { [keyword]: true })).rejects.toThrow(/can't be changed by a plugin/);
|
||||
await expect(call(writer, 'email.setKeyword', 'm1', keyword)).rejects.toThrow(/can't be changed by a plugin/);
|
||||
await expect(call(writer, 'email.removeKeyword', 'm1', keyword)).rejects.toThrow(/can't be changed by a plugin/);
|
||||
}
|
||||
await expect(call(writer, 'email.setKeyword', 'm1', 'a b')).rejects.toThrow(/Invalid JMAP keyword/);
|
||||
expect(client.batchUpdateKeywords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates the message in an expanded conversation too', async () => {
|
||||
const inner = mail('m2', { keywords: { $seen: true }, threadId: 't-m1' });
|
||||
useEmailStore.setState({
|
||||
emails: [mail('m1', { keywords: { $seen: true } })],
|
||||
threadEmailsCache: new Map([['t-m1', [mail('m1'), inner]]]),
|
||||
});
|
||||
await call(plugin(['email:write']), 'email.updateKeywords', 'm2', { $done: true }, 'ch');
|
||||
expect(useEmailStore.getState().threadEmailsCache.get('t-m1')?.[1].keywords).toEqual({ $seen: true, $done: true });
|
||||
expect(useEmailStore.getState().threadEmailsCache.get('t-m1')?.[0].keywords).toEqual({ $seen: 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 4 KB, leaving room for Bulwark\'s own entries', 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(5000))).rejects.toThrow(/at most 4096/);
|
||||
expect(client.setBulwarkMetadata).not.toHaveBeenCalled();
|
||||
await call(plugin(['email:write']), 'accountData.set', 'ch', 'x'.repeat(4000));
|
||||
expect(client.setBulwarkMetadata).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads at most 30 times a minute', async () => {
|
||||
client.supportsBulwarkMetadata.mockReturnValue(true);
|
||||
client.getBulwarkMetadata.mockResolvedValue({});
|
||||
for (let i = 0; i < 30; i++) await call(plugin(['email:read']), 'accountData.get', 'ch');
|
||||
await expect(call(plugin(['email:read']), 'accountData.get', 'ch')).rejects.toThrow(/too many reads/);
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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.
|
||||
//
|
||||
// Plugins only hear "group" for accounts the server confirmed are group
|
||||
// principals (Principal/get type "group"; Stalwart uses the account id as
|
||||
// the principal id and answers for the user's own groups even with
|
||||
// directory queries off). The policy subject also counts every non-personal
|
||||
// account as a group when the server won't say, which is right for the
|
||||
// admin's group scopes but would let a plugin treat a mailbox one person
|
||||
// shared like a team mailbox. So the confirmed list is kept here, apart,
|
||||
// and an account whose type isn't known is "shared".
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
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' | 'getSharedAccounts' | 'getPrincipalTypes'>>;
|
||||
|
||||
interface ConfirmedGroupsState {
|
||||
/** Per client: the ids of its accounts the server confirmed are groups. */
|
||||
groups: WeakMap<object, ReadonlySet<string>>;
|
||||
/** Moves on whenever a client's list arrives, for subscribers. */
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export const useConfirmedGroups = create<ConfirmedGroupsState>()(() => ({ groups: new WeakMap(), revision: 0 }));
|
||||
|
||||
const pending = new WeakMap<object, Promise<ReadonlySet<string>>>();
|
||||
const failedAt = new WeakMap<object, number>();
|
||||
/** A failed lookup is tried again after this long, not on every render. */
|
||||
export const CONFIRM_RETRY_MS = 60_000;
|
||||
|
||||
/** The non-personal accounts of a client's session. */
|
||||
function sharedAccountIds(client: KindClient): string[] {
|
||||
const own = client.getAccountId();
|
||||
const accounts = typeof client.getSharedAccounts === 'function' ? client.getSharedAccounts() : [];
|
||||
return accounts
|
||||
.filter((a) => a.id !== own && client.getAccountInfo?.(a.id)?.isPersonal !== true)
|
||||
.map((a) => a.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server once per client which of its accounts are groups. A
|
||||
* failed or missing lookup confirms none, and is asked again a minute later.
|
||||
*/
|
||||
export function confirmGroups(client: KindClient): Promise<ReadonlySet<string>> {
|
||||
const known = useConfirmedGroups.getState().groups.get(client);
|
||||
if (known) return Promise.resolve(known);
|
||||
const running = pending.get(client);
|
||||
if (running) return running;
|
||||
const failed = failedAt.get(client);
|
||||
if (failed !== undefined && Date.now() - failed < CONFIRM_RETRY_MS) return Promise.resolve(new Set());
|
||||
const ids = sharedAccountIds(client);
|
||||
const lookup = (async () => {
|
||||
let confirmed: ReadonlySet<string> = new Set();
|
||||
let answered = ids.length === 0;
|
||||
if (ids.length > 0 && typeof client.getPrincipalTypes === 'function') {
|
||||
const types = await client.getPrincipalTypes(ids).catch(() => null);
|
||||
if (types) {
|
||||
answered = true;
|
||||
confirmed = new Set(ids.filter((id) => types[id] === 'group'));
|
||||
}
|
||||
}
|
||||
if (!answered) failedAt.set(client, Date.now());
|
||||
if (answered) {
|
||||
useConfirmedGroups.setState((state) => {
|
||||
state.groups.set(client, confirmed);
|
||||
return { groups: state.groups, revision: state.revision + 1 };
|
||||
});
|
||||
}
|
||||
return confirmed;
|
||||
})().finally(() => pending.delete(client));
|
||||
pending.set(client, lookup);
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind of the JMAP account `accountId` reached through `client`. No
|
||||
* account, or the client's own, is personal. A group needs the server's
|
||||
* word; until it is in (this starts the lookup), or when the lookup
|
||||
* failed, the account is shared.
|
||||
*/
|
||||
export function mailAccountKind(client: KindClient | null | undefined, accountId: string | null | undefined): MailAccountKind {
|
||||
if (!client || !accountId || accountId === client.getAccountId()) return 'personal';
|
||||
const info = typeof client.getAccountInfo === 'function' ? client.getAccountInfo(accountId) : null;
|
||||
if (info?.isPersonal) return 'personal';
|
||||
const confirmed = useConfirmedGroups.getState().groups.get(client);
|
||||
if (!confirmed) {
|
||||
void confirmGroups(client);
|
||||
return 'shared';
|
||||
}
|
||||
return confirmed.has(accountId) ? 'group' : 'shared';
|
||||
}
|
||||
|
||||
/** For tests: forget every confirmed list. */
|
||||
export function resetConfirmedGroups(): void {
|
||||
useConfirmedGroups.setState({ groups: new WeakMap(), revision: 0 });
|
||||
}
|
||||
@@ -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;
|
||||
/**
|
||||
@@ -47,9 +62,12 @@ export interface RegisteredAction extends PluginAction {
|
||||
|
||||
export const MAX_ACTIONS_PER_SURFACE = 8;
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
|
||||
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
|
||||
/** A host icon name, the one rule for plugin actions, list filters and row badges. */
|
||||
export const PLUGIN_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 {
|
||||
@@ -70,7 +88,7 @@ export function checkActions(value: unknown): PluginAction[] {
|
||||
if (typeof a.label !== 'string' || !a.label.trim() || a.label.length > 60) throw new Error(`actions[${i}].label is required (max 60 characters)`);
|
||||
const out: PluginAction = { id: a.id, label: a.label.trim() };
|
||||
if (a.icon !== undefined) {
|
||||
if (typeof a.icon !== 'string' || !ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`);
|
||||
if (typeof a.icon !== 'string' || !PLUGIN_ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`);
|
||||
out.icon = a.icon;
|
||||
}
|
||||
if (a.order !== undefined) {
|
||||
@@ -89,6 +107,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 +195,39 @@ export function badgeText(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
/** Whether an action applies to a folder role and/or a MIME type. */
|
||||
export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean {
|
||||
/**
|
||||
* What an action's conditions are checked against. A missing folder role or
|
||||
* MIME type isn't checked; a missing account kind or keyword list hides the
|
||||
* actions that name one, since the surface is not about one message.
|
||||
*/
|
||||
export interface ActionFilter {
|
||||
mailboxRole?: string | null;
|
||||
mimeType?: string | null;
|
||||
/** The account kind of the message (or of the open folder, for the selection toolbar). */
|
||||
accountKind?: MailAccountKind | null;
|
||||
/** The message's keywords. */
|
||||
keywords?: readonly string[] | null;
|
||||
}
|
||||
|
||||
/** Whether `keywords` holds `pattern` (exact, or a prefix ending in `*`), case-insensitively. */
|
||||
export function keywordMatches(keywords: readonly string[], pattern: string): boolean {
|
||||
const p = pattern.toLowerCase();
|
||||
if (p.endsWith('*')) {
|
||||
const prefix = p.slice(0, -1);
|
||||
return keywords.some((k) => k.toLowerCase().startsWith(prefix));
|
||||
}
|
||||
return keywords.some((k) => k.toLowerCase() === p);
|
||||
}
|
||||
|
||||
/** Whether an action applies to a folder role, a MIME type, a message's account and keywords. */
|
||||
export function actionApplies(action: PluginAction, ctx: ActionFilter): boolean {
|
||||
if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false;
|
||||
if (action.accounts && (!ctx.accountKind || !action.accounts.includes(ctx.accountKind))) return false;
|
||||
if (action.hasKeyword || action.notKeyword) {
|
||||
if (!ctx.keywords) return false;
|
||||
if (action.hasKeyword && !keywordMatches(ctx.keywords, action.hasKeyword)) return false;
|
||||
if (action.notKeyword && keywordMatches(ctx.keywords, action.notKeyword)) return false;
|
||||
}
|
||||
if (action.mimeTypes && ctx.mimeType !== undefined) {
|
||||
const type = (ctx.mimeType ?? '').toLowerCase();
|
||||
if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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, MetadataTooLargeError, metadataValueBytes } from '../jmap/bulwark-metadata';
|
||||
import { confirmGroups, 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,
|
||||
};
|
||||
}
|
||||
|
||||
function mailAccountIds(client: IJMAPClient): string[] {
|
||||
const own = client.getAccountId();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mail accounts of the signed-in login, each with its kind. Waits for
|
||||
* the server's word on which are groups (account-kind.ts), so a plugin
|
||||
* asking right after sign-in already hears "group" for a team mailbox.
|
||||
*/
|
||||
export async function doUserGetMailAccounts(): Promise<PluginMailAccountView[]> {
|
||||
const client = sessionClient('user.getMailAccounts');
|
||||
await confirmGroups(client);
|
||||
const own = client.getAccountId();
|
||||
const open = resolveEmailBlobContext({}, client);
|
||||
const openId = open.client === client ? open.accountId || own : null;
|
||||
return mailAccountIds(client).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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** `accountArg` as one of the login's mail accounts, or the user's own when not given. */
|
||||
function namedAccount(client: IJMAPClient, method: string, accountArg: unknown): string {
|
||||
if (accountArg === undefined) return client.getAccountId();
|
||||
const accountId = readString(accountArg, method, 'accountId', 256, { min: 1 });
|
||||
if (!mailAccountIds(client).includes(accountId)) throw new Error(`${method}: no mail account "${accountId}"`);
|
||||
return accountId;
|
||||
}
|
||||
|
||||
// ─── Directory (directory:read) ───────────────────────────────
|
||||
|
||||
/** Most people one user.findPeople call returns. */
|
||||
export const MAX_PLUGIN_PEOPLE = 20;
|
||||
/** Most ids one call may ask for. */
|
||||
export const MAX_PLUGIN_PEOPLE_IDS = 20;
|
||||
/** The shortest search text: one or two letters would page through the directory. */
|
||||
export const MIN_PLUGIN_PEOPLE_TEXT = 3;
|
||||
/** Lookups one plugin may make a minute. */
|
||||
export const PLUGIN_PEOPLE_PER_MINUTE = 10;
|
||||
const DIRECTORY_TTL_MS = 5 * 60_000;
|
||||
|
||||
interface DirectoryCache {
|
||||
at: number;
|
||||
complete: boolean;
|
||||
people: PluginPersonView[];
|
||||
}
|
||||
|
||||
/** Per client, per account: the listing and the load in flight. */
|
||||
const directories = new WeakMap<object, Map<string, DirectoryCache>>();
|
||||
const directoryLoads = new WeakMap<object, Map<string, Promise<DirectoryCache>>>();
|
||||
|
||||
async function loadDirectory(client: IJMAPClient, accountId: string): Promise<DirectoryCache> {
|
||||
const cached = directories.get(client)?.get(accountId);
|
||||
if (cached && Date.now() - cached.at < DIRECTORY_TTL_MS) return cached;
|
||||
let loads = directoryLoads.get(client);
|
||||
if (!loads) {
|
||||
loads = new Map();
|
||||
directoryLoads.set(client, loads);
|
||||
}
|
||||
const running = loads.get(accountId);
|
||||
if (running) return running;
|
||||
const inFlight = loads;
|
||||
const load = (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(accountId);
|
||||
complete = listing.status === 'ok';
|
||||
principals = listing.principals;
|
||||
} else {
|
||||
principals = await client.getPrincipals(accountId);
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
const entry: DirectoryCache = {
|
||||
at: Date.now(),
|
||||
complete,
|
||||
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(),
|
||||
})),
|
||||
};
|
||||
let byAccount = directories.get(client);
|
||||
if (!byAccount) {
|
||||
byAccount = new Map();
|
||||
directories.set(client, byAccount);
|
||||
}
|
||||
byAccount.set(accountId, entry);
|
||||
return entry;
|
||||
})().finally(() => { inFlight.delete(accountId); });
|
||||
inFlight.set(accountId, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
/**
|
||||
* People from the server's directory, as the account named (`accountId`,
|
||||
* else the user's own) sees it: by principal id (`ids`, at most 20), or
|
||||
* whose name or address contains `text` (3 characters or more). At most 20
|
||||
* results and 10 lookups a minute. `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', 'accountId']);
|
||||
const client = sessionClient(method);
|
||||
let ids: Set<string> | null = null;
|
||||
if (opts.ids !== undefined) {
|
||||
if (!Array.isArray(opts.ids) || opts.ids.length === 0 || opts.ids.length > MAX_PLUGIN_PEOPLE_IDS
|
||||
|| !opts.ids.every((id) => typeof id === 'string' && id.length > 0 && id.length <= 256)) {
|
||||
throw new Error(`${method}: ids must be a list of 1 to ${MAX_PLUGIN_PEOPLE_IDS} 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`);
|
||||
if (text && text.length < MIN_PLUGIN_PEOPLE_TEXT) throw new Error(`${method}: text needs at least ${MIN_PLUGIN_PEOPLE_TEXT} characters`);
|
||||
const accountId = namedAccount(client, method, opts.accountId);
|
||||
if (!withinRate('directory', plugin.id, PLUGIN_PEOPLE_PER_MINUTE)) throw new Error(`${method}: too many lookups, try again in a minute`);
|
||||
const { people, complete } = await loadDirectory(client, accountId);
|
||||
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 };
|
||||
}
|
||||
|
||||
// ─── Shared data in a mail account ────────────────────────────
|
||||
|
||||
/**
|
||||
* Largest value a plugin keeps in one account: half of the 8 KB Stalwart
|
||||
* takes per entry. Every entry counts against the mailbox's 64 KB, which
|
||||
* Bulwark's own shared tags and team templates need too.
|
||||
*/
|
||||
export const MAX_PLUGIN_DATA_BYTES = 4096;
|
||||
/** Reads and changes one plugin may make a minute. */
|
||||
export const PLUGIN_DATA_READS_PER_MINUTE = 30;
|
||||
export const PLUGIN_DATA_WRITES_PER_MINUTE = 20;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = sessionClient(method);
|
||||
const accountId = namedAccount(client, method, accountArg);
|
||||
const key = pluginDataKey(plugin.id);
|
||||
if (!key || typeof client.getBulwarkMetadata !== 'function' || !client.supportsBulwarkMetadata?.(accountId)) {
|
||||
return { supported: false, value: null };
|
||||
}
|
||||
if (!withinRate('account-data-read', plugin.id, PLUGIN_DATA_READS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
|
||||
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 = sessionClient(method);
|
||||
const accountId = namedAccount(client, 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_PLUGIN_DATA_BYTES) {
|
||||
throw new Error(`${method}: the value is ${size} bytes; at most ${MAX_PLUGIN_DATA_BYTES} fit`);
|
||||
}
|
||||
if (!withinRate('account-data', plugin.id, PLUGIN_DATA_WRITES_PER_MINUTE)) throw new Error(`${method}: too many changes, try again in a minute`);
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, key, value);
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataTooLargeError) throw new Error(`${method}: the server has no room for the value (${error.message})`);
|
||||
throw new Error(`${method}: ${error instanceof Error ? error.message : 'could not save'}`);
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,15 @@ import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch,
|
||||
import { checkActions, isActionSurface, setPluginActions } from './actions';
|
||||
import { checkDayLabels, setDayLabels } from './day-labels';
|
||||
import { refreshRowBadges } from './row-badges';
|
||||
import { checkListFilters, setPluginListFilters } from './list-filters';
|
||||
import { checkPages, setPluginPages } from './pages';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { claimLegacyPluginStorage, pluginStoragePrefix } from './storage-scope';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
|
||||
import { keywordPointer } from '../jmap/patch-pointer';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import {
|
||||
@@ -34,12 +36,12 @@ 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';
|
||||
import { generateUUID } from '../utils';
|
||||
import { AddressBook, ContactCard, Identity } from '../jmap/types';
|
||||
import { AddressBook, ContactCard, Email, Identity } from '../jmap/types';
|
||||
import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecurityStore } from '@/stores/account-security-store';
|
||||
import {
|
||||
doCalendarCreateEvent, doCalendarDeleteEvent, doCalendarGetEvent, doCalendarGetEvents,
|
||||
@@ -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,15 @@ 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, and which mail
|
||||
// accounts are team (group) mailboxes.
|
||||
'user.getSelf': 'account:read',
|
||||
'user.getMailAccounts': 'account:read',
|
||||
// People from the server's directory: names and addresses of others.
|
||||
'user.findPeople': 'directory: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 +194,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 +234,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',
|
||||
@@ -1310,6 +1324,22 @@ function assertPluginKeyword(keyword: unknown): string {
|
||||
return keyword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keywords that change what the app and the server do with a message, not
|
||||
* just how it is labelled: a draft opens in the composer, $junk and
|
||||
* $notjunk train the spam filter, $phishing hides the content. The
|
||||
* email.* keyword methods leave them to the app's own actions.
|
||||
*/
|
||||
const PROTECTED_KEYWORDS = new Set(['$draft', '$junk', '$notjunk', '$phishing']);
|
||||
|
||||
function assertMailKeyword(keyword: unknown): string {
|
||||
const checked = assertPluginKeyword(keyword);
|
||||
if (PROTECTED_KEYWORDS.has(checked.toLowerCase())) {
|
||||
throw new Error(`JMAP keyword "${checked}" can't be changed by a plugin`);
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
|
||||
function assertPluginKeywords(value: unknown): Record<string, true> {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error('jmap.setKeywords: keywords must be an object');
|
||||
@@ -1350,6 +1380,79 @@ 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) {
|
||||
assertMailKeyword(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 isTarget = (email: 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;
|
||||
};
|
||||
const row = [...state.emails, ...(state.selectedEmail ? [state.selectedEmail] : [])].find(isTarget);
|
||||
let done = true;
|
||||
if (row) {
|
||||
done = await state.patchEmailKeywords(client, emailId, changes, emailKeyFor(row));
|
||||
} else {
|
||||
const patch = Object.fromEntries(Object.entries(changes).map(([keyword, value]) => [keywordPointer(keyword), value ? true : null]));
|
||||
await client.batchUpdateKeywords([emailId], patch, wanted);
|
||||
}
|
||||
if (done) patchThreadCache(isTarget, changes);
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same change on the messages of expanded conversations
|
||||
* (threadEmailsCache), whose inner rows patchEmailKeywords doesn't touch.
|
||||
*/
|
||||
function patchThreadCache(isTarget: (email: Email) => boolean, changes: Record<string, boolean>): void {
|
||||
useEmailStore.setState((state) => {
|
||||
let cache: Map<string, Email[]> | null = null;
|
||||
for (const [key, members] of state.threadEmailsCache) {
|
||||
if (!members.some(isTarget)) continue;
|
||||
cache ??= new Map(state.threadEmailsCache);
|
||||
cache.set(key, members.map((email) => {
|
||||
if (!isTarget(email)) return email;
|
||||
const keywords = { ...email.keywords };
|
||||
for (const [keyword, on] of Object.entries(changes)) {
|
||||
if (on) keywords[keyword] = true;
|
||||
else delete keywords[keyword];
|
||||
}
|
||||
return { ...email, keywords };
|
||||
}));
|
||||
}
|
||||
return cache ? { threadEmailsCache: cache } : {};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Native keyword definitions ──────────────────────────────
|
||||
|
||||
const MAX_PLUGIN_KEYWORD_DEFINITIONS = 500;
|
||||
@@ -1894,6 +1997,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 +2085,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();
|
||||
@@ -2092,14 +2201,15 @@ export async function dispatchApiCall(
|
||||
|
||||
case 'email.setKeyword': {
|
||||
const emailId = assertEmailId(args[0], 'email.setKeyword');
|
||||
const keyword = assertPluginKeyword(args[1]);
|
||||
const keyword = assertMailKeyword(args[1]);
|
||||
const accountId = assertOptionalAccountId(args[2], 'email.setKeyword');
|
||||
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]);
|
||||
const keyword = assertMailKeyword(args[1]);
|
||||
const accountId = assertOptionalAccountId(args[2], 'email.removeKeyword');
|
||||
await requireClient().removeKeyword(emailId, keyword, accountId);
|
||||
return undefined;
|
||||
|
||||
@@ -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';
|
||||
import { PLUGIN_ICON_RE } from './actions';
|
||||
|
||||
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 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' || !PLUGIN_ICON_RE.test(f.icon)) throw new Error(`filters[${i}].icon is invalid`);
|
||||
out.icon = f.icon;
|
||||
}
|
||||
if (f.accounts !== undefined) {
|
||||
if (!Array.isArray(f.accounts) || f.accounts.length === 0 || !f.accounts.every((k) => (MAIL_ACCOUNT_KINDS as readonly unknown[]).includes(k))) {
|
||||
throw new Error(`filters[${i}].accounts must be a list of "personal", "group" and "shared"`);
|
||||
}
|
||||
out.accounts = [...new Set(f.accounts as MailAccountKind[])];
|
||||
}
|
||||
if (f.mailboxRoles !== undefined) {
|
||||
if (!Array.isArray(f.mailboxRoles) || f.mailboxRoles.length > 10 || !f.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) {
|
||||
throw new Error(`filters[${i}].mailboxRoles must be a list of folder roles`);
|
||||
}
|
||||
out.mailboxRoles = [...f.mailboxRoles] as string[];
|
||||
}
|
||||
if (f.order !== undefined) {
|
||||
if (typeof f.order !== 'number' || !Number.isFinite(f.order)) throw new Error(`filters[${i}].order must be a number`);
|
||||
out.order = f.order;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────
|
||||
|
||||
const registry = new Map<string, RegisteredListFilter[]>();
|
||||
const active = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: RegisteredListFilter[] | null = null;
|
||||
let activeSnapshot: ReadonlySet<string> = new Set();
|
||||
/** Moves on whenever the set of applied filters changes, so the list loads again. */
|
||||
let revision = 0;
|
||||
|
||||
function changed(activeChanged: boolean): void {
|
||||
snapshot = null;
|
||||
if (activeChanged) {
|
||||
activeSnapshot = new Set(active);
|
||||
revision += 1;
|
||||
}
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeListFilters(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
export function setPluginListFilters(pluginId: string, pluginName: string, filters: PluginListFilter[]): void {
|
||||
const previous = registry.get(pluginId) ?? [];
|
||||
if (filters.length === 0) registry.delete(pluginId);
|
||||
else registry.set(pluginId, filters.map((f) => ({ ...f, pluginId, pluginName, key: `${pluginId}:${f.id}` })));
|
||||
// A filter that is on and changed its condition or went away loads the list again.
|
||||
let activeChanged = false;
|
||||
for (const key of [...active]) {
|
||||
if (!key.startsWith(`${pluginId}:`)) continue;
|
||||
const before = previous.find((f) => f.key === key);
|
||||
const after = registry.get(pluginId)?.find((f) => f.key === key);
|
||||
if (!after) {
|
||||
active.delete(key);
|
||||
activeChanged = true;
|
||||
} else if (!before || before.hasKeyword !== after.hasKeyword || before.notKeyword !== after.notKeyword) {
|
||||
activeChanged = true;
|
||||
}
|
||||
}
|
||||
changed(activeChanged);
|
||||
}
|
||||
|
||||
/** Drop everything a plugin offered (it was turned off or removed). */
|
||||
export function clearPluginListFilters(pluginId: string): void {
|
||||
if (!registry.delete(pluginId)) return;
|
||||
let activeChanged = false;
|
||||
for (const key of [...active]) {
|
||||
if (key.startsWith(`${pluginId}:`)) {
|
||||
active.delete(key);
|
||||
activeChanged = true;
|
||||
}
|
||||
}
|
||||
changed(activeChanged);
|
||||
}
|
||||
|
||||
function snapshotFilters(): RegisteredListFilter[] {
|
||||
if (snapshot) return snapshot;
|
||||
const out: RegisteredListFilter[] = [];
|
||||
for (const filters of registry.values()) out.push(...filters);
|
||||
out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
|
||||
snapshot = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every offered filter, in order; the same array until something changes. */
|
||||
export function listFilters(): RegisteredListFilter[] {
|
||||
return snapshotFilters();
|
||||
}
|
||||
|
||||
/** The keys of the filters that are on; the same set until one is turned on or off. */
|
||||
export function activeListFilterKeys(): ReadonlySet<string> {
|
||||
return activeSnapshot;
|
||||
}
|
||||
|
||||
export function listFilterRevision(): number {
|
||||
return revision;
|
||||
}
|
||||
|
||||
/** Turn a filter on or off (toggle without `on`). */
|
||||
export function setListFilterActive(key: string, on?: boolean): void {
|
||||
if (!snapshotFilters().some((f) => f.key === key)) return;
|
||||
const next = on ?? !active.has(key);
|
||||
if (next === active.has(key)) return;
|
||||
if (next) active.add(key);
|
||||
else active.delete(key);
|
||||
changed(true);
|
||||
}
|
||||
|
||||
/** The folder a filter is checked against. */
|
||||
export interface ListFilterFolder {
|
||||
role?: string | null;
|
||||
accountKind: MailAccountKind | null;
|
||||
}
|
||||
|
||||
export function listFilterApplies(filter: PluginListFilter, folder: ListFilterFolder): boolean {
|
||||
if (filter.mailboxRoles && !filter.mailboxRoles.includes(folder.role ?? '')) return false;
|
||||
if (filter.accounts && (!folder.accountKind || !filter.accounts.includes(folder.accountKind))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The JMAP condition of the filters that are on and apply to `folder`, or null. */
|
||||
export function activeListFilterCondition(folder: ListFilterFolder): Record<string, unknown> | null {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
for (const filter of snapshotFilters()) {
|
||||
if (!active.has(filter.key) || !listFilterApplies(filter, folder)) continue;
|
||||
if (filter.hasKeyword) conditions.push({ hasKeyword: filter.hasKeyword });
|
||||
if (filter.notKeyword) conditions.push({ notKeyword: filter.notKeyword });
|
||||
}
|
||||
if (conditions.length === 0) return null;
|
||||
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
/** The keywords the filters that are on and apply to `folder` test, lowercase. */
|
||||
export function activeListFilterKeywords(folder: ListFilterFolder): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const filter of snapshotFilters()) {
|
||||
if (!active.has(filter.key) || !listFilterApplies(filter, folder)) continue;
|
||||
if (filter.hasKeyword) out.add(filter.hasKeyword);
|
||||
if (filter.notKeyword) out.add(filter.notKeyword);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetListFilters(): void {
|
||||
registry.clear();
|
||||
active.clear();
|
||||
changed(true);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { registerShortcuts } from './shortcuts';
|
||||
import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions';
|
||||
import { clearPluginActions, setActionDispatcher } from './actions';
|
||||
import { clearDayLabels } from './day-labels';
|
||||
import { clearPluginListFilters } from './list-filters';
|
||||
import { clearPluginPages } from './pages';
|
||||
import { clearSectionTitle } from './section-titles';
|
||||
import { cancelPluginProxyRequests } from './proxy-client';
|
||||
@@ -376,9 +377,10 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
// Drop any message-list category tabs the plugin registered so the strip
|
||||
// disappears (and the inbox unfilters) the moment the plugin is disabled.
|
||||
try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ }
|
||||
// Same for the buttons, day labels and pages it declared, and anything
|
||||
// still running through the proxy for it.
|
||||
// Same for the buttons, list filters, day labels and pages it declared,
|
||||
// and anything still running through the proxy for it.
|
||||
clearPluginActions(pluginId);
|
||||
clearPluginListFilters(pluginId);
|
||||
clearDayLabels(pluginId);
|
||||
clearPluginPages(pluginId);
|
||||
clearSectionTitle(pluginId);
|
||||
|
||||
@@ -427,6 +427,32 @@ 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, as the named mail account (else
|
||||
* the user's own) sees it: by principal id (at most 20) or by a part
|
||||
* of their name or address (3 characters or more). At most 20 results
|
||||
* and 10 lookups a minute. `complete` is false when the administrator
|
||||
* turned directory lookups off; only the people the user shares an
|
||||
* account with are known then. Needs directory:read.
|
||||
*/
|
||||
findPeople: (query: { text?: string; ids?: string[]; accountId?: 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 +640,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 +750,26 @@ 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 4 KB) the plugin keeps in a mail account's
|
||||
// Inbox, shared with everyone who can open that account (Stalwart 1.0
|
||||
// metadata; it counts against the mailbox's metadata room). get needs
|
||||
// email:read, set email:write; 30 reads and 20 changes a minute.
|
||||
accountData: {
|
||||
/** `supported` is false on servers without metadata; `value` is then null. */
|
||||
get: (accountId?: string) =>
|
||||
callApi('accountData.get', [accountId], NETWORK_API_TIMEOUT_MS) as Promise<{ supported: boolean; value: unknown }>,
|
||||
/** Replace the value; null removes it. Throws where `get` says unsupported. */
|
||||
set: (accountId: string | undefined, value: unknown) =>
|
||||
callApi('accountData.set', [accountId, value], NETWORK_API_TIMEOUT_MS) as Promise<void>,
|
||||
},
|
||||
// The composer (permission: compose:open). Opens it filled in; the user
|
||||
// reviews the message and decides whether to send it.
|
||||
|
||||
@@ -58,7 +58,7 @@ export const SLOT_CATALOG = {
|
||||
// ─── Message list ──────────────────────────────────────────
|
||||
'message-list-header': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null }, 'above the message list'),
|
||||
'message-list-empty': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null, searching: null }, 'in an empty message list, below the "no messages" text'),
|
||||
'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read' }, 'hover buttons on a message row, and the row menu on phones', true),
|
||||
'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read', accountId: 'email:read' }, 'hover buttons on a message row, and its right-click menu (the row menu on phones); the click\'s `accountId` names the message\'s account when it is not the user\'s own (a team or shared mailbox)', true),
|
||||
'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true),
|
||||
'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'),
|
||||
'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true),
|
||||
@@ -71,7 +71,7 @@ export const SLOT_CATALOG = {
|
||||
'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'),
|
||||
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
|
||||
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read', deviceType: null }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
|
||||
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
|
||||
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read', accountId: 'email:read' }, 'buttons in the open message\'s toolbar (its More menu on phones); `accountId` as on message-row-actions', true),
|
||||
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read', focusRequest: null }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text }); `focusRequest` counts up when the plugin opened the panel with api.ui.openPanel'),
|
||||
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
|
||||
'attachment-actions': S('reader', 'frame', ['email:read'], { attachment: null }, 'buttons on each attachment', true),
|
||||
|
||||
@@ -908,6 +908,8 @@ export interface EmailListBadge {
|
||||
color?: string;
|
||||
/** Tooltip / aria-label */
|
||||
title?: string;
|
||||
/** Name of a host icon (see components/icons) shown before the label, e.g. "check" or "user". */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// ─── Message-list category tabs (Gmail-style inbox tabs) ─────
|
||||
@@ -1350,6 +1352,8 @@ export const ALL_PERMISSIONS = [
|
||||
'auth:observe',
|
||||
'auth:emit',
|
||||
'account:read',
|
||||
// Look people up in the server's directory (api.user.findPeople).
|
||||
'directory:read',
|
||||
'http:post', 'http:fetch',
|
||||
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', 'ui:contact-cryptokeys',
|
||||
'ui:email-details',
|
||||
|
||||
+10
-2
@@ -4379,7 +4379,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Ihre E-Mails ändern und ordnen",
|
||||
"body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen, Nachrichten zwischen Ihren Ordnern und ins Archiv verschieben und Entwürfe speichern."
|
||||
"body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen, Nachrichten zwischen Ihren Ordnern und ins Archiv verschieben und Entwürfe speichern. Außerdem kann es einen kleinen Wert speichern, den jedes Mitglied eines Team-Postfachs lesen kann."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "E-Mails vor dem Senden ändern",
|
||||
@@ -4495,7 +4495,11 @@
|
||||
},
|
||||
"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 und erkennen, welche Postfächer Team-Postfächer sind."
|
||||
},
|
||||
"directory_read": {
|
||||
"title": "Personen nachschlagen",
|
||||
"body": "Personen im Verzeichnis Ihrer Organisation nachschlagen."
|
||||
},
|
||||
"http_post": {
|
||||
"title": "Den Webmail-Server für Sie nutzen",
|
||||
@@ -4659,6 +4663,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...",
|
||||
|
||||
+10
-2
@@ -4379,7 +4379,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Change and organise your email",
|
||||
"body": "Add or remove tags, flags and other keywords on your messages, move them between your folders and to the archive, and save drafts."
|
||||
"body": "Add or remove tags, flags and other keywords on your messages, move them between your folders and to the archive, and save drafts. It can also store a small value that every member of a team mailbox can read."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Change mail before it is sent",
|
||||
@@ -4495,7 +4495,11 @@
|
||||
},
|
||||
"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, and which mail accounts are team mailboxes."
|
||||
},
|
||||
"directory_read": {
|
||||
"title": "Look up people",
|
||||
"body": "Look up people in your organisation's directory."
|
||||
},
|
||||
"http_post": {
|
||||
"title": "Use the webmail server for you",
|
||||
@@ -4659,6 +4663,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,141 @@
|
||||
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 { confirmGroups, resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind';
|
||||
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;
|
||||
}
|
||||
|
||||
// Plugins hear "group" once the server confirmed the account is one.
|
||||
async function signIn(c: IJMAPClient) {
|
||||
useAuthStore.setState({ client: c });
|
||||
await confirmGroups(c);
|
||||
}
|
||||
|
||||
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),
|
||||
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' }),
|
||||
batchUpdateKeywords: vi.fn(async () => undefined),
|
||||
} as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetListFilters();
|
||||
resetConfirmedGroups();
|
||||
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();
|
||||
await 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();
|
||||
await 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')]);
|
||||
await 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' });
|
||||
});
|
||||
|
||||
it('filters a refresh and the next page the same way', async () => {
|
||||
const c = client([email('m1')]);
|
||||
await signIn(c);
|
||||
setListFilterActive('team-inbox:open');
|
||||
await useEmailStore.getState().fetchEmails(c, 'ch:a');
|
||||
const before = vi.mocked(c.getEmails).mock.calls.length;
|
||||
await useEmailStore.getState().refreshCurrentMailbox(c);
|
||||
expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(before);
|
||||
expect(vi.mocked(c.getEmails).mock.calls.at(-1)?.[6]).toEqual({ notKeyword: '$done' });
|
||||
|
||||
useEmailStore.setState({ hasMoreEmails: true, isLoadingMore: false });
|
||||
const beforeMore = vi.mocked(c.getEmails).mock.calls.length;
|
||||
await useEmailStore.getState().loadMoreEmails(c);
|
||||
expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(beforeMore);
|
||||
expect(vi.mocked(c.getEmails).mock.calls.at(-1)?.[6]).toEqual({ notKeyword: '$done' });
|
||||
});
|
||||
|
||||
it('leaves a tag view alone', async () => {
|
||||
const c = client();
|
||||
await signIn(c);
|
||||
setListFilterActive('team-inbox:open');
|
||||
useEmailStore.setState({ selectedKeyword: 'work' });
|
||||
await useEmailStore.getState().fetchEmails(c, 'ch:a');
|
||||
expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(0);
|
||||
for (const args of vi.mocked(c.getEmails).mock.calls) expect(JSON.stringify(args[6] ?? null)).not.toContain('$done');
|
||||
});
|
||||
|
||||
it('applies nothing to a team folder before the server confirmed it is a group', async () => {
|
||||
const c = { ...client(), getPrincipalTypes: async () => null } as unknown as IJMAPClient;
|
||||
await signIn(c);
|
||||
setListFilterActive('team-inbox:open');
|
||||
await useEmailStore.getState().fetchEmails(c, 'ch:a');
|
||||
expect(vi.mocked(c.getEmails).mock.calls[0][6]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+32
-6
@@ -30,6 +30,8 @@ import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderL
|
||||
import { keywordPointer } from "@/lib/jmap/patch-pointer";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
||||
import { activeListFilterCondition, activeListFilterKeywords, listFilters, type ListFilterFolder } from "@/lib/plugin-sandbox/list-filters";
|
||||
import { mailAccountKind } from "@/lib/plugin-sandbox/account-kind";
|
||||
import { SendCancelledCleanupError } from "@/lib/send-failure-message";
|
||||
import type { StorageQuota } from "@/lib/quota";
|
||||
import { isArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
@@ -797,22 +799,43 @@ function unreadFirstFor(
|
||||
}
|
||||
|
||||
/**
|
||||
* What a folder's query ANDs in: the open category tab's filter, and read
|
||||
* mail only in the "Unread first" inbox, whose unread mail is the section's.
|
||||
* The keyword filters plugins offer for the list (api.ui.setListFilters)
|
||||
* that the user turned on and that apply to this folder.
|
||||
*/
|
||||
function pluginListFilter(mailbox: Mailbox | undefined): Record<string, unknown> | null {
|
||||
if (!mailbox || listFilters().length === 0) return null;
|
||||
return activeListFilterCondition(pluginFilterFolder(mailbox));
|
||||
}
|
||||
|
||||
/** A folder as the plugins' list filters see it: its role and its account's kind. */
|
||||
function pluginFilterFolder(mailbox: Mailbox): ListFilterFolder {
|
||||
const auth = useAuthStore.getState();
|
||||
const viewing = useEmailStore.getState().viewingAccountId;
|
||||
const client = viewing ? auth.getClientForAccount(viewing) : auth.client;
|
||||
return {
|
||||
role: mailbox.role ?? null,
|
||||
accountKind: client ? mailAccountKind(client, mailbox.isShared ? mailbox.accountId : undefined) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a folder's query ANDs in: the open category tab's filter, the
|
||||
* plugins' list filters the user turned on, and read mail only in the
|
||||
* "Unread first" inbox, whose unread mail is the section's.
|
||||
*/
|
||||
function folderListFilter(
|
||||
mailbox: Mailbox | undefined,
|
||||
s: Pick<EmailStore, 'selectedKeyword' | 'searchQuery' | 'searchFilters' | 'isUnifiedView'>,
|
||||
): Record<string, unknown> | null {
|
||||
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
||||
return andFilters(categoryFilter, unreadFirstFor(mailbox, s) ? READ_FILTER : null);
|
||||
return andFilters(andFilters(categoryFilter, pluginListFilter(mailbox)), unreadFirstFor(mailbox, s) ? READ_FILTER : null);
|
||||
}
|
||||
|
||||
/** The newest unread mail of the folder, for the "Unread first" inbox. */
|
||||
async function loadUnreadSection(client: IJMAPClient, mailbox: Mailbox, get: StoreGet): Promise<{ emails: Email[]; total: number }> {
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox.role);
|
||||
const categoryFilter = andFilters(useMessageListTabsStore.getState().getCategoryFilter(mailbox.role), pluginListFilter(mailbox));
|
||||
const result = await effectiveClient.getEmails(
|
||||
mailbox.originalId || mailbox.id,
|
||||
accountId,
|
||||
@@ -847,13 +870,16 @@ function applyUnreadSection(get: StoreGet, set: StoreSet, mailboxId: string, loa
|
||||
* the changed keywords (#718). The change moves the row at once, possibly past
|
||||
* the end of what is loaded, but nothing moves the rows from beyond it up -
|
||||
* reading the top unread mail over and over ran out of unread mail after the
|
||||
* first page while more waited on the server.
|
||||
* first page while more waited on the server. The same for a keyword a
|
||||
* plugin's list filter tests: a message marked done leaves an "Open" list.
|
||||
*/
|
||||
function refillAfterKeywordChange(get: StoreGet, client: IJMAPClient, changed: Iterable<string>): void {
|
||||
const s = get();
|
||||
if (s.isUnifiedView || s.isScheduledView || s.selectedKeyword || s.searchQuery || !isFilterEmpty(s.searchFilters)) return;
|
||||
const sortKeywords = orderKeywords(s.listOrder);
|
||||
if (![...changed].some((keyword) => sortKeywords.includes(keyword))) return;
|
||||
const mailbox = listFilters().length > 0 ? resolveActionMailboxes().find((mb) => mb.id === s.selectedMailbox) : undefined;
|
||||
const filtered = mailbox ? activeListFilterKeywords(pluginFilterFolder(mailbox)) : null;
|
||||
if (![...changed].some((keyword) => sortKeywords.includes(keyword) || !!filtered?.has(keyword.toLowerCase()))) return;
|
||||
void s.refreshCurrentMailbox(client);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user