Merge branch 'feat/org-tags' into feat/org-features

# Conflicts:
#	lib/admin/restriction-keys.ts
#	lib/jmap/client.ts
This commit is contained in:
Linus Rath
2026-10-28 09:15:17 +01:00
37 changed files with 2934 additions and 50 deletions
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
// Organisation > Sharing > Shared tags: one sentence and the lock for the
// `sharedTags` restriction, written at the scope chosen in the scope bar.
const puts: Array<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
puts.push(body);
stored = body;
return new Response('{"ok":true}');
}
return new Response(JSON.stringify(stored));
}),
withBasePath: (p: string) => p,
getPathPrefix: () => '',
}));
import { SharedTagsSection } from '../sections/shared-tags-section';
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
const POLICY = {
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
scopes: [{ id: 'leads', label: 'Leads', match: { groups: ['leads@example.org'] }, policy: {} }],
};
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
function Harness() {
const draft = useOrgPolicyDraft('test');
latest = draft;
if (draft.loading) return null;
return <SharedTagsSection draft={draft} />;
}
beforeEach(() => {
puts.length = 0;
stored = JSON.parse(JSON.stringify(POLICY));
latest = null;
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
describe('Shared tags section', () => {
it('registers sharedTags on the Sharing page, lock only', () => {
expect(RESTRICTION_KEYS.find((def) => def.key === 'sharedTags')).toMatchObject({ page: 'org-sharing', modes: ['locked'] });
});
it('locks shared tags for everyone and unlocks them in a group scope', async () => {
render(<Harness />);
const section = await screen.findByTestId('org-shared-tags-section');
expect(within(section).queryByText('Hide')).not.toBeInTheDocument();
fireEvent.click(within(section).getByRole('checkbox'));
expect(latest!.policy?.restrictions.sharedTags).toEqual({ locked: true });
act(() => latest!.setScopeId('leads'));
await waitFor(() => expect(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox')).toBeChecked());
fireEvent.click(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox'));
// In a scope an empty restriction is a real override: it unlocks.
expect(latest!.policy?.scopes?.[0].policy.restrictions?.sharedTags).toEqual({});
const lead = resolvePolicy(latest!.policy!, { email: 'anna@example.org', groups: ['leads@example.org'] }).policy;
const member = resolvePolicy(latest!.policy!, { email: 'bob@example.org', groups: ['team@example.org'] }).policy;
expect(lead.restrictions.sharedTags?.locked).not.toBe(true);
expect(member.restrictions.sharedTags?.locked).toBe(true);
await act(async () => { await latest!.save(); });
expect(puts[0]).toMatchObject({
restrictions: { sharedTags: { locked: true } },
scopes: [{ id: 'leads', policy: { restrictions: { sharedTags: {} } } }],
});
});
});
@@ -1,14 +1,27 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
import { RestrictionRow } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/** Organisation feature 6; filled in by its own step (local-data/org-features-plan.md). */
/**
* Organisation feature 6: shared tags in group mailboxes. The tags live in
* the group's Inbox on the mail server (Stalwart 1.0 metadata), so the only
* thing to set here is who may change them.
*/
export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return (
<SettingsSection id="shared-tags" title="Shared tags" description="Tag names and colours that every member of a shared mailbox sees.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
<SettingsSection
id="shared-tags"
title="Shared tags"
description="Members of a group mailbox see the same tag names and colors on its mail; members who may change the mailbox can edit them (Stalwart v1.0.0 or later)."
>
<div data-testid="org-shared-tags-section">
{restrictionKeysForPage('org-sharing')
.filter((def) => def.key === 'sharedTags')
.map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
</div>
</SettingsSection>
);
}
@@ -0,0 +1,96 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: () => null,
}));
import { EmailContextMenu } from '../email-context-menu';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { loadSharedTags, resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
// The context menu's tag list for a selection offers the tags of the
// selection's account (commonTagScope, like the toolbar), not those of the
// row that was right-clicked.
const TEAM = { loginId: 'login-1', accountId: 'team' };
// A message in the group account, right-clicked.
const email = {
id: 'e1',
threadId: 't1',
mailboxIds: { 'team:inbox': true },
keywords: { $seen: true },
from: [{ email: 'alice@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
size: 100,
sourceClientAccountId: 'login-1',
sourceAccountId: 'team',
} as unknown as Email;
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
function openTags(batchTagScope: typeof TEAM | null) {
render(
<EmailContextMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
isMultiSelect
selectedCount={2}
batchTagIds={[]}
batchPartialTagIds={[]}
batchTagScope={batchTagScope}
onBatchToggleTag={vi.fn()}
/>,
);
act(() => { fireEvent.mouseEnter(screen.getByTestId('ctx-tag')); });
}
beforeEach(async () => {
resetSharedTags();
// The group's tags come through the real loading path.
const client = {
getAccountId: () => 'me',
getSieveAccountId: () => 'me',
supportsSieve: () => false,
getSharedAccounts: () => [],
getAccountInfo: () => ({ isPersonal: false }),
getPrincipalTypes: async () => ({ team: 'group' }),
advertisesBulwarkMetadata: () => true,
supportsBulwarkMetadata: () => true,
getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }),
discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }),
};
useAuthStore.setState({
client: client as never,
activeAccountId: 'login-1',
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
});
useSettingsStore.setState({ emailKeywords: [{ id: 'mine', label: 'Mine', color: 'blue' }], nestedTags: false });
await loadSharedTags(sharedTagsKey(TEAM.loginId, TEAM.accountId), TEAM.accountId, client);
expect(useSharedTagsStore.getState().entries[sharedTagsKey(TEAM.loginId, TEAM.accountId)]?.status).toBe('ready');
});
afterEach(() => resetSharedTags());
describe('EmailContextMenu tags for a selection', () => {
it('offers the group\'s shared tags when the whole selection is in the group', async () => {
openTags(TEAM);
expect(await screen.findByText('Team urgent')).toBeInTheDocument();
expect(screen.queryByText('Mine')).not.toBeInTheDocument();
});
it('offers the own tags when the selection mixes accounts, even from a group row', async () => {
openTags(null);
expect(await screen.findByText('Mine')).toBeInTheDocument();
expect(screen.queryByText('Team urgent')).not.toBeInTheDocument();
});
});
+5 -1
View File
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { Tag } from "@/components/icons";
import { TagPicker } from "./tag-picker";
import type { TagScope } from "@/hooks/use-account-tags";
const TAG_PICKER_WIDTH = 224;
@@ -22,6 +23,7 @@ export function BatchTagButton({
onToggle,
active,
disabled,
scope = null,
}: {
title: string;
/** Tags every selected message carries. */
@@ -32,6 +34,8 @@ export function BatchTagButton({
/** Whether there is a selection to tag; the picker shuts without one. */
active: boolean;
disabled?: boolean;
/** The account of the whole selection (commonTagScope); null: the user's own tags. */
scope?: TagScope | null;
}) {
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const pickerRef = useRef<HTMLDivElement | null>(null);
@@ -97,7 +101,7 @@ export function BatchTagButton({
className="fixed z-50 w-56 max-w-[18rem] rounded-lg border border-border bg-popover py-1 shadow-lg"
style={{ top: pos.top, left: pos.left }}
>
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} />
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} scope={scope} />
</div>,
document.body,
)}
+11
View File
@@ -4,6 +4,7 @@ import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useEmailStore, removalFolder, canRemoveFromFolder, emailLinkSource } from "@/stores/email-store";
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
@@ -112,6 +113,11 @@ interface EmailContextMenuProps {
/** Tags every selected message carries, and those only some of them do. */
batchTagIds?: string[];
batchPartialTagIds?: string[];
/**
* The account of the whole selection (commonTagScope): its tags are
* offered. Null when the selection mixes accounts: the user's own tags.
*/
batchTagScope?: TagScope | null;
/** Takes the tag off when the whole selection has it, else puts it on all. */
onBatchToggleTag?: (tagId: string) => void;
onBatchDelete?: () => void;
@@ -179,6 +185,7 @@ export function EmailContextMenu({
onBatchMarkAsRead,
batchTagIds,
batchPartialTagIds,
batchTagScope = null,
onBatchToggleTag,
onBatchDelete,
onBatchArchive,
@@ -206,6 +213,8 @@ export function EmailContextMenu({
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentTagIds = getEmailTagIds(email.keywords);
// The row's account names its tags; a selection uses batchTagScope.
const tagScope = useEmailTagScope(email);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
@@ -503,11 +512,13 @@ export function EmailContextMenu({
selectedIds={batchTagIds ?? []}
partialIds={batchPartialTagIds}
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
scope={batchTagScope}
/>
) : (
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
scope={tagScope}
/>
)}
</div>
+3
View File
@@ -11,6 +11,7 @@ import { useArchiveActionEnabled, withoutArchiveIfOff } from "@/lib/archive-gate
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
import { TagPicker } from "@/components/email/tag-picker";
import { useEmailTagScope } from "@/hooks/use-account-tags";
import { getEmailTagIds } from "@/lib/thread-utils";
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
@@ -111,6 +112,7 @@ export function EmailHoverActions({
spamApplicable = true,
mailboxRole = null,
}: EmailHoverActionsProps) {
const tagScope = useEmailTagScope(email);
const configuredHoverActions = useSettingsStore((state) => state.hoverActions);
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
const archiveActionEnabled = useArchiveActionEnabled();
@@ -294,6 +296,7 @@ export function EmailHoverActions({
>
<TagPicker
selectedIds={currentTagIds}
scope={tagScope}
onToggle={(tagId) => onSetTag(tagId)}
/>
</div>,
+7 -1
View File
@@ -44,6 +44,7 @@ import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
import { unreadSectionRowCount } from "@/lib/unread-first";
import { MvListSectionHeader } from "./mv-list-section-header";
import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results";
import { commonTagScope } from "@/hooks/use-account-tags";
interface EmailListProps {
emails: Email[];
@@ -256,6 +257,7 @@ export function EmailList({
const listHold = useEmailStore((state) => state.listHold);
const viewingAccountId = useEmailStore((state) => state.viewingAccountId);
const selectedKeyword = useEmailStore((state) => state.selectedKeyword);
const selectedKeywordAccountId = useEmailStore((state) => state.selectedKeywordAccountId);
const searchMailboxId = useEmailStore((state) => state.searchMailboxId);
const activeAccountId = useAuthStore((state) => state.activeAccountId);
const activeTabId = useMessageListTabsStore((state) => state.activeTabId);
@@ -511,6 +513,7 @@ export function EmailList({
viewingAccountId,
selectedMailbox,
selectedKeyword,
selectedKeywordAccountId,
isUnifiedView && (crossView ?? unifiedRole),
isScheduledView,
activeTabId,
@@ -587,7 +590,8 @@ export function EmailList({
const all: string[] = [];
const some: string[] = [];
for (const [tagId, count] of counts) (count === selected.length ? all : some).push(tagId);
return { all, some };
// A selection within one group account offers that account's shared tags.
return { all, some, scope: commonTagScope(selected) };
}, [emails, selectedEmailKeys]);
// A tag every selected message has comes off; any other goes onto all of
@@ -860,6 +864,7 @@ export function EmailList({
title={tContextMenu('tag')}
selectedIds={selectionTags.all}
partialIds={selectionTags.some}
scope={selectionTags.scope}
onToggle={handleBatchToggleTag}
active={hasSelection && !isScheduledView}
disabled={isProcessing}
@@ -1182,6 +1187,7 @@ export function EmailList({
onBatchMarkAsRead={(read) => markSelectionRead(read)}
batchTagIds={selectionTags.all}
batchPartialTagIds={selectionTags.some}
batchTagScope={selectionTags.scope}
onBatchToggleTag={handleBatchToggleTag}
onBatchDelete={async () => {
if (!client) return;
+9 -2
View File
@@ -30,6 +30,7 @@ import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useAccountTags, useEmailTagScope } from "@/hooks/use-account-tags";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
@@ -1120,8 +1121,10 @@ export function EmailViewer({
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
// A message in a group account is tagged with that account's shared tags.
const tagScope = useEmailTagScope(email);
const emailKeywords = useAccountTags(tagScope).definitions;
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
@@ -4110,6 +4113,7 @@ export function EmailViewer({
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
scope={tagScope}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
@@ -4404,6 +4408,7 @@ export function EmailViewer({
>
<TagPicker
selectedIds={currentTagIds}
scope={tagScope}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</SubMenuPanel>
@@ -4865,6 +4870,7 @@ export function EmailViewer({
<TagPicker
touch
selectedIds={currentTagIds}
scope={tagScope}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
)}
@@ -4948,6 +4954,7 @@ export function EmailViewer({
key={tagId}
tagId={tagId}
variant={headerTagVariant}
scope={tagScope}
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
/>
))}
+2 -1
View File
@@ -37,6 +37,7 @@ import { getEmailTagIds } from "@/lib/thread-utils";
import { cn, type MailboxNode } from "@/lib/utils";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
import { commonTagScope } from "@/hooks/use-account-tags";
interface MvListToolbarProps {
/** Loaded conversation count, for the right-hand range readout. */
@@ -643,7 +644,7 @@ export function MvListToolbar({
aria-label={tMenu("tag")}
className="absolute start-0 top-full mt-1 z-50 min-w-64 rounded-lg border border-border bg-popover py-1 shadow-xl"
>
<TagPicker selectedIds={appliedTags} partialIds={partialTags} onToggle={handleToggleTag} />
<TagPicker selectedIds={appliedTags} partialIds={partialTags} onToggle={handleToggleTag} scope={commonTagScope(selectedEmails)} />
</div>
)}
</div>
+5 -1
View File
@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
import { X } from "@/components/icons";
import { cn } from "@/lib/utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import type { TagScope } from "@/hooks/use-account-tags";
import { useShortenedText } from "@/hooks/use-shortened-text";
/**
@@ -42,6 +43,7 @@ export function TagBadge({
variant,
onRemove,
className,
scope,
}: {
tagId: string;
variant: TagBadgeVariant;
@@ -51,9 +53,11 @@ export function TagBadge({
*/
onRemove?: () => void;
className?: string;
/** The message's account (useEmailTagScope); undefined: TagScopeContext. */
scope?: TagScope | null;
}) {
const t = useTranslations("email_viewer");
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(scope);
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
const color = tagColor(tagId);
const name = tagName(tagId);
+19 -2
View File
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
/** Below this many tags a filter box costs more room than it saves. */
const SEARCH_THRESHOLD = 10;
@@ -30,6 +31,7 @@ export function TagPicker({
partialIds = NO_IDS,
onToggle,
touch = false,
scope,
}: {
selectedIds: string[];
/**
@@ -40,11 +42,18 @@ export function TagPicker({
onToggle: (tagId: string) => void;
/** Larger hit areas for the mobile sheet. */
touch?: boolean;
/**
* The account whose tags to offer (a group account offers its shared
* tags). Undefined: the TagScopeContext around the picker; null: the
* user's own tags.
*/
scope?: TagScope | null;
}) {
const t = useTranslations("email_viewer");
const keywords = useSettingsStore((state) => state.emailKeywords);
const tShared = useTranslations("shared_tags");
const { definitions: keywords, shared } = useAccountTags(scope);
const nestedTags = useSettingsStore((state) => state.nestedTags);
const { tagName, tagColor } = useKeywordFormat();
const { tagName, tagColor } = useKeywordFormat(scope);
const [query, setQuery] = useState("");
const trimmedQuery = query.trim().toLowerCase();
@@ -119,6 +128,14 @@ export function TagPicker({
return (
<>
{shared && (
<p
className={cn("text-xs text-muted-foreground", touch ? "px-4 pb-2" : "px-3 pb-1")}
data-testid="tag-picker-shared"
>
{tShared("picker_heading")}
</p>
)}
{showSearch && (
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
+5 -3
View File
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useEmailTagScope } from "@/hooks/use-account-tags";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge } from "./tag-badge";
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
@@ -43,7 +44,8 @@ export function ThreadEmailItem({
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { sortTagIds } = useKeywordFormat();
const tagScope = useEmailTagScope(email);
const { sortTagIds } = useKeywordFormat(tagScope);
const { variant: tagVariant } = useTagDisplay();
// A message inside an expanded thread carries its own tags; the collapsed
// header pools them, so without this they disappear on the way in.
@@ -164,7 +166,7 @@ export function ThreadEmailItem({
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
<span className={cn("min-w-0 truncate", isUnread ? "text-muted-foreground" : "text-muted-foreground/70")}>
{cleanPreview(email.preview) || t('no_preview_available')}
@@ -295,7 +297,7 @@ export function ThreadEmailItem({
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
</div>
+11 -8
View File
@@ -23,6 +23,7 @@ import { useEmailStore, isUnscopedSearchActive } from "@/stores/email-store";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useEmailTagScope } from "@/hooks/use-account-tags";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
import { useEmailDrag } from "@/hooks/use-email-drag";
@@ -380,7 +381,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const formatAddress = useOwnDomainAddress();
const { sortTagIds, tagColor } = useKeywordFormat();
const tagScope = useEmailTagScope(email);
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
@@ -694,7 +696,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{tagIds.length > 0 && (
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
</span>
)}
@@ -775,7 +777,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{tagPlacement === 'sender' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
</span>
)}
@@ -837,7 +839,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
</span>
)}
@@ -1003,7 +1005,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
);
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const { sortTagIds, tagColor } = useKeywordFormat();
const tagScope = useEmailTagScope(latestEmail);
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
@@ -1345,7 +1348,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/>
)}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
</span>
)}
@@ -1434,7 +1437,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
title={t('messages_tooltip', { count: emailCount })}
/>
{tagPlacement === 'sender' && tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
</span>
<div className="flex items-center gap-1.5">
@@ -1495,7 +1498,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
))}
</span>
)}
@@ -0,0 +1,110 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// A group account's shared tags under its folders in the Shared section:
// counted in that account only, and picking one lists that account's mail
// with the tag, without selecting the user's own tag of the same id.
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/contexts/drag-drop-context', () => ({
useDragDropContext: () => ({ isDragging: false }),
}));
vi.mock('@/hooks/use-mailbox-drop', () => ({
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
}));
vi.mock('@/hooks/use-tag-drop', () => ({
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
}));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const { Sidebar } = await import('../sidebar');
const { useAuthStore } = await import('@/stores/auth-store');
const { useEmailStore } = await import('@/stores/email-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const { usePolicyStore } = await import('@/stores/policy-store');
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
const { loadSharedTags, resetSharedTags, sharedTagsKey } = await import('@/stores/shared-tags-store');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } },
{ id: 'team:a', originalId: 'a', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, accountId: 'team', accountName: 'org-team@example.org', isShared: true },
{ id: 'team:t', originalId: 't', name: 'Trash', role: 'trash', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, accountId: 'team', accountName: 'org-team@example.org', isShared: true },
] as unknown as Mailbox[];
const client = {
getAccountId: () => 'me',
getAccountInfo: () => ({ isPersonal: false }),
getPrincipalTypes: async () => ({ team: 'group' }),
advertisesBulwarkMetadata: () => true,
supportsBulwarkMetadata: () => true,
getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }),
discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }),
getTagCounts: vi.fn(async () => ({ urgent: { total: 5, unread: 2 } })),
};
async function renderSidebar(onTagSelect = vi.fn()) {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" selectedKeyword={useEmailStore.getState().selectedKeyword} onTagSelect={onTagSelect} />);
const account = screen.getByTestId('section-shared-account');
if (account.getAttribute('data-expanded') !== 'true') fireEvent.click(account);
return within(await screen.findByTestId('shared-account-tags'));
}
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('sidebarSharedExpanded', 'true');
resetSharedTags();
client.getTagCounts.mockClear();
useAuthStore.setState({
client: client as never,
activeAccountId: 'login-1',
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
getAllConnectedClients: (() => new Map([['login-1', client]])) as never,
});
useEmailStore.setState({ mailboxes, selectedKeyword: null, selectedKeywordAccountId: null, tagCounts: {} });
useSettingsStore.setState({
hideAccountSwitcher: true,
enableUnifiedMailbox: false,
showSharedMailAccounts: true,
hiddenSharedMailAccounts: {},
nestedTags: false,
emailKeywords: [{ id: 'urgent', label: 'My urgent', color: 'blue' }],
} as never);
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY } });
await loadSharedTags(sharedTagsKey('login-1', 'team'), 'team', client);
});
afterEach(() => resetSharedTags());
describe('Sidebar shared tags', () => {
it('counts a shared tag in its group account only, outside Trash', async () => {
const tags = await renderSidebar();
expect(tags.getByText('Team urgent')).toBeInTheDocument();
await waitFor(() => expect(client.getTagCounts).toHaveBeenCalledWith(['urgent'], 'team', ['t']));
await waitFor(() => expect(tags.getByText('2')).toBeInTheDocument());
});
it('opens the tag view of that account and leaves the own tag of the same id unselected', async () => {
const onTagSelect = vi.fn();
const tags = await renderSidebar(onTagSelect);
fireEvent.click(tags.getByText('Team urgent'));
expect(onTagSelect).toHaveBeenCalledWith('urgent', 'team');
// The store now lists that account only; the sidebar marks the group's row.
act(() => useEmailStore.getState().selectKeyword('urgent', 'team'));
expect(useEmailStore.getState().selectedKeywordAccountId).toBe('team');
cleanup();
const after = await renderSidebar(onTagSelect);
expect(after.getByText('Team urgent').closest('[data-testid="folder-row"]')).toHaveAttribute('data-selected', 'true');
expect(screen.getByText('My urgent').closest('[data-testid="folder-row"]')).not.toHaveAttribute('data-selected');
// The own tag picked from the own list: the other way round.
act(() => useEmailStore.getState().selectKeyword('urgent'));
cleanup();
const own = await renderSidebar(onTagSelect);
expect(own.getByText('Team urgent').closest('[data-testid="folder-row"]')).not.toHaveAttribute('data-selected');
expect(screen.getByText('My urgent').closest('[data-testid="folder-row"]')).toHaveAttribute('data-selected', 'true');
});
});
+122 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { memo, useState, useEffect, useMemo, Fragment, ReactNode } from "react";
import { memo, useState, useEffect, useMemo, useCallback, Fragment, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -52,6 +52,7 @@ import {
} from "@/lib/keyword-nesting";
import { useShortenedText } from "@/hooks/use-shortened-text";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { TagScopeContext, useAccountTags } from "@/hooks/use-account-tags";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -70,7 +71,8 @@ import { unifiedLoadProgress } from "@/lib/unified-mailbox";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useEmailStore } from "@/stores/email-store";
import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store";
import { trashAndJunkIds } from "@/lib/unified-mailbox";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { AccountSwitcher } from "./account-switcher";
@@ -96,7 +98,7 @@ interface SidebarProps {
selectedMailbox?: string;
selectedKeyword?: string | null;
onMailboxSelect?: (mailboxId: string) => void;
onTagSelect?: (keywordId: string | null) => void;
onTagSelect?: (keywordId: string | null, accountId?: string | null) => void;
onCompose?: () => void;
/**
* Mountain View, desktop only: the rail grows a Compose pill at the top and
@@ -743,7 +745,7 @@ function TagItem({
selectedKeyword: string | null;
expandedTags: Set<string>;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
onTagSelect?: (keywordId: string | null, accountId?: string | null) => void;
onToggleExpand: (keywordId: string) => void;
tagCounts: Record<string, { total: number; unread: number }>;
colorful: boolean;
@@ -822,6 +824,106 @@ function TagItem({
);
}
type TagCounts = Record<string, { total: number; unread: number }>;
const NO_TAG_COUNTS: TagCounts = {};
/**
* Counts of a group account's shared tags in that account only, outside
* Trash and Junk like the tag view. Asked again whenever the sidebar's own
* tag counts are (every mail change), and when the tags change.
*/
function useSharedTagCounts(accountId: string, ids: string[], enabled: boolean): TagCounts {
const client = useAuthStore((s) => s.client);
const refresh = useEmailStore((s) => s.tagCounts);
const idsKey = ids.join('\n');
const [counts, setCounts] = useState<TagCounts>(NO_TAG_COUNTS);
useEffect(() => {
if (!enabled || !client || !idsKey) return;
const account = buildTagViewAccountClients(client).find((a) => a.jmapAccountId === accountId);
if (!account) return;
let cancelled = false;
client.getTagCounts(idsKey.split('\n'), accountId, trashAndJunkIds(account))
.then((next) => { if (!cancelled) setCounts(next); })
.catch(() => { /* the rows show no count */ });
return () => { cancelled = true; };
}, [client, accountId, idsKey, refresh, enabled]);
return counts;
}
/**
* The tags of a group account, under its folders in the Shared section: the
* shared definitions plus the tags found on its messages (useAccountTags),
* with their counts in that account. Picking one lists that account's
* messages with the tag. Nothing for a mailbox a person shared or a server
* without metadata, where the user's own tags above stand for every account
* as before.
*/
function SharedAccountTags({
loginId,
accountId,
selectedKeyword,
expandedTags,
onTagSelect,
onToggleExpand,
colorful,
}: {
loginId: string;
accountId: string;
/** The tag picked in this account's list, if any. */
selectedKeyword: string | null;
expandedTags: Set<string>;
onTagSelect?: (keywordId: string | null, accountId?: string | null) => void;
onToggleExpand: (keywordId: string) => void;
colorful: boolean;
}) {
const t = useTranslations('shared_tags');
const router = useRouter();
const scope = useMemo(() => ({ loginId, accountId }), [loginId, accountId]);
const { definitions, shared } = useAccountTags(scope);
const nestedTags = useSettingsStore((s) => s.nestedTags);
const [expanded, setExpanded] = useState(true);
const tree = useMemo(
() => (nestedTags ? buildKeywordTree(definitions) : definitions.map((kw) => ({ ...kw, children: [], depth: 0 }))),
[definitions, nestedTags],
);
const ids = useMemo(() => definitions.map((kw) => kw.id), [definitions]);
const counts = useSharedTagCounts(accountId, ids, shared && expanded);
const select = useCallback(
(keywordId: string | null) => onTagSelect?.(keywordId, keywordId ? accountId : null),
[onTagSelect, accountId],
);
if (!shared || definitions.length === 0) return null;
return (
<TagScopeContext.Provider value={scope}>
<div data-testid="shared-account-tags">
<SidebarSectionHeader
label={t('sidebar_heading')}
expanded={expanded}
onToggle={() => setExpanded((open) => !open)}
onSettings={() => router.push(buildSettingsPath('organize', 'shared-tags'))}
settingsTitle={t('edit')}
testId="section-shared-tags"
isCollapsed={false}
sub
/>
{expanded && tree.map((node) => (
<TagItem
key={node.id}
node={node}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={false}
onTagSelect={select}
onToggleExpand={onToggleExpand}
tagCounts={counts}
colorful={colorful}
/>
))}
</div>
</TagScopeContext.Provider>
);
}
function DemoBanner() {
const t = useTranslations('sidebar');
const { isDemoMode, loginDemo } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode, loginDemo: state.loginDemo })));
@@ -1013,6 +1115,9 @@ function SidebarImpl({
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const tagsAboveShared = usePolicyStore(s => s.isFeatureEnabled('tagsAboveSharedEnabled'));
// A tag picked in a group account's shared tags (see SharedAccountTags).
const selectedKeywordAccountId = useEmailStore(s => s.selectedKeywordAccountId);
const personalSelectedKeyword = selectedKeywordAccountId ? null : selectedKeyword;
const isEmbedded = useIsEmbedded();
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
@@ -1174,7 +1279,7 @@ function SidebarImpl({
// filling it back in. A tag the server answered for with zero unread hides,
// which is the point of the setting.
const isTagVisible = (node: KeywordNode) => {
if (showAllTags || node.id === selectedKeyword) return true;
if (showAllTags || node.id === personalSelectedKeyword) return true;
const visibility = getKeywordVisibility(node);
if (visibility === 'hide') return false;
if (visibility === 'unread') {
@@ -1312,7 +1417,7 @@ function SidebarImpl({
<TagItem
key={node.id}
node={node}
selectedKeyword={selectedKeyword}
selectedKeyword={personalSelectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
@@ -1882,6 +1987,17 @@ function SidebarImpl({
))}
{menuAccountId && !account.children.some((child) => child.role === 'drafts')
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
{menuAccountId && activeAccountId && (
<SharedAccountTags
loginId={activeAccountId}
accountId={menuAccountId}
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
expandedTags={expandedTags}
onTagSelect={onTagSelect}
onToggleExpand={handleToggleTagExpand}
colorful={colorfulSidebarIcons}
/>
)}
</>
)}
</div>
+2 -2
View File
@@ -3311,9 +3311,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
};
const handleTagSelect = async (keywordId: string | null) => {
const handleTagSelect = async (keywordId: string | null, accountId: string | null = null) => {
setScheduledView(false);
selectKeyword(keywordId);
selectKeyword(keywordId, accountId);
// On mobile, close sidebar and go to list view
if (isMobile) {
@@ -0,0 +1,192 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Names with their tag in them: the global mock returns the bare key.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: { name?: string }) => (params?.name ? `${key}:${params.name}` : key),
useLocale: () => 'en',
}));
import { SharedTagSettings, maySetSharedTags } from '../shared-tag-settings';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import { resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
// Settings > Folders & tags > Shared tags: the editor of each group
// mailbox's tags, read-only when the admin locked them, and a note instead of
// an editor where the server keeps no metadata or couldn't be read.
const URGENT = { label: 'Urgent', color: 'red' };
const RIGHTS = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
let stored: Record<string, unknown> | null;
let keywords: Record<string, number>;
let advertised: boolean;
let principalType: string;
const client = {
getAccountId: () => 'me',
getAccountInfo: (id: string) => ({ isPersonal: id === 'me' }),
getSharedAccounts: () => [
{ id: 'me', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } },
{ id: 'team', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } },
],
getPrincipalTypes: async () => ({ team: principalType }),
advertisesBulwarkMetadata: () => advertised,
supportsBulwarkMetadata: () => advertised,
getBulwarkMetadata: vi.fn(async () => (stored ? { tags: stored } : null)),
patchBulwarkMetadata: vi.fn(async (_id: string | undefined, _key: string, patches: Array<{ path: string[]; value: unknown }>) => {
const tags = { ...((stored?.tags ?? {}) as Record<string, unknown>) };
for (const { path, value } of patches) {
if (value === null) delete tags[path[1]];
else tags[path[1]] = value;
}
stored = { tags };
return { newState: 'sX' };
}),
discoverKeywords: async () => ({ keywords, scanned: 0, total: 0, complete: true }),
getMailboxes: vi.fn(async () => []),
};
function setRights(rights: Record<string, boolean>) {
useEmailStore.setState({ mailboxes: [{ id: 'team:a', name: 'Inbox', role: 'inbox', accountId: 'team', isShared: true, myRights: rights } as never] });
}
function setLocked(locked: boolean) {
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: locked ? { sharedTags: { locked: true } } : {} } });
}
beforeEach(() => {
resetSharedTags();
stored = { tags: { urgent: { ...URGENT } } };
keywords = {};
advertised = true;
principalType = 'group';
client.getBulwarkMetadata.mockClear();
client.patchBulwarkMetadata.mockClear();
client.getMailboxes.mockClear();
useSettingsStore.setState({ emailKeywords: [], nestedTags: false });
useAuthStore.setState({
client: client as never,
activeAccountId: 'login-1',
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
});
setLocked(false);
setRights({ ...RIGHTS, maySetMetadata: true });
});
afterEach(() => resetSharedTags());
describe('SharedTagSettings', () => {
it('lets a member who may change the Inbox add a shared tag, as one patch', async () => {
render(<SharedTagSettings />);
expect(await screen.findByText('Urgent')).toBeInTheDocument();
fireEvent.click(screen.getByText('add'));
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Waiting' } });
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
{ path: ['tags', 'waiting'], value: { label: 'Waiting', color: 'blue' } },
]));
expect(await screen.findByText('Waiting')).toBeInTheDocument();
});
it('names the buttons after their tag and puts focus back on the edit button after saving', async () => {
render(<SharedTagSettings />);
fireEvent.click(await screen.findByLabelText('edit_tag:Urgent'));
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Very urgent' } });
fireEvent.click(screen.getByText('save'));
// The color wasn't picked, so it isn't sent: whatever is stored stays.
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
{ path: ['tags', 'urgent'], value: { label: 'Very urgent', color: 'red' } },
]));
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('edit_tag:Very urgent')));
});
it('warns that deleting is for everyone, in words, before it deletes', async () => {
render(<SharedTagSettings />);
fireEvent.click(await screen.findByLabelText('delete_tag:Urgent'));
const confirm = screen.getByTestId('shared-tag-delete-confirm');
const warning = screen.getByText('delete_confirm');
expect(confirm).toContainElement(warning);
// Shown at every width, not only from the sm breakpoint up.
expect(warning.className).not.toMatch(/(^|\s)hidden(\s|$)/);
fireEvent.click(screen.getByText('delete'));
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [{ path: ['tags', 'urgent'], value: null }]));
});
it('shows the tags read-only with "Set by your organisation" when the admin locked them', async () => {
setLocked(true);
keywords = { '$label:found': 1 };
const { container } = render(<SharedTagSettings />);
expect(await screen.findByText('Urgent')).toBeInTheDocument();
await screen.findByText('found_on_messages');
expect(container.querySelector('[data-org-locked]')).not.toBeNull();
expect(screen.queryByText('add')).not.toBeInTheDocument();
expect(screen.queryByLabelText(/^edit_tag/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/^delete_tag/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/^name_it/)).not.toBeInTheDocument();
});
it('is read-only with a note for a member without the right to change the Inbox', async () => {
setRights({ ...RIGHTS, maySetMetadata: false });
render(<SharedTagSettings />);
expect(await screen.findByTestId('shared-tags-no-rights')).toHaveTextContent('no_rights');
expect(screen.queryByText('add')).not.toBeInTheDocument();
});
it('asks for no rights of a mailbox a person shared', async () => {
principalType = 'individual';
useEmailStore.setState({ mailboxes: [] });
render(<SharedTagSettings />);
await waitFor(() => expect(useSharedTagsStore.getState().entries[sharedTagsKey('login-1', 'team')]?.status).toBe('personal'));
expect(client.getMailboxes).not.toHaveBeenCalled();
expect(screen.queryByTestId('shared-tags-account')).not.toBeInTheDocument();
});
it('says in one line that the server keeps no shared tags, with no editor', async () => {
advertised = false;
render(<SharedTagSettings />);
expect(await screen.findByTestId('shared-tags-unsupported')).toHaveTextContent('unsupported');
expect(screen.queryByText('add')).not.toBeInTheDocument();
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
});
it('says so in one line when the shared tags couldn\'t be read', async () => {
stored = null;
render(<SharedTagSettings />);
expect(await screen.findByTestId('shared-tags-unavailable')).toHaveTextContent('unavailable');
expect(screen.queryByText('add')).not.toBeInTheDocument();
});
it('keeps a change the server didn\'t take and says it will try again', async () => {
render(<SharedTagSettings />);
await screen.findByText('Urgent');
client.patchBulwarkMetadata.mockRejectedValueOnce(new TypeError('Failed to fetch'));
fireEvent.click(screen.getByText('add'));
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Waiting' } });
await act(async () => { fireEvent.click(screen.getByText('save')); });
expect(await screen.findByTestId('shared-tags-pending')).toHaveTextContent('save_pending');
expect(screen.getByText('Waiting')).toBeInTheDocument();
});
it('offers to name a tag found on the messages, keeping its id', async () => {
keywords = { '$label:waiting-for-customer': 2 };
render(<SharedTagSettings />);
fireEvent.click(await screen.findByLabelText('name_it:waiting-for-customer'));
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Customer' } });
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
{ path: ['tags', 'waiting-for-customer'], value: { label: 'Customer', color: expect.any(String) } },
]));
});
});
describe('maySetSharedTags', () => {
it('follows maySetMetadata, and renaming where the server has no such right', () => {
expect(maySetSharedTags({ maySetMetadata: true, mayRename: false })).toBe(true);
expect(maySetSharedTags({ maySetMetadata: false, mayRename: true })).toBe(false);
expect(maySetSharedTags({ mayRename: true })).toBe(true);
expect(maySetSharedTags(null)).toBe(false);
});
});
+1 -1
View File
@@ -34,7 +34,7 @@ import { useShortenedText } from "@/hooks/use-shortened-text";
import { TagBadge } from "@/components/email/tag-badge";
/** Lighter, base and darker shade of each hue, one row per shade. */
function KeywordColorPicker({
export function KeywordColorPicker({
value,
onChange,
}: {
@@ -2,6 +2,7 @@
import { FolderSettings } from './folder-settings';
import { KeywordSettings } from './keyword-settings';
import { SharedTagSettings } from './shared-tag-settings';
import { usePolicyStore } from '@/stores/policy-store';
/**
@@ -14,6 +15,7 @@ export function OrganizeSettings() {
<div className="space-y-8">
<FolderSettings />
{tagsEnabled && <KeywordSettings />}
{tagsEnabled && <SharedTagSettings />}
</div>
);
}
+378
View File
@@ -0,0 +1,378 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Loader2, Pencil, Plus, Trash2, Users, X } from "@/components/icons";
import { TagBadge } from "@/components/email/tag-badge";
import { useAccountTags, TagScopeContext, clientForLogin, type TagScope } from "@/hooks/use-account-tags";
import { useRestriction } from "@/hooks/use-restriction";
import { MetadataTooLargeError } from "@/lib/jmap/bulwark-metadata";
import type { MailboxRights } from "@/lib/jmap/types";
import { composeKeywordId, MAX_KEYWORD_ID_LENGTH } from "@/lib/keyword-nesting";
import { MAX_SHARED_TAG_LABEL, groupAccountTags, type SharedTagChange } from "@/lib/shared-tags";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { KEYWORD_PALETTE, useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
import { SharedTagsSaveDeferredError, saveSharedTags, sharedTagsKey, useSharedTagsStore } from "@/stores/shared-tags-store";
import { toast } from "@/stores/toast-store";
import { toUnicodeEmail } from "@/lib/idn";
import { KeywordColorPicker } from "./keyword-settings";
import { OrgLockedNote } from "./org-locked-note";
import { SettingsSection } from "./settings-section";
/** Whether the rights on a group's Inbox let the user change its metadata. */
export function maySetSharedTags(rights: Partial<MailboxRights> | null | undefined): boolean {
if (!rights) return false;
// Stalwart 1.0 names the right; a server that doesn't counts renaming.
return typeof rights.maySetMetadata === "boolean" ? rights.maySetMetadata : rights.mayRename === true;
}
/**
* The user's rights on the Inbox of a confirmed group account, from the
* folder list or asked once. Nothing is asked for any other account.
*/
function useInboxRights(scope: TagScope, enabled: boolean): Partial<MailboxRights> | null | undefined {
const fromStore = useEmailStore((state) =>
state.mailboxes.find((mailbox) => mailbox.accountId === scope.accountId && mailbox.role === "inbox")?.myRights);
const [fetched, setFetched] = useState<Partial<MailboxRights> | null | undefined>(undefined);
useEffect(() => {
if (!enabled || fromStore) return;
let cancelled = false;
const client = clientForLogin(scope.loginId) as unknown as { getMailboxes?: (accountId?: string) => Promise<Array<{ role?: string; myRights?: MailboxRights }>> } | null;
if (!client?.getMailboxes) return;
client.getMailboxes(scope.accountId)
.then((mailboxes) => { if (!cancelled) setFetched(mailboxes.find((m) => m.role === "inbox")?.myRights ?? null); })
.catch(() => { if (!cancelled) setFetched(null); });
return () => { cancelled = true; };
}, [enabled, fromStore, scope.loginId, scope.accountId]);
return enabled ? fromStore ?? fetched : undefined;
}
function SharedTagForm({
initial,
fixedId,
takenIds,
saving,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
/** The id stays: an existing tag or one found on messages. */
fixedId?: string;
takenIds: string[];
saving: boolean;
/** `color` only when it was picked (or the tag is new), so a color this version doesn't know survives a rename. */
onSave: (id: string, value: { label: string; color?: string }) => void;
onCancel: () => void;
}) {
const t = useTranslations("shared_tags");
const [label, setLabel] = useState(initial?.label ?? "");
const [color, setColor] = useState(initial?.color ?? "blue");
const [colorPicked, setColorPicked] = useState(false);
const id = fixedId ?? composeKeywordId(null, label);
const duplicate = !fixedId && id.length > 0 && takenIds.includes(id.toLowerCase());
const tooLong = !fixedId && id.length > MAX_KEYWORD_ID_LENGTH;
const valid = label.trim().length > 0 && id.length > 0 && !duplicate && !tooLong;
const keepsColor = !!initial && !colorPicked;
const save = () => {
if (valid && !saving) onSave(id, keepsColor ? { label: label.trim() } : { label: label.trim(), color });
};
return (
<div className="space-y-3 rounded-md border border-primary/30 bg-accent/30 p-3" data-testid="shared-tag-form">
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground" htmlFor={`shared-tag-label-${fixedId ?? "new"}`}>
{t("name")}
</label>
<input
id={`shared-tag-label-${fixedId ?? "new"}`}
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_SHARED_TAG_LABEL}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") { e.stopPropagation(); onCancel(); }
}}
className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{duplicate && <p className="mt-1 text-xs text-destructive">{t("exists")}</p>}
{tooLong && <p className="mt-1 text-xs text-destructive">{t("too_long")}</p>}
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">{t("color")}</p>
<KeywordColorPicker value={color} onChange={(next) => { setColor(next); setColorPicked(true); }} />
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="flex min-h-[44px] items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted sm:min-h-0"
>
<X className="h-3.5 w-3.5" />
{t("cancel")}
</button>
<button
type="button"
onClick={save}
disabled={!valid || saving}
className="flex min-h-[44px] items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:min-h-0"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{t("save")}
</button>
</div>
</div>
);
}
const ICON_BUTTON = "grid h-11 w-11 place-items-center rounded-md text-muted-foreground hover:bg-muted sm:h-8 sm:w-8";
/**
* The shared tags of one group account. Read-only with "Set by your
* organisation" when the admin locked `sharedTags`, read-only with a note
* when the user may not change the group's Inbox, and a one-line note
* instead of the editor when the server keeps no metadata or couldn't be
* read.
*/
function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string }) {
const t = useTranslations("shared_tags");
const key = sharedTagsKey(scope.loginId, scope.accountId);
const { status } = useAccountTags(scope);
const entry = useSharedTagsStore((state) => state.entries[key]);
const nested = useSettingsStore((state) => state.nestedTags);
const { locked } = useRestriction("sharedTags");
const rights = useInboxRights(scope, status === "ready");
const canWrite = maySetSharedTags(rights);
const editable = !locked && canWrite;
const [editing, setEditing] = useState<{ id?: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const editButtons = useRef(new Map<string, HTMLButtonElement>());
const { shared, discovered } = useMemo(() => {
if (status !== "ready" || !entry) return { shared: [] as KeywordDefinition[], discovered: [] as KeywordDefinition[] };
const { definitions, discoveredIds } = groupAccountTags(entry.tags, entry.discovered, nested);
const found = new Set(discoveredIds);
return { shared: entry.tags, discovered: definitions.filter((tag) => found.has(tag.id)) };
}, [status, entry, nested]);
const takenIds = useMemo(() => [...shared, ...discovered].map((tag) => tag.id.toLowerCase()), [shared, discovered]);
// Back to the row's edit button once a change is saved.
useEffect(() => {
if (!focusId) return;
editButtons.current.get(focusId)?.focus();
setFocusId(null);
}, [focusId, shared]);
if (status === "personal") return null;
const save = async (change: SharedTagChange) => {
const client = clientForLogin(scope.loginId);
if (!client) return false;
setSaving(true);
try {
await saveSharedTags(key, scope.accountId, client, [change]);
return true;
} catch (error) {
if (error instanceof SharedTagsSaveDeferredError) {
// Kept and shown; sent again when the server answers.
toast.warning(t("save_failed"));
return true;
}
toast.error(error instanceof MetadataTooLargeError ? t("too_many") : t("save_failed"));
return false;
} finally {
setSaving(false);
}
};
const heading = (
<div className="flex min-w-0 items-center gap-2">
<Users className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate text-sm font-medium text-foreground">{toUnicodeEmail(name)}</span>
</div>
);
if (status === "loading") {
return (
<div className="space-y-2" data-testid="shared-tags-account" data-status="loading">
{heading}
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("loading")}
</p>
</div>
);
}
if (status === "unsupported" || status === "unavailable") {
return (
<div className="space-y-2" data-testid="shared-tags-account" data-status={status}>
{heading}
<p className="text-xs text-muted-foreground" data-testid={`shared-tags-${status}`}>{t(status)}</p>
</div>
);
}
const row = (tag: KeywordDefinition, found: boolean) => {
if (editing && editing.id === tag.id) {
const raw = entry?.stored?.entries[tag.id];
// The form shows the stored color when this version knows it.
const initial = { ...tag, color: typeof raw?.color === "string" && KEYWORD_PALETTE[raw.color] ? raw.color : tag.color };
return (
<SharedTagForm
key={tag.id}
initial={initial}
fixedId={tag.id}
takenIds={takenIds}
saving={saving}
onCancel={() => { setEditing(null); setFocusId(tag.id); }}
onSave={async (id, value) => {
const next = found ? { label: value.label, color: value.color ?? tag.color } : value;
if (await save({ id, value: next })) { setEditing(null); setFocusId(id); }
}}
/>
);
}
const confirming = editable && confirmDelete === tag.id;
return (
<div
key={tag.id}
className="flex min-h-[44px] flex-wrap items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 sm:min-h-0 sm:flex-nowrap"
data-testid="shared-tag-row"
data-tag-id={tag.id}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<TagBadge tagId={tag.id} variant="badge" className="text-xs" />
{found && <span className="truncate text-xs text-muted-foreground">{t("found_on_messages")}</span>}
</div>
{confirming ? (
<div className="flex basis-full flex-wrap items-center justify-end gap-1 sm:basis-auto sm:flex-nowrap" data-testid="shared-tag-delete-confirm">
<span className="text-xs text-muted-foreground">{t("delete_confirm")}</span>
<button
type="button"
onClick={async () => { if (await save({ id: tag.id, value: null })) setConfirmDelete(null); }}
className="min-h-[44px] rounded-md px-3 py-1 text-xs text-destructive hover:bg-destructive/10 sm:min-h-0"
>
{t("delete")}
</button>
<button
type="button"
onClick={() => { setConfirmDelete(null); setFocusId(tag.id); }}
className="min-h-[44px] rounded-md px-3 py-1 text-xs hover:bg-muted sm:min-h-0"
>
{t("cancel")}
</button>
</div>
) : editable && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
ref={(el) => { if (el) editButtons.current.set(tag.id, el); else editButtons.current.delete(tag.id); }}
onClick={() => { setEditing({ id: tag.id }); setConfirmDelete(null); }}
className={cn(ICON_BUTTON, "hover:text-foreground")}
title={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
aria-label={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
>
{found ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</button>
{!found && (
<button
type="button"
onClick={() => { setConfirmDelete(tag.id); setEditing(null); }}
className={cn(ICON_BUTTON, "hover:text-destructive")}
title={t("delete_tag", { name: tag.label })}
aria-label={t("delete_tag", { name: tag.label })}
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
};
return (
<TagScopeContext.Provider value={scope}>
<div className="space-y-2" data-testid="shared-tags-account" data-status="ready" data-editable={editable || undefined}>
{heading}
{shared.length === 0 && discovered.length === 0 && (
<p className="text-xs text-muted-foreground">{t("empty")}</p>
)}
{shared.map((tag) => row(tag, false))}
{discovered.map((tag) => row(tag, true))}
{(entry?.pending.length ?? 0) > 0 && (
<p className="text-xs text-muted-foreground" data-testid="shared-tags-pending">{t("save_pending")}</p>
)}
{locked ? (
<OrgLockedNote />
) : !canWrite ? (
<p className="text-xs text-muted-foreground" data-testid="shared-tags-no-rights">
{rights === undefined ? t("loading") : t("no_rights")}
</p>
) : editing && !editing.id ? (
<SharedTagForm
takenIds={takenIds}
saving={saving}
onCancel={() => setEditing(null)}
onSave={async (id, value) => { if (await save({ id, value })) { setEditing(null); setFocusId(id); } }}
/>
) : (
<button
type="button"
onClick={() => { setEditing({}); setConfirmDelete(null); }}
className={cn(
"flex min-h-[44px] items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors sm:min-h-0",
"hover:border-primary hover:bg-accent hover:text-foreground",
)}
>
<Plus className="h-3.5 w-3.5" />
{t("add")}
</button>
)}
</div>
</TagScopeContext.Provider>
);
}
/**
* Settings > Folders & tags > Shared tags: the tags of each group mailbox
* the user is a member of. Hidden without group accounts. The account list
* is worked out again whenever the folder list changes, which it does after
* every session change.
*/
export function SharedTagSettings() {
const t = useTranslations("shared_tags");
const client = useAuthStore((state) => state.client);
const loginId = useAuthStore((state) => state.activeAccountId);
const mailboxes = useEmailStore((state) => state.mailboxes);
const accounts = useMemo(
() => {
void mailboxes;
return client && typeof client.getSharedAccounts === "function"
? client.getSharedAccounts().filter((account) => !account.isPrimary && account.capabilities.mail && client.getAccountInfo?.(account.id)?.isPersonal === false)
: [];
},
[client, mailboxes],
);
const entries = useSharedTagsStore((state) => state.entries);
if (!loginId || accounts.length === 0) return null;
// Sharing by a person isn't a group: shown only once known not to be one.
const groups = accounts.filter((account) => entries[sharedTagsKey(loginId, account.id)]?.status !== "personal");
return (
<div className={groups.length === 0 ? "hidden" : undefined}>
<SettingsSection id="shared-tags" title={t("title")} description={t("description")}>
<div className="space-y-5" data-testid="shared-tag-settings">
{accounts.map((account) => (
<SharedAccountTagEditor key={account.id} scope={{ loginId, accountId: account.id }} name={account.name} />
))}
</div>
</SettingsSection>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { TagBadge } from '@/components/email/tag-badge';
import { TagPicker } from '@/components/email/tag-picker';
import { TagScopeContext, tagScopeOf, type TagScope } from '../use-account-tags';
import { useAuthStore } from '@/stores/auth-store';
import { KEYWORD_PALETTE, useSettingsStore } from '@/stores/settings-store';
import { resetSharedTags, sharedTagsKey, useSharedTagsStore, type SharedTagsEntry } from '@/stores/shared-tags-store';
// A message in a group account shows the group's shared tag names and
// colours; everywhere else, and wherever the shared ones aren't known, the
// user's own tags apply as before.
const TEAM: TagScope = { loginId: 'login-1', accountId: 'team' };
const OTHER_TEAM: TagScope = { loginId: 'login-1', accountId: 'other-team' };
function setEntry(scope: TagScope, entry: Partial<SharedTagsEntry>) {
useSharedTagsStore.setState((state) => ({
entries: {
...state.entries,
[sharedTagsKey(scope.loginId, scope.accountId)]: { status: 'ready', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 1, discoveredAt: 1, readFailedAt: null, ...entry },
},
}));
}
function badge(tagId: string, scope?: TagScope | null) {
return render(<TagBadge tagId={tagId} variant="badge" scope={scope} />);
}
beforeEach(() => {
resetSharedTags();
// No signed-in client: the entries below stay what the test sets.
useAuthStore.setState({ client: null, activeAccountId: null, getClientForAccount: (() => undefined) as never });
useSettingsStore.setState({ emailKeywords: [{ id: 'urgent', label: 'My urgent', color: 'blue' }], nestedTags: false });
});
afterEach(() => resetSharedTags());
describe('tag names and colours per account', () => {
it('uses the shared definition in a group account and the own one elsewhere', () => {
setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] });
badge('urgent', TEAM);
expect(screen.getByTitle('Urgent')).toHaveClass(KEYWORD_PALETTE.red.fill);
badge('urgent', null);
expect(screen.getByText('My urgent')).toBeInTheDocument();
});
it('keeps the own tags in a group account whose server keeps no metadata', () => {
setEntry(TEAM, { status: 'unsupported' });
badge('urgent', TEAM);
expect(screen.getByText('My urgent')).toBeInTheDocument();
});
it('keeps the own tags while the shared ones couldn\'t be read', () => {
setEntry(TEAM, { status: 'unavailable', tags: [{ id: 'urgent', label: 'Stale', color: 'red' }] });
badge('urgent', TEAM);
expect(screen.getByText('My urgent')).toBeInTheDocument();
});
it('keeps the own tags in a mailbox a person shared', () => {
setEntry(TEAM, { status: 'personal' });
badge('urgent', TEAM);
expect(screen.getByText('My urgent')).toBeInTheDocument();
});
it('applies definitions that arrive after the message was drawn', () => {
setEntry(TEAM, { status: 'loading' });
badge('urgent', TEAM);
expect(screen.getByText('My urgent')).toBeInTheDocument();
act(() => setEntry(TEAM, { status: 'ready', tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }));
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('My urgent')).not.toBeInTheDocument();
});
it('names each message by its own account in a mixed list', () => {
setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Team urgent', color: 'red' }] });
setEntry(OTHER_TEAM, { tags: [{ id: 'urgent', label: 'Sales urgent', color: 'green' }] });
render(
<>
<TagScopeContext.Provider value={TEAM}><TagBadge tagId="urgent" variant="badge" /></TagScopeContext.Provider>
<TagScopeContext.Provider value={OTHER_TEAM}><TagBadge tagId="urgent" variant="badge" /></TagScopeContext.Provider>
<TagBadge tagId="urgent" variant="badge" />
</>,
);
expect(screen.getByText('Team urgent')).toBeInTheDocument();
expect(screen.getByText('Sales urgent')).toBeInTheDocument();
expect(screen.getByText('My urgent')).toBeInTheDocument();
});
});
describe('the tag picker in a group account', () => {
it('offers the shared tags and every tag found on the messages, not the own ones', () => {
setEntry(TEAM, {
tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }],
discovered: { '$label:waiting-for-customer': 2 },
});
useSettingsStore.setState({ emailKeywords: [{ id: 'private', label: 'Private', color: 'blue' }] });
render(<TagPicker selectedIds={['only-on-this-one']} onToggle={() => {}} scope={TEAM} />);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.getByText('Waiting For Customer')).toBeInTheDocument();
// A keyword on the message is never hidden, named or not.
expect(screen.getByText('only-on-this-one')).toBeInTheDocument();
expect(screen.queryByText('Private')).not.toBeInTheDocument();
expect(screen.getByTestId('tag-picker-shared')).toHaveTextContent('picker_heading');
});
it('offers the own tags without a scope', () => {
setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] });
render(<TagPicker selectedIds={[]} onToggle={() => {}} scope={null} />);
expect(screen.getByText('My urgent')).toBeInTheDocument();
expect(screen.queryByTestId('tag-picker-shared')).not.toBeInTheDocument();
});
});
describe('tagScopeOf', () => {
beforeEach(() => {
const client = { getAccountId: () => 'me' };
useAuthStore.setState({
client: client as never,
activeAccountId: 'login-1',
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
});
});
it('names the group account of a message reached through a login', () => {
expect(tagScopeOf({ sourceClientAccountId: 'login-1', sourceAccountId: 'team' })).toEqual(TEAM);
});
it('is null for a message in the login\'s own account', () => {
expect(tagScopeOf({ sourceClientAccountId: 'login-1', sourceAccountId: 'me' })).toBeNull();
expect(tagScopeOf(null)).toBeNull();
});
});
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { createContext, useContext, useEffect, useMemo } from "react";
import type { Email } from "@/lib/jmap/types";
import { groupAccountTags } from "@/lib/shared-tags";
import { useAuthStore } from "@/stores/auth-store";
import { resolveEmailBlobContext, useEmailStore } from "@/stores/email-store";
import { useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
import {
loadSharedTags,
sharedTagsKey,
useSharedTagsStore,
type SharedTagsClient,
type SharedTagsEntry,
type SharedTagsStatus,
} from "@/stores/shared-tags-store";
/**
* Which account's tags apply: a JMAP account other than the login's own,
* reached through the login `loginId`. Null means the user's own tags.
*/
export interface TagScope {
loginId: string;
accountId: string;
}
/**
* The tag scope of whatever is drawn inside: a message row, the open
* message, a group account's sidebar section. Tag badges, pickers and
* names read it, so each message shows its own account's tags (the unified
* inbox mixes accounts).
*/
export const TagScopeContext = createContext<TagScope | null>(null);
/** The scope of a message: its own account when that isn't the login's. */
export function tagScopeOf(email: Pick<Email, "sourceClientAccountId" | "sourceAccountId"> | null | undefined): TagScope | null {
if (!email) return null;
try {
const auth = useAuthStore.getState();
const { client, accountId, clientAccountId } = resolveEmailBlobContext(email, auth.client);
if (!client || !accountId || accountId === client.getAccountId()) return null;
const loginId = clientAccountId ?? auth.activeAccountId;
return loginId ? { loginId, accountId } : null;
} catch {
// No session to tell (a stand-in store, a signed-out moment): own tags.
return null;
}
}
/**
* tagScopeOf for a message on screen, kept stable between renders and
* worked out again when the view changes (an unstamped message belongs to
* the account of the folder on screen).
*/
export function useEmailTagScope(email: Pick<Email, "sourceClientAccountId" | "sourceAccountId"> | null | undefined): TagScope | null {
useEmailStore((state) => state.selectedMailbox);
useEmailStore((state) => state.viewingAccountId);
useAuthStore((state) => state.activeAccountId);
const scope = tagScopeOf(email);
const loginId = scope?.loginId;
const accountId = scope?.accountId;
return useMemo(() => (loginId && accountId ? { loginId, accountId } : null), [loginId, accountId]);
}
/** The scope shared by every message given, or null when they differ. */
export function commonTagScope(emails: Array<Pick<Email, "sourceClientAccountId" | "sourceAccountId">>): TagScope | null {
const scopes = emails.map((email) => tagScopeOf(email));
const first = scopes[0] ?? null;
const same = scopes.every((scope) => scope?.loginId === first?.loginId && scope?.accountId === first?.accountId);
return same ? first : null;
}
export function clientForLogin(loginId: string): SharedTagsClient | null {
const auth = useAuthStore.getState();
return (auth.getClientForAccount?.(loginId) as SharedTagsClient | undefined)
?? (loginId === auth.activeAccountId ? (auth.client as SharedTagsClient | null) : null);
}
/** Starts loading a scope's shared tags (once per sign-in). */
export function ensureSharedTags(scope: TagScope): void {
const client = clientForLogin(scope.loginId);
if (client) void loadSharedTags(sharedTagsKey(scope.loginId, scope.accountId), scope.accountId, client);
}
const derived = new WeakMap<SharedTagsEntry, Map<boolean, KeywordDefinition[]>>();
/** The shared definitions plus the tags found on the messages, worked out once per entry. */
function groupDefinitions(entry: SharedTagsEntry, nested: boolean): KeywordDefinition[] {
let byNesting = derived.get(entry);
if (!byNesting) {
byNesting = new Map();
derived.set(entry, byNesting);
}
let definitions = byNesting.get(nested);
if (!definitions) {
definitions = groupAccountTags(entry.tags, entry.discovered, nested).definitions;
byNesting.set(nested, definitions);
}
return definitions;
}
export interface AccountTags {
/** The tags to offer and name things by in this scope. */
definitions: KeywordDefinition[];
/** True when they are the group account's shared tags. */
shared: boolean;
/** `personal` for the user's own account. */
status: SharedTagsStatus;
}
/**
* The tags of a scope: in a group account whose server keeps metadata, its
* shared definitions plus every tag found on its messages; everywhere else
* (own account, a mailbox a person shared, a server without metadata, while
* loading) the user's own tags. Definitions that arrive after the first
* render apply as soon as they are there.
*
* `scope` undefined reads TagScopeContext; null means the user's own tags.
*/
export function useAccountTags(scope?: TagScope | null): AccountTags {
const contextScope = useContext(TagScopeContext);
const effective = scope === undefined ? contextScope : scope;
const key = effective ? sharedTagsKey(effective.loginId, effective.accountId) : null;
const entry = useSharedTagsStore((state) => (key ? state.entries[key] : undefined));
const personal = useSettingsStore((state) => state.emailKeywords);
const nested = useSettingsStore((state) => state.nestedTags);
const loginId = effective?.loginId;
const accountId = effective?.accountId;
useEffect(() => {
if (loginId && accountId) ensureSharedTags({ loginId, accountId });
}, [loginId, accountId]);
return useMemo(() => {
if (!effective) return { definitions: personal, shared: false, status: "personal" };
if (entry?.status !== "ready") return { definitions: personal, shared: false, status: entry?.status ?? "loading" };
return { definitions: groupDefinitions(entry, nested), shared: true, status: "ready" };
// `effective` is fully described by the key.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, entry, personal, nested]);
}
+7 -2
View File
@@ -9,6 +9,7 @@ import {
} from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
import { suggestKeywordColor } from "@/lib/keyword-discovery";
import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
/**
* Names and colours tags for the screen, bound to the user's tag settings.
@@ -18,9 +19,13 @@ import { suggestKeywordColor } from "@/lib/keyword-discovery";
* someone who never asked for nesting. Subscribing to them also keeps tags in
* step the moment either changes: reading the store inside the formatter would
* leave every list stale until something else happened to re-render it.
*
* The definitions are those of the account in `scope` (useAccountTags): a
* group account's shared tags, otherwise the user's own. Without `scope`
* the TagScopeContext around the caller decides.
*/
export function useKeywordFormat() {
const keywords = useSettingsStore((state) => state.emailKeywords);
export function useKeywordFormat(scope?: TagScope | null) {
const keywords = useAccountTags(scope).definitions;
const nested = useSettingsStore((state) => state.nestedTags);
return useMemo(
+370
View File
@@ -0,0 +1,370 @@
import { test, expect, type Page } from '@playwright/test';
import { JMAP_URL } from './helpers/config';
import { expandSharedFolders, emailItem, folderRow } from './helpers/app';
import {
ORG,
ORG_PASSWORD,
ORG_SERVER,
adminContext,
getPolicy,
putPolicy,
requireOrgEnv,
shot,
userPage,
} from './helpers/org';
/**
* Shared tags in group mailboxes (organisation feature 6), through the real
* UI. On the v1.0.0 release (label 1.0.0) the definitions live in the
* metadata of org-team's Inbox: org-alice defines "Urgent" in red and tags a
* message, org-bob sees the same name and colour, and a group scope that
* locks `sharedTags` makes the editor read-only. On servers without working
* metadata (0.16, the 1.0 test build) there is no shared editor, the tags on
* group messages still show, and the app stays online.
*
* Cleanup: the test message (and with it its tags) is destroyed, the
* `org.bulwarkmail.tags` metadata entry is set back to null, the policy is
* put back. Other `org.bulwarkmail.*` entries (templates) are never touched.
*/
test.describe.configure({ mode: 'serial' });
const SUBJECT = `Shared tags ${Date.now()}`;
const OWN_SUBJECT = `Own tags ${Date.now()}`;
const HAS_METADATA = ORG_SERVER === '1.0.0';
// --- JMAP, as org-alice, straight to the server --------------------------
type Call = [string, Record<string, unknown>, string];
let api: { url: string; team: string; inbox: string; own: string; ownInbox: string } | null = null;
let messageId: string | null = null;
/** A message in org-alice's own Inbox with the same keyword, for the unified inbox. */
let ownMessageId: string | null = null;
const auth = () => `Basic ${Buffer.from(`${ORG.alice}:${ORG_PASSWORD}`).toString('base64')}`;
/** The API url, org-team's account id and its Inbox id, looked up once. */
async function ensureApi(): Promise<NonNullable<typeof api>> {
if (!api) {
const session = await (await fetch(`${JMAP_URL}/.well-known/jmap`, { headers: { Authorization: auth() } })).json();
const team = Object.entries(session.accounts as Record<string, { name: string }>).find(([, a]) => a.name === ORG.team)![0];
const own = session.primaryAccounts['urn:ietf:params:jmap:mail'] as string;
api = { url: `${JMAP_URL}${new URL(session.apiUrl).pathname}`, team, inbox: '', own, ownInbox: '' };
const res = await jmapRaw([
['Mailbox/query', { accountId: team, filter: { role: 'inbox' } }, 'q'],
['Mailbox/query', { accountId: own, filter: { role: 'inbox' } }, 'o'],
]);
api.inbox = res[0][1].ids[0];
api.ownInbox = res[1][1].ids[0];
}
return api;
}
async function jmap(calls: Call[], using?: string[]) {
await ensureApi();
return jmapRaw(calls, using);
}
async function jmapRaw(calls: Call[], using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']) {
const res = await fetch(api!.url, {
method: 'POST',
headers: { Authorization: auth(), 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls: calls }),
});
expect(res.ok, `JMAP ${res.status}`).toBe(true);
return (await res.json()).methodResponses as Array<[string, any, string]>;
}
const META_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata'];
async function sharedTagsOnServer(): Promise<unknown> {
const res = await jmap([['Mailbox/get', { accountId: api!.team, ids: [api!.inbox], properties: ['metadata'] }, 'g']], META_USING);
return res[0][1].list[0].metadata?.['org.bulwarkmail.tags'] ?? null;
}
async function clearSharedTags(): Promise<void> {
await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags': null } } }, 's']], META_USING);
}
async function messageKeywords(): Promise<Record<string, boolean>> {
const res = await jmap([['Email/get', { accountId: api!.team, ids: [messageId], properties: ['keywords'] }, 'g']]);
return res[0][1].list[0].keywords;
}
// --- UI helpers ----------------------------------------------------------
async function openTeamInbox(page: Page): Promise<void> {
await expandSharedFolders(page, ORG.team);
await folderRow(page, { role: 'inbox', shared: true }).first().click();
await expect(emailItem(page, SUBJECT).first()).toBeVisible({ timeout: 30000 });
}
/** Settings > Folders & tags, scrolled to the shared tags of org-team. */
async function openSharedTagSettings(page: Page, phone = false) {
if (phone) {
await page.goto('/settings');
await page.getByRole('button', { name: 'Folders & tags' }).or(page.getByRole('link', { name: 'Folders & tags' })).first().click();
} else {
await page.goto('/settings/organize/shared-tags');
}
// The block is drawn again once the tags have loaded.
const account = page.locator('[data-testid="shared-tags-account"]:not([data-status="loading"])').filter({ hasText: ORG.team });
await expect(account).toBeVisible({ timeout: 30000 });
await account.scrollIntoViewIfNeeded();
return account;
}
let original: Record<string, unknown>;
test.beforeAll(async ({ browser }) => {
requireOrgEnv();
const admin = await adminContext(browser);
original = await getPolicy(admin);
await putPolicy(admin, { ...original, restrictions: {}, scopes: [] });
await admin.close();
// A message in org-team's Inbox; on servers without metadata it carries a
// tag set by another client, which must still show.
const keywords = HAS_METADATA ? {} : { '$label:from-another-client': true };
const { team, inbox, own, ownInbox } = await ensureApi();
const message = (mailbox: string, to: string, subject: string, kw: Record<string, boolean>) => ({
mailboxIds: { [mailbox]: true },
from: [{ name: 'Org Alice', email: ORG.alice }],
to: [{ email: to }],
subject,
keywords: kw,
bodyValues: { b: { value: 'A message for the shared tags spec.' } },
textBody: [{ partId: 'b', type: 'text/plain' }],
});
const res = await jmap([['Email/set', { accountId: team, create: { m: message(inbox, ORG.team, SUBJECT, keywords) } }, 'c']]);
messageId = res[0][1].created.m.id;
if (HAS_METADATA) {
const mine = await jmap([['Email/set', { accountId: own, create: { m: message(ownInbox, ORG.alice, OWN_SUBJECT, { '$label:urgent': true }) } }, 'c']]);
ownMessageId = mine[0][1].created.m.id;
}
if (HAS_METADATA) await clearSharedTags();
});
test.afterAll(async ({ browser }) => {
if (messageId) await jmap([['Email/set', { accountId: api!.team, destroy: [messageId] }, 'd']]);
if (ownMessageId) await jmap([['Email/set', { accountId: api!.own, destroy: [ownMessageId] }, 'd']]);
if (HAS_METADATA && api) {
await clearSharedTags();
expect(await sharedTagsOnServer()).toBeNull();
}
const admin = await adminContext(browser);
await putPolicy(admin, original);
await admin.close();
});
test.describe('on a server with metadata', () => {
test.skip(!HAS_METADATA, 'shared definitions need Stalwart v1.0.0 metadata');
test('org-alice defines "Urgent" in red for org-team and tags a message with it', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
const account = await openSharedTagSettings(alice);
await account.getByRole('button', { name: 'Add shared tag' }).click();
const form = account.getByTestId('shared-tag-form');
await form.getByLabel('Name').fill('Urgent');
await form.getByRole('button', { name: 'red', exact: true }).click();
await form.getByRole('button', { name: 'Save' }).click();
await expect(account.locator('[data-testid="shared-tag-row"][data-tag-id="urgent"]')).toBeVisible();
await shot(alice, 'tags-settings-editor');
await expect.poll(sharedTagsOnServer).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } });
await alice.goto('/');
await openTeamInbox(alice);
// The group's tags are listed under its folders.
await expect(alice.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible();
const row = emailItem(alice, SUBJECT).first();
await expect(async () => {
await row.click({ button: 'right' });
await alice.getByTestId('ctx-tag').waitFor({ state: 'visible', timeout: 2000 });
}).toPass({ timeout: 15000 });
await alice.getByTestId('ctx-tag').hover();
await expect(alice.getByTestId('tag-picker-shared')).toBeVisible();
await shot(alice, 'tags-picker');
await alice.getByRole('menuitemcheckbox', { name: 'Urgent' }).click();
await expect.poll(async () => (await messageKeywords())['$label:urgent']).toBe(true);
await alice.keyboard.press('Escape');
await expect(row.getByTitle('Urgent')).toBeVisible();
// The group's tag in the sidebar counts and lists that group's mail
// only: her own message with the same keyword stays out.
const sharedRow = alice.getByTestId('shared-account-tags').getByTestId('folder-row').filter({ hasText: 'Urgent' });
await expect(sharedRow.getByTestId('folder-counts')).toHaveAttribute('data-total', '1', { timeout: 30000 });
await sharedRow.click();
await expect(emailItem(alice, SUBJECT).first()).toBeVisible({ timeout: 30000 });
await expect(emailItem(alice, OWN_SUBJECT)).toHaveCount(0);
await expect(sharedRow).toHaveAttribute('data-selected', 'true');
await shot(alice, 'tags-sidebar-group-view');
await alice.context().close();
});
test('org-bob sees the same name and colour on that message', async ({ browser }) => {
const bob = await userPage(browser, ORG.bob);
await openTeamInbox(bob);
const chip = emailItem(bob, SUBJECT).first().getByTitle('Urgent');
await expect(chip).toBeVisible();
await expect(chip).toHaveClass(/bg-red-500\/10/);
await expect(bob.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible();
await shot(bob, 'tags-member-view');
// Another member's edit arrives without a reload (push: the Inbox's
// Mailbox state changes with its metadata).
await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Very urgent', color: 'orange' } } } }, 's']], META_USING);
const renamed = emailItem(bob, SUBJECT).first().getByTitle('Very urgent');
await expect(renamed).toBeVisible({ timeout: 30000 });
await expect(renamed).toHaveClass(/bg-orange-500\/10/);
await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Urgent', color: 'red' } } } }, 's']], META_USING);
await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 });
await bob.context().close();
});
test('the unified inbox names the tags of each message by its own account', async ({ browser }) => {
// Unified inbox with the group inbox in it, pinned by the policy so a
// synced setting of the account can't turn it off.
const admin = await adminContext(browser);
const policy = await getPolicy(admin);
await putPolicy(admin, {
...policy,
restrictions: {
...(policy.restrictions as Record<string, unknown>),
enableUnifiedMailbox: { locked: true, value: true },
includeGroupInUnified: { locked: true, value: true },
},
});
const alice = await userPage(browser, ORG.alice);
await folderRow(alice, { name: 'unified-inbox' }).first().click({ timeout: 60000 });
// The same keyword: the group's shared name on the team message, the
// bare keyword on her own (she has no tag of that name).
await expect(emailItem(alice, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 });
await expect(emailItem(alice, OWN_SUBJECT).first().getByTitle('urgent', { exact: true })).toBeVisible();
await shot(alice, 'tags-unified-inbox');
await alice.context().close();
await putPolicy(admin, policy);
await admin.close();
});
test('deleting a shared tag warns that it is for everyone, at phone width too', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice, true);
const account = await openSharedTagSettings(alice, true);
await account.getByRole('button', { name: 'Delete Urgent' }).click();
const confirm = account.getByTestId('shared-tag-delete-confirm');
await expect(confirm.getByText('Delete for everyone? The tag stays on its messages.')).toBeVisible();
for (const name of ['Delete', 'Cancel']) {
const box = await confirm.getByRole('button', { name, exact: true }).boundingBox();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
await shot(alice, 'tags-delete-confirm-phone');
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(account.getByRole('button', { name: 'Edit Urgent' })).toBeFocused();
expect(await sharedTagsOnServer()).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } });
await alice.context().close();
});
test('a lock applies only to the scope that sets it, read-only with the reason in place', async ({ browser }) => {
const admin = await adminContext(browser);
let policy = await getPolicy(admin);
await putPolicy(admin, {
...policy,
scopes: [
{ id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { restrictions: { sharedTags: { locked: true } } } },
{ id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
],
});
// A domain scope for another domain leaves org-team's members alone.
const alice = await userPage(browser, ORG.alice);
const aliceTags = await openSharedTagSettings(alice);
await expect(aliceTags.getByRole('button', { name: 'Add shared tag' })).toBeVisible();
await alice.context().close();
// The admin locks shared tags for the org-team group on Organisation > Sharing.
const page = await admin.newPage();
await page.goto('/admin/org-sharing');
await expect(page.getByRole('heading', { name: 'Sharing' }).first()).toBeVisible();
await page.getByRole('button', { name: 'Org team' }).click();
await page.waitForLoadState('networkidle');
const section = page.getByTestId('org-shared-tags-section');
await section.getByLabel('Lock').check();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
await section.scrollIntoViewIfNeeded();
await shot(page, 'tags-admin-lock');
policy = await getPolicy(admin);
const team = (policy.scopes as Array<{ id: string; policy: Record<string, any> }>).find((s) => s.id === 'org-team')!;
expect(team.policy.restrictions.sharedTags).toEqual({ locked: true });
expect((policy.restrictions as Record<string, unknown>).sharedTags).toBeUndefined();
await admin.close();
const bob = await userPage(browser, ORG.bob);
const bobTags = await openSharedTagSettings(bob);
await expect(bobTags.getByText('Set by your organisation')).toBeVisible();
await expect(bobTags.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0);
await expect(bobTags.getByRole('button', { name: /^(Edit|Delete|Name) / })).toHaveCount(0);
await expect(bobTags.locator('[data-tag-id="urgent"]')).toBeVisible();
await shot(bob, 'tags-settings-locked');
// Tagging with the shared tags still works.
await bob.goto('/');
await openTeamInbox(bob);
await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible();
await bob.context().close();
});
test('the shared tags work at phone width', async ({ browser }) => {
const bob = await userPage(browser, ORG.bob, true);
const account = await openSharedTagSettings(bob, true);
await expect(account.getByText('Set by your organisation')).toBeVisible();
await expect(account.locator('[data-tag-id="urgent"]')).toBeVisible();
const overflow = await bob.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
await shot(bob, 'tags-settings-phone');
await bob.context().close();
const admin = await adminContext(browser, true);
const page = await admin.newPage();
await page.goto('/admin/org-sharing');
await expect(page.getByTestId('org-shared-tags-section')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await page.getByTestId('org-shared-tags-section').scrollIntoViewIfNeeded();
await shot(page, 'tags-admin-phone');
await admin.close();
});
});
test.describe('on a server without metadata', () => {
test.skip(HAS_METADATA, 'covered above');
test('no shared editor, the tags on group messages still show, and the app stays online', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
const requests: string[] = [];
alice.on('request', (request) => {
const body = request.postData() ?? '';
if (body.includes('urn:ietf:params:jmap:metadata')) requests.push(body);
});
await openTeamInbox(alice);
await expect(emailItem(alice, SUBJECT).first().getByTitle('from-another-client')).toBeVisible();
await expect(alice.getByTestId('shared-account-tags')).toHaveCount(0);
const account = await openSharedTagSettings(alice);
// 0.16 has no metadata; the 1.0 test build has it but drops the request.
await expect(account.getByTestId(ORG_SERVER === '1.0' ? 'shared-tags-unavailable' : 'shared-tags-unsupported')).toBeVisible();
await expect(account.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0);
await shot(alice, 'tags-settings-unsupported');
// The 1.0 test build drops the connection on Mailbox/get with metadata;
// that must not leave the app reconnecting. Had the failed read reported
// the login offline (onConnectionChange), the banner would stay until the
// next good keep-alive ping, 30 s later. 0.16 gets no such request.
await alice.goto('/');
await openTeamInbox(alice);
await alice.waitForTimeout(3000);
await expect(alice.getByText('Connection lost. Attempting to reconnect')).toHaveCount(0);
if (ORG_SERVER === '0.16') expect(requests).toEqual([]);
// The 1.0 test build advertises metadata, so it was asked (and dropped it).
if (ORG_SERVER === '1.0') expect(requests.length).toBeGreaterThan(0);
await alice.context().close();
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import {
EMPTY_SHARED_TAGS,
groupAccountTags,
parseSharedTags,
planSharedTagsSave,
readSharedTags,
sharedDefinitions,
} from '../shared-tags';
import { MetadataTooLargeError } from '../jmap/bulwark-metadata';
// Shared tag definitions of a group account, as they sit in the metadata of
// its Inbox: a map by tag id under `tags`, changed one tag at a time.
const MAP = {
tags: {
urgent: { label: 'Urgent', color: 'red' },
other: { label: 'Other', color: 'chartreuse', icon: 'flame' },
},
note: 'kept',
};
describe('readSharedTags', () => {
it('reads the map by id and keeps fields it doesn\'t know', () => {
const stored = readSharedTags(MAP);
expect(stored.patchable).toBe(true);
expect(stored.entries.other).toEqual({ label: 'Other', color: 'chartreuse', icon: 'flame' });
expect(stored.extra).toEqual({ note: 'kept' });
});
it('reads the earlier list and bare-array shapes, for converting', () => {
const fromList = readSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }, { id: 'URGENT', label: 'Twice' }] });
expect(fromList).toEqual({ entries: { urgent: { label: 'Urgent', color: 'red' } }, extra: {}, patchable: false });
expect(readSharedTags([{ id: 'later', label: 'Later', color: 'blue' }]).entries).toEqual({ later: { label: 'Later', color: 'blue' } });
});
it('drops what cannot be a tag', () => {
const stored = readSharedTags({ tags: { 'has space': { label: 'x' }, 'ctl\u0001': { label: 'x' }, fine: 'not an object', ok: {} } });
expect(Object.keys(stored.entries)).toEqual(['ok']);
expect(readSharedTags(undefined)).toEqual(EMPTY_SHARED_TAGS);
});
});
describe('sharedDefinitions', () => {
it('names each tag, shows a color it doesn\'t know as grey, and sorts by name', () => {
expect(sharedDefinitions(readSharedTags(MAP))).toEqual([
{ id: 'other', label: 'Other', color: 'gray' },
{ id: 'urgent', label: 'Urgent', color: 'red' },
]);
expect(parseSharedTags({ tags: { x: { color: 'blue' } } })).toEqual([{ id: 'x', label: 'x', color: 'blue' }]);
});
});
describe('planSharedTagsSave', () => {
it('patches only the tags that change', () => {
const { patches, result } = planSharedTagsSave(readSharedTags(MAP), [
{ id: 'new/child', value: { label: 'Child', color: 'blue' } },
{ id: 'URGENT', value: null },
]);
expect(patches).toEqual([
{ path: ['tags', 'new/child'], value: { label: 'Child', color: 'blue' } },
{ path: ['tags', 'urgent'], value: null },
]);
expect(Object.keys(result.entries).sort()).toEqual(['new/child', 'other']);
});
it('keeps the fields of a changed tag it doesn\'t know, and its color unless one was picked', () => {
const { patches } = planSharedTagsSave(readSharedTags(MAP), [{ id: 'other', value: { label: 'Renamed' } }]);
expect(patches).toEqual([{ path: ['tags', 'other'], value: { label: 'Renamed', color: 'chartreuse', icon: 'flame' } }]);
});
it('writes an earlier shape once, whole, as the map', () => {
const { patches, result } = planSharedTagsSave(
readSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }),
[{ id: 'later', value: { label: 'Later', color: 'blue' } }],
);
expect(patches).toEqual([{ path: [], value: { tags: { urgent: { label: 'Urgent', color: 'red' }, later: { label: 'Later', color: 'blue' } } } }]);
expect(result.patchable).toBe(true);
});
it('refuses more than 8 KB of UTF-8, counted in bytes not characters', () => {
// 100 names of 30 two-byte characters: about 4.9 K characters, over 8 K bytes.
const changes = Array.from({ length: 100 }, (_, i) => ({ id: `t${i}`, value: { label: 'ä'.repeat(30), color: 'red' } }));
const json = JSON.stringify({ tags: Object.fromEntries(changes.map((c) => [c.id, c.value])) });
expect(json.length).toBeLessThan(8192);
expect(() => planSharedTagsSave(EMPTY_SHARED_TAGS, changes)).toThrow(MetadataTooLargeError);
expect(() => planSharedTagsSave(EMPTY_SHARED_TAGS, changes.slice(0, 50))).not.toThrow();
});
});
describe('groupAccountTags', () => {
it('lists the shared definitions, then every tag on the messages nobody named', () => {
const { definitions, discoveredIds } = groupAccountTags(
[{ id: 'urgent', label: 'Urgent', color: 'red' }],
{ '$label:urgent': 4, '$label:q3-invoices': 2 },
false,
);
expect(definitions.map((t) => t.id)).toEqual(['urgent', 'q3-invoices']);
expect(definitions[1].label).toBe('Q3 Invoices');
expect(discoveredIds).toEqual(['q3-invoices']);
});
it('names a tag by its shared definition whatever case the message uses', () => {
const { definitions } = groupAccountTags([{ id: 'urgent', label: 'Urgent', color: 'red' }], { '$label:Urgent': 1 }, false);
expect(definitions).toHaveLength(1);
});
});
+9
View File
@@ -69,6 +69,15 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
page: 'org-templates',
modes: ['locked'],
},
{
key: 'sharedTags',
label: 'Shared tags',
description: "Lock: users can tag mail with the shared tags of their group mailboxes but can't add, rename or delete them. Unlock it in a group scope for the people who look after the tags.",
category: 'Sharing',
type: 'boolean',
page: 'org-sharing',
modes: ['locked'],
},
{ key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
{ key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] },
];
+17
View File
@@ -46,6 +46,23 @@ export class MetadataUnsupportedError extends Error {
}
}
/** A patch inside an entry whose parent value doesn't exist (yet, or any more). */
export class MetadataPatchError extends Error {
constructor(message: string) {
super(message);
this.name = 'MetadataPatchError';
}
}
/**
* One segment of a patch path inside a metadata entry, escaped the JSON
* Pointer way (`~` as `~0`, `/` as `~1`), which Stalwart v1.0.0 decodes:
* a tag id `work/clients` is the segment `work~1clients`.
*/
export function escapeMetadataPathSegment(segment: string): string {
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
}
/** `tags`, `template.<id>`: lowercase, digits, dashes, dot-separated. */
export function isValidMetadataKey(key: string): boolean {
return key.length <= 120 && KEY_RE.test(key);
+4
View File
@@ -297,6 +297,8 @@ export interface IJMAPClient {
limit?: number;
onProgress?: (scanned: number, total: number) => void;
signal?: AbortSignal;
/** Another account of this login, e.g. a group account (default: the own one). */
accountId?: string;
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }>;
/** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */
getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, accountId?: string): Promise<Record<string, number>>;
@@ -622,6 +624,8 @@ export interface IJMAPClient {
supportsBulwarkMetadata?(accountId?: string): boolean;
getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise<Record<string, unknown> | null>;
setBulwarkMetadata?(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise<void>;
advertisesBulwarkMetadata?(accountId?: string): boolean;
patchBulwarkMetadata?(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }>;
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
+44 -4
View File
@@ -1,4 +1,4 @@
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
import { ArchiveEmailsError } from "./archive-error";
import { generateUUID } from '@/lib/utils';
@@ -2673,7 +2673,10 @@ export class JMAPClient implements IJMAPClient {
limit?: number;
onProgress?: (scanned: number, total: number) => void;
signal?: AbortSignal;
/** Another account of this login, e.g. a group account (default: the own one). */
accountId?: string;
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }> {
const accountId = options?.accountId || this.accountId;
const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT);
const pageSize = Math.max(1, Math.min(500, this.getMaxObjectsInGet()));
const keywords: Record<string, number> = {};
@@ -2687,14 +2690,14 @@ export class JMAPClient implements IJMAPClient {
try {
const response = await this.request([
["Email/query", {
accountId: this.accountId,
accountId,
sort: [{ property: "receivedAt", isAscending: false }],
limit: Math.min(pageSize, cap - scanned),
position: scanned,
calculateTotal: scanned === 0,
}, "0"],
["Email/get", {
accountId: this.accountId,
accountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: ["keywords"],
}, "1"],
@@ -7160,6 +7163,40 @@ export class JMAPClient implements IJMAPClient {
if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`);
}
await this.updateInboxMetadata(id, { [`${property}/${namespaceFor(key)}`]: value }, options);
}
/**
* Whether the account advertises vendor metadata on mailboxes, whatever
* its last request did (supportsBulwarkMetadata also pauses after a
* failure). False means the server keeps no metadata for this account.
*/
advertisesBulwarkMetadata(accountId?: string): boolean {
const id = accountId || this.accountId;
return advertisesMailboxMetadata(this.accounts[id]?.accountCapabilities as Record<string, unknown> | undefined);
}
/**
* Changes values inside one Bulwark metadata entry of the account's Inbox
* and nothing else: each patch path (segments, escaped here) is set, null
* removes it, an empty path is the whole entry. Returns the Mailbox state
* after the change, so a caller can recognise the push of its own write.
* Throws MetadataTooLargeError (the server's size limit),
* MetadataPatchError (a parent of a path doesn't exist) and
* MetadataUnsupportedError.
*/
async patchBulwarkMetadata(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }> {
const id = accountId || this.accountId;
if (!this.supportsBulwarkMetadata(id)) throw new MetadataUnsupportedError();
const update: Record<string, unknown> = {};
for (const { path, value } of patches) {
update[[`metadata/${namespaceFor(key)}`, ...path.map(escapeMetadataPathSegment)].join("/")] = value;
}
return { newState: await this.updateInboxMetadata(id, update) };
}
/** One Mailbox/set of the Inbox's metadata; answers the new Mailbox state. */
private async updateInboxMetadata(id: string, update: Record<string, unknown>, options?: { private?: boolean }): Promise<string | undefined> {
let inboxId = this.metadataInboxIds.get(id);
if (!inboxId) {
await this.getBulwarkMetadata(id, options);
@@ -7167,7 +7204,7 @@ export class JMAPClient implements IJMAPClient {
if (!inboxId) throw new MetadataUnsupportedError();
}
const response = await this.request([
["Mailbox/set", { accountId: id, update: { [inboxId]: { [`${property}/${namespaceFor(key)}`]: value } } }, "0"],
["Mailbox/set", { accountId: id, update: { [inboxId]: update } }, "0"],
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]);
const [method, result] = response.methodResponses?.[0] ?? [];
if (method !== "Mailbox/set") {
@@ -7176,8 +7213,11 @@ export class JMAPClient implements IJMAPClient {
const notUpdated = (result as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
if (notUpdated) {
if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!);
if (notUpdated.type === "invalidPatch") throw new MetadataPatchError(notUpdated.description || "A parent of the patched value does not exist");
throw new MetadataRefusedError(notUpdated.type ?? "unknown", notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
}
const newState = (result as { newState?: unknown }).newState;
return typeof newState === "string" ? newState : undefined;
}
/**
+2
View File
@@ -268,6 +268,8 @@ export interface MailboxRights {
mayDelete: boolean;
maySubmit: boolean;
mayShare?: boolean;
/** Stalwart 1.0: may change the mailbox's shared metadata (urn:ietf:params:jmap:metadata). */
maySetMetadata?: boolean;
}
export interface Thread {
+195
View File
@@ -0,0 +1,195 @@
/**
* Shared tags of a group account: the tag names and colors every member of
* a shared mailbox sees, kept in the metadata of the group's Inbox
* (namespace `org.bulwarkmail.tags`, lib/jmap/bulwark-metadata.ts).
*
* Keywords in a group account are the same for every member
* (local-data/org-features-facts.md §1), so `$label:urgent` on a team
* message is the team's tag, not one member's. A personal definition only
* names it for the member who made it; the shared definitions name it for
* everyone.
*
* Stored as a map by tag id, `{ "tags": { "<id>": { "label", "color", ... } } }`,
* so a change patches one tag (`metadata/org.bulwarkmail.tags/tags/<id>`)
* and two members adding different tags at the same time both keep theirs.
* Fields this version doesn't know are kept. Earlier shapes (`{ "list": [...] }`
* or a bare array) are read, and converted once by the first save.
*
* Pure functions only.
*/
import {
FALLBACK_KEYWORD_COLOR,
KEYWORD_PALETTE,
type KeywordDefinition,
type KeywordVisibility,
} from '@/stores/settings-store';
import { findUnrecognizedKeywords } from './keyword-discovery';
import { MAX_KEYWORD_ID_LENGTH } from './keyword-nesting';
import { MAX_METADATA_ENTRY_BYTES, MetadataTooLargeError, metadataValueBytes } from './jmap/bulwark-metadata';
/** The metadata key (namespace `org.bulwarkmail.tags`). */
export const SHARED_TAGS_KEY = 'tags';
/** Longest name a shared tag keeps, as in the personal tag editor. */
export const MAX_SHARED_TAG_LABEL = 30;
const VISIBILITIES: readonly KeywordVisibility[] = ['show', 'hide', 'unread'];
/**
* Characters RFC 8621 keywords can't hold (and whitespace), so a tag id
* from the server that could never be on a message is dropped.
*/
const BAD_ID_CHARS = /[\s(){\]%*"\\]/;
/** Whether a tag id from the server can be a keyword (no control characters either). */
function isKeywordSafe(id: string): boolean {
if (!id || id.length > MAX_KEYWORD_ID_LENGTH || BAD_ID_CHARS.test(id)) return false;
for (let i = 0; i < id.length; i++) {
const code = id.charCodeAt(i);
if (code < 0x20 || code === 0x7f) return false;
}
return true;
}
const isObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
/** What the server holds, as read. */
export interface StoredSharedTags {
/** The raw entry of each tag by id, unknown fields included. */
entries: Record<string, Record<string, unknown>>;
/** Other values of the namespace next to `tags`, kept as they are. */
extra: Record<string, unknown>;
/** The map shape is on the server, so changes can patch one tag. */
patchable: boolean;
}
export const EMPTY_SHARED_TAGS: StoredSharedTags = { entries: {}, extra: {}, patchable: false };
/**
* Reads the namespace value: the map shape, or the earlier `{ list }` and
* bare-array shapes (converted to entries; `patchable` stays false so the
* first save writes the map shape).
*/
export function readSharedTags(value: unknown): StoredSharedTags {
if (isObject(value) && isObject(value.tags)) {
const entries: Record<string, Record<string, unknown>> = {};
for (const [id, entry] of Object.entries(value.tags)) {
if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry;
}
const { tags: _tags, ...extra } = value;
void _tags;
return { entries, extra, patchable: true };
}
const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : [];
const entries: Record<string, Record<string, unknown>> = {};
const seen = new Set<string>();
for (const item of items) {
if (!isObject(item) || typeof item.id !== 'string') continue;
const id = item.id.trim();
if (!isKeywordSafe(id) || seen.has(id.toLowerCase())) continue;
seen.add(id.toLowerCase());
const { id: _id, ...rest } = item;
void _id;
entries[id] = rest;
}
return { entries, extra: {}, patchable: false };
}
/** One tag as the app shows it; an unknown color shows grey but stays stored. */
function definitionOf(id: string, entry: Record<string, unknown>): KeywordDefinition {
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
const color = typeof entry.color === 'string' && KEYWORD_PALETTE[entry.color] ? entry.color : FALLBACK_KEYWORD_COLOR;
const definition: KeywordDefinition = { id, label: label || id, color };
if (typeof entry.visibility === 'string' && (VISIBILITIES as readonly string[]).includes(entry.visibility)) {
definition.visibility = entry.visibility as KeywordVisibility;
}
return definition;
}
/** The shared definitions, by name (a map has no order of its own). */
export function sharedDefinitions(stored: StoredSharedTags): KeywordDefinition[] {
return Object.entries(stored.entries)
.map(([id, entry]) => definitionOf(id, entry))
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
}
/** The shared definitions in a metadata value (any shape). */
export function parseSharedTags(value: unknown): KeywordDefinition[] {
return sharedDefinitions(readSharedTags(value));
}
/**
* One change: a tag's new name and, when the member picked one, its color;
* null deletes the tag. Other fields of the entry are kept.
*/
export interface SharedTagChange {
id: string;
value: { label: string; color?: string } | null;
}
/** The id under which `id` is stored, in any case, or `id` itself. */
function storedId(entries: Record<string, unknown>, id: string): string {
const folded = id.toLowerCase();
return Object.keys(entries).find((key) => key.toLowerCase() === folded) ?? id;
}
/** The stored tags with the changes applied, nothing else touched. */
export function applySharedTagChanges(stored: StoredSharedTags, changes: SharedTagChange[]): StoredSharedTags {
const entries = { ...stored.entries };
for (const change of changes) {
const id = storedId(entries, change.id);
if (change.value === null) {
delete entries[id];
continue;
}
const { label, color } = change.value;
entries[id] = { ...(entries[id] ?? {}), label, ...(color !== undefined ? { color } : {}) };
}
return { ...stored, entries };
}
/**
* The Mailbox/set patches for the changes, and what the server holds after
* them. On the map shape each changed tag is one patch and no other tag is
* written; on an older shape (or none) the whole namespace is written once
* in the map shape. Throws MetadataTooLargeError when the result would take
* more than the 8 KB of one namespace (UTF-8 bytes of its JSON).
*/
export function planSharedTagsSave(
stored: StoredSharedTags,
changes: SharedTagChange[],
): { patches: Array<{ path: string[]; value: unknown }>; result: StoredSharedTags } {
const next = applySharedTagChanges(stored, changes);
const namespace = { ...next.extra, tags: next.entries };
if (metadataValueBytes(namespace) > MAX_METADATA_ENTRY_BYTES) {
throw new MetadataTooLargeError(`The shared tags take more than ${MAX_METADATA_ENTRY_BYTES} bytes`);
}
if (!stored.patchable) {
return { patches: [{ path: [], value: namespace }], result: { ...next, patchable: true } };
}
const ids = [...new Set(changes.map((change) => storedId(stored.entries, change.id)))];
return {
patches: ids.map((id) => ({ path: ['tags', id], value: next.entries[id] ?? null })),
result: next,
};
}
/**
* What a group account shows as its tags: the shared definitions, then
* every tag found on its messages that no definition names, with the name
* and color keyword discovery proposes for it. A keyword that is on a
* message is never hidden because nobody defined it.
*/
export function groupAccountTags(
shared: KeywordDefinition[],
discovered: Record<string, number>,
nested: boolean,
): { definitions: KeywordDefinition[]; discoveredIds: string[] } {
const found = findUnrecognizedKeywords(discovered, shared, nested);
return {
definitions: [...shared, ...found.map(({ id, label, color }) => ({ id, label, color }))],
discoveredIds: found.map((entry) => entry.id),
};
}
+28
View File
@@ -326,6 +326,34 @@
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
},
"shared_tags": {
"title": "Geteilte Labels",
"description": "Labels Ihrer Gruppenpostfächer. Alle in der Gruppe sehen dieselben Namen und Farben an ihren Nachrichten.",
"picker_heading": "Mit allen in diesem Postfach geteilt",
"sidebar_heading": "Labels",
"edit": "Geteilte Labels bearbeiten",
"edit_tag": "{name} bearbeiten",
"delete_tag": "{name} löschen",
"name_it": "{name} benennen",
"add": "Geteiltes Label hinzufügen",
"name": "Name",
"color": "Farbe",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"delete_confirm": "Für alle löschen? Das Label bleibt an seinen Nachrichten.",
"found_on_messages": "An Nachrichten, noch ohne Namen",
"empty": "Noch keine geteilten Labels.",
"exists": "Dieses Postfach hat dieses Label schon.",
"too_long": "Dieser Name ist zu lang.",
"loading": "Wird geladen…",
"unsupported": "Dieser Mailserver kann keine geteilten Labels speichern, daher zeigt dieses Postfach Ihre eigenen Labels.",
"unavailable": "Die geteilten Labels dieses Postfachs konnten nicht geladen werden, daher zeigt es Ihre eigenen Labels. Bulwark versucht es später erneut.",
"no_rights": "Sie können diese Labels verwenden, aber in diesem Postfach nicht ändern.",
"too_many": "Es gibt zu viele geteilte Labels. Löschen Sie einige, bevor Sie weitere hinzufügen.",
"save_failed": "Die geteilten Labels konnten nicht gespeichert werden. Bulwark versucht es erneut, sobald der Server antwortet.",
"save_pending": "Noch nicht gespeichert. Bulwark versucht es erneut, sobald der Server antwortet."
},
"email_viewer": {
"read_receipt": {
"prompt": "Der Absender bittet um eine Lesebestätigung:",
+28
View File
@@ -326,6 +326,34 @@
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
},
"shared_tags": {
"title": "Shared tags",
"description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.",
"picker_heading": "Shared with everyone in this mailbox",
"sidebar_heading": "Tags",
"edit": "Edit shared tags",
"edit_tag": "Edit {name}",
"delete_tag": "Delete {name}",
"name_it": "Name {name}",
"add": "Add shared tag",
"name": "Name",
"color": "Color",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"delete_confirm": "Delete for everyone? The tag stays on its messages.",
"found_on_messages": "On messages, no name yet",
"empty": "No shared tags yet.",
"exists": "This mailbox already has this tag.",
"too_long": "This name is too long.",
"loading": "Loading…",
"unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags.",
"unavailable": "Couldn't load the shared tags of this mailbox, so it shows your own tags. Bulwark tries again later.",
"no_rights": "You can use these tags, but you can't change them in this mailbox.",
"too_many": "There are too many shared tags. Delete some before adding more.",
"save_failed": "Couldn't save the shared tags. Bulwark tries again when the server answers.",
"save_pending": "Not saved yet. Bulwark tries again when the server answers."
},
"email_viewer": {
"read_receipt": {
"prompt": "The sender asked to be notified when you open this message.",
@@ -112,6 +112,21 @@ describe('tag view across the own and group accounts (#1038)', () => {
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []);
});
it('lists the group account only for a tag picked from its shared tags', async () => {
useEmailStore.setState({ selectedMailbox: ownInbox.id });
useEmailStore.getState().selectKeyword(label, 'group');
await useEmailStore.getState().fetchEmails(client);
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1']);
expect(client.getEmails).toHaveBeenCalledTimes(1);
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []);
// A tag from the own list spans every account again.
useEmailStore.getState().selectKeyword(label);
expect(useEmailStore.getState().selectedKeywordAccountId).toBeNull();
});
it('lists the same set while an own folder is selected', async () => {
useEmailStore.setState({ selectedMailbox: ownInbox.id });
useEmailStore.getState().selectKeyword(label);
+363
View File
@@ -0,0 +1,363 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
import { MAX_METADATA_ENTRY_BYTES, MetadataPatchError, MetadataTooLargeError, metadataValueBytes } from '@/lib/jmap/bulwark-metadata';
import { emitPushStateChange } from '@/lib/push-events';
import {
DISCOVERY_INTERVAL_MS,
GROUP_CHECK_RETRY_MS,
PUSH_DEBOUNCE_MS,
STALE_AFTER_MS,
SharedTagsSaveDeferredError,
loadSharedTags,
resetSharedTags,
saveSharedTags,
sharedTagsKey,
useSharedTagsStore,
type SharedTagsClient,
} from '../shared-tags-store';
const KEY = sharedTagsKey('login-1', 'team');
const URGENT = { label: 'Urgent', color: 'red' };
/** org-team's Inbox metadata on a v1.0.0 server, shared by every member's client. */
class FakeServer {
namespace: Record<string, unknown> | null = { tags: { urgent: { ...URGENT } } };
state = 1;
failReads = false;
failWrites = false;
patch(patches: Array<{ path: string[]; value: unknown }>): { newState: string } {
if (this.failWrites) throw new TypeError('Failed to fetch');
let next = this.namespace === null ? null : structuredClone(this.namespace);
for (const { path, value } of patches) {
if (path.length === 0) { next = value as Record<string, unknown> | null; continue; }
if (!next) throw new MetadataPatchError('The namespace does not exist.');
let parent: Record<string, unknown> = next;
for (const segment of path.slice(0, -1)) {
const child = parent[segment];
if (!child || typeof child !== 'object' || Array.isArray(child)) throw new MetadataPatchError('A parent of the patched value does not exist or is not an object.');
parent = child as Record<string, unknown>;
}
const last = path[path.length - 1];
if (value === null) delete parent[last];
else parent[last] = value;
}
if (next && metadataValueBytes(next) > MAX_METADATA_ENTRY_BYTES) throw new MetadataTooLargeError('exceeds the maximum');
this.namespace = next;
this.state += 1;
return { newState: `s${this.state}` };
}
}
function member(server: FakeServer, options: { type?: string | null; advertised?: boolean; keywords?: Record<string, number> } = {}) {
let paused = false;
const client = {
getAccountInfo: vi.fn(() => ({ isPersonal: false })),
getPrincipalTypes: vi.fn(async (ids: string[]) =>
options.type === null ? null : Object.fromEntries(ids.map((id) => [id, options.type ?? 'group']))),
advertisesBulwarkMetadata: vi.fn(() => options.advertised !== false),
supportsBulwarkMetadata: vi.fn(() => options.advertised !== false && !paused),
getBulwarkMetadata: vi.fn(async () => {
if (server.failReads) { paused = true; return null; }
return server.namespace ? { tags: structuredClone(server.namespace) } : {};
}),
patchBulwarkMetadata: vi.fn(async (_id: string | undefined, _key: string, patches: Array<{ path: string[]; value: unknown }>) => server.patch(patches)),
discoverKeywords: vi.fn(async () => ({ keywords: options.keywords ?? {}, scanned: 0, total: 0, complete: true })),
endPause: () => { paused = false; },
};
return client as typeof client & SharedTagsClient;
}
const entry = (key = KEY) => useSharedTagsStore.getState().entries[key];
const push = (client: SharedTagsClient, state: string, account = 'team') =>
emitPushStateChange({ '@type': 'StateChange', changed: { [account]: { Mailbox: state } } }, client as never, 'active');
function becomeVisible() {
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
}
let server: FakeServer;
beforeEach(() => { resetSharedTags(); server = new FakeServer(); });
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
describe('loading', () => {
it('reads the shared definitions of a confirmed group and scans its messages', async () => {
const client = member(server, { keywords: { '$label:found': 3, $seen: 9 } });
await loadSharedTags(KEY, 'team', client);
expect(client.getBulwarkMetadata).toHaveBeenCalledWith('team');
expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] });
expect(client.discoverKeywords).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'team' }));
expect(entry().discovered).toEqual({ '$label:found': 3 });
});
it('leaves a mailbox a person shared to the user\'s own tags, without asking for metadata', async () => {
const client = member(server, { type: 'individual' });
await loadSharedTags(KEY, 'team', client);
expect(entry().status).toBe('personal');
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
});
it('never guesses a group when the principal lookup fails, and asks again later', async () => {
vi.useFakeTimers();
const client = member(server, { type: null });
await loadSharedTags(KEY, 'team', client);
expect(entry().status).toBe('loading');
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
client.getPrincipalTypes.mockImplementation(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, 'group'])));
await vi.advanceTimersByTimeAsync(GROUP_CHECK_RETRY_MS);
await vi.waitFor(() => expect(entry().status).toBe('ready'));
});
it('sends no metadata request where the server advertises none (0.16)', async () => {
const client = member(server, { advertised: false });
await loadSharedTags(KEY, 'team', client);
expect(entry().status).toBe('unsupported');
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
});
it('tells a failed read apart from no metadata, and reads again after the client\'s pause', async () => {
server.failReads = true;
const client = member(server);
await loadSharedTags(KEY, 'team', client);
expect(entry().status).toBe('unavailable');
// Still paused: coming back to the tab asks nothing.
server.failReads = false;
becomeVisible();
await Promise.resolve();
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
client.endPause();
becomeVisible();
await vi.waitFor(() => expect(entry().status).toBe('ready'));
});
it('keeps the last good tags when a later read fails', async () => {
const client = member(server);
await loadSharedTags(KEY, 'team', client);
server.failReads = true;
await loadSharedTags(KEY, 'team', client, { force: true });
expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] });
expect(entry().readFailedAt).not.toBeNull();
});
it('loads once per client and from scratch for a new sign-in', async () => {
const client = member(server);
await loadSharedTags(KEY, 'team', client);
await loadSharedTags(KEY, 'team', client);
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
server.namespace = { tags: {} };
await loadSharedTags(KEY, 'team', member(server));
expect(entry().tags).toEqual([]);
});
});
describe('refreshing', () => {
it('reads again once push has been quiet for 5 s, once for a burst', async () => {
const client = member(server);
await loadSharedTags(KEY, 'team', client);
vi.useFakeTimers();
server.patch([{ path: ['tags', 'later'], value: { label: 'Later', color: 'blue' } }]);
// Another account of the login doesn't concern it.
push(client, 'x9', 'own');
push(client, 's2');
await vi.advanceTimersByTimeAsync(2_000);
push(client, 's3');
await vi.advanceTimersByTimeAsync(PUSH_DEBOUNCE_MS - 1);
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await vi.waitFor(() => expect(entry().tags.map((t) => t.id)).toEqual(['later', 'urgent']));
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(2);
});
it('skips the push of its own save', async () => {
const client = member(server);
await loadSharedTags(KEY, 'team', client);
await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]);
vi.useFakeTimers();
push(client, `s${server.state}`);
await vi.advanceTimersByTimeAsync(PUSH_DEBOUNCE_MS * 2);
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
});
it('reloads only the definitions when the tab comes back, and scans the messages at most every 30 minutes', async () => {
let now = 1_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const client = member(server);
await loadSharedTags(KEY, 'team', client);
expect(client.discoverKeywords).toHaveBeenCalledTimes(1);
now += STALE_AFTER_MS;
becomeVisible();
await vi.waitFor(() => expect(entry().loadedAt).toBe(now));
// Let that load finish before the tab comes back again.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(2);
expect(client.discoverKeywords).toHaveBeenCalledTimes(1);
now += DISCOVERY_INTERVAL_MS;
becomeVisible();
await vi.waitFor(() => expect(client.discoverKeywords).toHaveBeenCalledTimes(2));
});
});
describe('saving', () => {
it('keeps the tags two members add at the same time', async () => {
const alice = member(server);
const bob = member(server);
const bobKey = sharedTagsKey('login-2', 'team');
await loadSharedTags(KEY, 'team', alice);
await loadSharedTags(bobKey, 'team', bob);
await Promise.all([
saveSharedTags(KEY, 'team', alice, [{ id: 'alice-tag', value: { label: 'Alice', color: 'green' } }]),
saveSharedTags(bobKey, 'team', bob, [{ id: 'bob-tag', value: { label: 'Bob', color: 'blue' } }]),
]);
expect(Object.keys((server.namespace as { tags: object }).tags).sort()).toEqual(['alice-tag', 'bob-tag', 'urgent']);
});
it('leaves a color another client set alone when it edits something else, or renames that tag', async () => {
server.namespace = { tags: { urgent: { ...URGENT }, other: { label: 'Other', color: 'chartreuse', icon: 'flame' } } };
const client = member(server);
await loadSharedTags(KEY, 'team', client);
await saveSharedTags(KEY, 'team', client, [{ id: 'urgent', value: { label: 'Very urgent', color: 'red-dark' } }]);
await saveSharedTags(KEY, 'team', client, [{ id: 'other', value: { label: 'Renamed' } }]);
expect(server.namespace).toEqual({ tags: {
urgent: { label: 'Very urgent', color: 'red-dark' },
other: { label: 'Renamed', color: 'chartreuse', icon: 'flame' },
} });
expect(client.patchBulwarkMetadata.mock.calls.map((c) => c[2])).toEqual([
[{ path: ['tags', 'urgent'], value: { label: 'Very urgent', color: 'red-dark' } }],
[{ path: ['tags', 'other'], value: { label: 'Renamed', color: 'chartreuse', icon: 'flame' } }],
]);
});
it('converts the earlier list shape once, then patches', async () => {
server.namespace = { list: [{ id: 'urgent', ...URGENT }] };
const client = member(server);
await loadSharedTags(KEY, 'team', client);
await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]);
expect(server.namespace).toEqual({ tags: { urgent: URGENT, later: { label: 'Later', color: 'blue' } } });
await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: null }]);
expect(client.patchBulwarkMetadata.mock.calls[1][2]).toEqual([{ path: ['tags', 'later'], value: null }]);
expect(server.namespace).toEqual({ tags: { urgent: URGENT } });
});
it('creates the namespace when there is none, keeping a tag another member just created it with', async () => {
server.namespace = null;
const client = member(server);
await loadSharedTags(KEY, 'team', client);
await saveSharedTags(KEY, 'team', client, [{ id: 'first', value: { label: 'First', color: 'blue' } }]);
expect(server.namespace).toEqual({ tags: { first: { label: 'First', color: 'blue' } } });
// Bob read nothing, Alice created it meanwhile: Bob's patch lands next to hers.
server.namespace = null;
const bob = member(server);
const bobKey = sharedTagsKey('login-2', 'team');
await loadSharedTags(bobKey, 'team', bob);
server.namespace = { tags: { alice: { label: 'Alice', color: 'green' } } };
await saveSharedTags(bobKey, 'team', bob, [{ id: 'bob', value: { label: 'Bob', color: 'red' } }]);
expect(Object.keys((server.namespace as { tags: object }).tags).sort()).toEqual(['alice', 'bob']);
});
it('refuses a list that no longer fits and sends nothing', async () => {
const client = member(server);
await loadSharedTags(KEY, 'team', client);
const many = Array.from({ length: 200 }, (_, i) => ({ id: `tag-number-${i}`, value: { label: `Tag number ${i}`, color: 'blue' } }));
await expect(saveSharedTags(KEY, 'team', client, many)).rejects.toBeInstanceOf(MetadataTooLargeError);
expect(client.patchBulwarkMetadata).not.toHaveBeenCalled();
expect(entry().pending).toEqual([]);
});
it('keeps a change the server didn\'t take, shows it, and sends it with the next good read', async () => {
const client = member(server);
await loadSharedTags(KEY, 'team', client);
server.failWrites = true;
await expect(saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]))
.rejects.toBeInstanceOf(SharedTagsSaveDeferredError);
expect(entry().pending).toHaveLength(1);
expect(entry().tags.map((t) => t.id)).toEqual(['later', 'urgent']);
server.failWrites = false;
becomeVisible();
await vi.waitFor(() => expect(entry().pending).toEqual([]));
expect((server.namespace as { tags: object }).tags).toHaveProperty('later');
});
});
// Through the real JMAP client and recorded Stalwart v1.0.0 answers.
describe('with the JMAP client', () => {
const METADATA_CAP = {
'urn:ietf:params:jmap:metadata': {
dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
},
};
const session = {
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals': {}, 'urn:ietf:params:jmap:metadata': {} },
accounts: {
me: { name: 'org-alice@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } },
team: { name: 'org-team@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals': {}, ...METADATA_CAP } },
},
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'me', 'urn:ietf:params:jmap:principals': 'me' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: '',
};
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
type Body = { using: string[]; methodCalls: Array<[string, Record<string, unknown>, string]> };
async function connected(answer: (body: Body) => Response | Promise<Response>) {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(json(session));
const client = new JMAPClient('https://mail.example.com', 'org-alice@example.org', 'pw');
await client.connect();
fetchSpy.mockReset();
const requests: Body[] = [];
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}')) as Body;
requests.push(body);
return answer(body);
});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
return { client, requests };
}
const answers = (body: Body, inbox: Record<string, unknown>) => {
const method = body.methodCalls[0][0];
if (method === 'Principal/get') return json({ methodResponses: [['Principal/get', { list: [{ id: 'team', type: 'group' }] }, '0']] });
if (method === 'Mailbox/query') {
return json({ methodResponses: [['Mailbox/query', { ids: ['a'] }, 'q'], ['Mailbox/get', { list: [{ id: 'a', ...inbox }] }, 'g']] });
}
if (method === 'Mailbox/set') return json({ methodResponses: [['Mailbox/set', { oldState: 's1', newState: 's2', updated: { a: null } }, '0']] });
if (method === 'Email/query') return json({ methodResponses: [['Email/query', { ids: [], total: 0 }, '0'], ['Email/get', { list: [] }, '1']] });
throw new Error(`unexpected ${method}`);
};
it('reads org.bulwarkmail.tags of the group Inbox and patches one tag, its id escaped', async () => {
const { client, requests } = await connected((body) => answers(body, { metadata: { 'org.bulwarkmail.tags': { tags: { urgent: URGENT } }, 'org.bulwarkmail.template.x': {} } }));
await loadSharedTags(KEY, 'team', client);
expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] });
// Tags nobody named are looked for in the group account, not the own one.
const scan = requests.find((r) => r.methodCalls[0][0] === 'Email/query')!;
expect(scan.methodCalls.map((c) => c[1].accountId)).toEqual(['team', 'team']);
await saveSharedTags(KEY, 'team', client, [{ id: 'work/clients', value: { label: 'Clients', color: 'blue' } }]);
const set = requests.find((r) => r.methodCalls[0][0] === 'Mailbox/set')!;
expect(set.methodCalls[0][1]).toEqual({
accountId: 'team',
update: { a: { 'metadata/org.bulwarkmail.tags/tags/work~1clients': { label: 'Clients', color: 'blue' } } },
});
});
it('counts a dropped metadata request as unavailable, not as no metadata', async () => {
const { client } = await connected((body) => {
if (body.methodCalls[0][0] === 'Mailbox/query') throw new TypeError('Failed to fetch');
return answers(body, {});
});
await loadSharedTags(KEY, 'team', client);
expect(entry().status).toBe('unavailable');
});
});
+34 -12
View File
@@ -192,6 +192,12 @@ interface EmailStore {
// Keyword/tag filter
selectedKeyword: string | null;
/**
* A tag picked from a group account's shared tags in the sidebar: the tag
* view lists that JMAP account (of the active login) only. Null: every
* account the login reaches (#1038).
*/
selectedKeywordAccountId: string | null;
tagCounts: Record<string, { total: number; unread: number }>;
// Advanced search state
@@ -303,7 +309,7 @@ interface EmailStore {
setError: (error: string | null) => void;
setSearchQuery: (query: string) => void;
setQuota: (quota: StorageQuota | null) => void;
selectKeyword: (keyword: string | null) => void;
selectKeyword: (keyword: string | null, accountId?: string | null) => void;
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
/** Take the email, not its id: the id alone collides across accounts. */
toggleEmailSelection: (email: SelectableEmail) => void;
@@ -1843,6 +1849,16 @@ export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAc
return built;
}
/**
* The accounts a tag view lists: all of them, or only the group account
* whose shared tag was picked in the sidebar (selectedKeywordAccountId).
*/
function tagViewAccounts(passedClient: IJMAPClient): UnifiedAccountClient[] {
const built = buildTagViewAccountClients(passedClient);
const scoped = useEmailStore.getState().selectedKeywordAccountId;
return scoped ? built.filter((account) => account.jmapAccountId === scoped) : built;
}
/**
* Whether a search is running under a folder-less scope (the default "All
* folders except Spam and Trash" or "All folders", see
@@ -3096,6 +3112,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Keyword/tag filter
selectedKeyword: null,
selectedKeywordAccountId: null,
tagCounts: {},
// Advanced search state
@@ -3161,6 +3178,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedEmail: null,
selectedEmailKeys: new Set(),
selectedKeyword: null,
selectedKeywordAccountId: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
threadEmailCounts: new Map(),
@@ -3251,8 +3269,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
set({ listHold: { rowKey, keywords } });
},
selectKeyword: (keyword) => set(state => ({
selectKeyword: (keyword, accountId = null) => set(state => ({
selectedKeyword: keyword,
selectedKeywordAccountId: keyword ? accountId : null,
isLoadingMore: false,
selectedEmail: null,
selectedEmailKeys: new Set(),
@@ -3309,6 +3328,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedEmail: null,
selectedEmailKeys: new Set(),
selectedKeyword: null,
selectedKeywordAccountId: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
threadEmailCounts: new Map(),
@@ -3603,7 +3623,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { selectedKeyword } = get();
if (selectedKeyword) {
const order = getMessageListOrderFor(null);
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
// Selecting a tag keeps an active search (`handleTagSelect` does not
// clear it), and the search paths narrow the tag rather than replace
// it — so honour the query here too, or the list would flip between
@@ -3907,7 +3927,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Searching inside a tag view narrows the tag (see searchEmails), so
// page 2 must carry the same keyword AND the same query — otherwise it
// appends unrelated all-folders hits to a tag list.
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await fetchTagEmails(
built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null),
hasFilters
@@ -3922,7 +3942,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (isAllFoldersSearchScope(searchMailboxId)) {
// Folder-less scope: the next page of the same own + group account
// fan-out the search ran (#1082).
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
const scope = acrossAccountsSearchOptions(searchMailboxId);
result = hasFilters
? await advancedSearchAcrossAccounts(built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, positionsByAccount(emails), scope)
@@ -3945,7 +3965,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Tag view: the next page of the same cross-account fan-out the
// initial fetch ran (same keyword, no folder constraint, same order),
// so pagination spans the own and group accounts alike (#1038).
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await fetchTagEmails(
built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null),
);
@@ -4979,7 +4999,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// NARROWS the tag rather than silently replacing the list with an
// unrelated all-folders result that the next push refresh would throw
// away again.
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await fetchTagEmails(
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
textFilter(query),
@@ -4992,7 +5012,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// views use (#1038). A single own-account query silently skipped the
// shared accounts' mail (#1082). The default scope leaves every
// account's Trash and Junk out; "All folders" searches them too.
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await searchAcrossAccounts(built, query, emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true });
unifiedErrors = result.errors;
@@ -5094,7 +5114,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} else if (selectedKeyword) {
// Narrow the tag rather than replace it — see searchEmails.
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await fetchTagEmails(
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
buildJMAPFilter(searchQuery, searchFilters, undefined),
@@ -5104,7 +5124,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} else if (isAllFoldersSearchScope(searchMailboxId)) {
// Folder-less scope: fan out over the own + group/shared accounts with
// a filter that carries no inMailbox clause (#1082).
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await advancedSearchAcrossAccounts(
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
{ ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true },
@@ -6501,7 +6521,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// They cannot establish a folder delta baseline.
// An active search narrows the tag, so carry it or the refresh
// would silently widen the list back to the whole tag.
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await fetchTagEmails(
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
hasFilters
@@ -6518,7 +6538,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (isAllFoldersSearchScope(searchMailboxId)) {
// Folder-less scope: the same own + group account fan-out the
// search ran, so a change in a group account reaches the list (#1082).
const built = buildTagViewAccountClients(client);
const built = tagViewAccounts(client);
result = await advancedSearchAcrossAccounts(
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
acrossAccountsSearchOptions(searchMailboxId),
@@ -7484,6 +7504,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedRole: role,
crossView: null,
selectedKeyword: null,
selectedKeywordAccountId: null,
retainedInViewIds: new Set(),
listHold: null,
viewToken: token,
@@ -7570,6 +7591,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedRole: null,
crossView: view,
selectedKeyword: null,
selectedKeywordAccountId: null,
retainedInViewIds: new Set(),
listHold: null,
viewToken: token,
+363
View File
@@ -0,0 +1,363 @@
/**
* The shared tags of each group account a signed-in login reaches
* (lib/shared-tags.ts): loaded the first time something on screen shows a
* message or the sidebar of that account, kept per login and account.
*
* Refreshing: Stalwart bumps the Mailbox state of the group account when
* its Inbox metadata changes, and every member's push channel reports the
* state changes of the accounts it reaches. So another member's edit
* arrives as a Mailbox change of the group account; reloads are debounced
* per account (5 s, a Mailbox change also comes with new mail) and the push
* of our own save is recognised by its state and skipped. Push can be off,
* so a tab that comes back reads definitions older than two minutes again.
* The scan for tags nobody named runs once per sign-in and then at most
* every 30 minutes.
*
* Status per account:
* - `loading`: not known yet, or the server wouldn't say whether the
* account is a group (asked again later; never guessed, since members
* write here). The user's own tags show meanwhile.
* - `personal`: not a group principal (a mailbox a person shared).
* - `unsupported`: a group, but the server advertises no metadata (0.16).
* - `unavailable`: a group whose metadata couldn't be read yet (the 1.0
* test build drops the request). Read again after the client's pause.
* - `ready`: the shared definitions. A later failed read keeps them.
*/
import { create } from 'zustand';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { MetadataPatchError, MetadataTooLargeError, MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata';
import { tagIdFromKeyword } from '@/lib/keyword-discovery';
import {
EMPTY_SHARED_TAGS,
SHARED_TAGS_KEY,
applySharedTagChanges,
planSharedTagsSave,
readSharedTags,
sharedDefinitions,
type SharedTagChange,
type StoredSharedTags,
} from '@/lib/shared-tags';
import { subscribePushStateChanges } from '@/lib/push-events';
import type { KeywordDefinition } from '@/stores/settings-store';
export type SharedTagsStatus = 'loading' | 'personal' | 'unsupported' | 'unavailable' | 'ready';
export interface SharedTagsEntry {
status: SharedTagsStatus;
/** The shared definitions with unsaved changes applied (status `ready`). */
tags: KeywordDefinition[];
/** What the server holds, as last read or written. */
stored: StoredSharedTags | null;
/** Changes a failed save left; sent again with the next good read. */
pending: SharedTagChange[];
/** Tag keywords found on the account's recent messages, with counts. */
discovered: Record<string, number>;
loadedAt: number;
discoveredAt: number;
/** When the last read failed; null after a good one. */
readFailedAt: number | null;
}
export type SharedTagsClient = Pick<IJMAPClient, 'discoverKeywords'>
& Partial<Pick<IJMAPClient, 'getPrincipalTypes' | 'supportsBulwarkMetadata' | 'advertisesBulwarkMetadata' | 'getBulwarkMetadata' | 'patchBulwarkMetadata'>>;
/** Messages scanned for tags nobody named: one request's worth. */
export const DISCOVERY_SCAN_LIMIT = 500;
/** A tab that comes back reads definitions older than this again. */
export const STALE_AFTER_MS = 2 * 60_000;
/** The scan for unnamed tags runs again after this, at the earliest. */
export const DISCOVERY_INTERVAL_MS = 30 * 60_000;
/** Push-triggered reloads of one account wait this long for quiet. */
export const PUSH_DEBOUNCE_MS = 5_000;
/** First retry when the server wouldn't say whether an account is a group. */
export const GROUP_CHECK_RETRY_MS = 30_000;
/** A save that didn't reach the server; its changes are kept and sent again. */
export class SharedTagsSaveDeferredError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : 'The shared tags could not be saved');
this.name = 'SharedTagsSaveDeferredError';
}
}
interface SharedTagsState {
entries: Record<string, SharedTagsEntry>;
}
export const useSharedTagsStore = create<SharedTagsState>()(() => ({ entries: {} }));
export function sharedTagsKey(loginId: string, accountId: string): string {
return `${loginId}|${accountId}`;
}
const clients = new Map<string, SharedTagsClient>();
const inflight = new Map<string, Promise<void>>();
const accountIds = new Map<string, string>();
const groupConfirmed = new Set<string>();
const ownStates = new Map<string, Set<string>>();
const pushTimers = new Map<string, ReturnType<typeof setTimeout>>();
const retryTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; delay: number }>();
let unsubscribePush: (() => void) | null = null;
let visibilityHandler: (() => void) | null = null;
const NEW_ENTRY: SharedTagsEntry = {
status: 'loading', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 0, discoveredAt: 0, readFailedAt: null,
};
const entryOf = (key: string): SharedTagsEntry | undefined => useSharedTagsStore.getState().entries[key];
function setEntry(key: string, patch: Partial<SharedTagsEntry>): void {
useSharedTagsStore.setState((state) => {
const previous = state.entries[key] ?? NEW_ENTRY;
const next = { ...previous, ...patch };
if (next.stored) next.tags = sharedDefinitions(applySharedTagChanges(next.stored, next.pending));
return { entries: { ...state.entries, [key]: next } };
});
}
/** Whether the client's metadata pause is over for the account. */
const mayAskMetadata = (client: SharedTagsClient, accountId: string) =>
typeof client.supportsBulwarkMetadata === 'function' && client.supportsBulwarkMetadata(accountId);
function scheduleGroupRetry(key: string): void {
const previous = retryTimers.get(key);
if (previous) clearTimeout(previous.timer);
const delay = previous ? Math.min(previous.delay * 2, 10 * 60_000) : GROUP_CHECK_RETRY_MS;
const timer = setTimeout(() => {
const client = clients.get(key);
const accountId = accountIds.get(key);
if (client && accountId && entryOf(key)?.status === 'loading') void loadSharedTags(key, accountId, client, { force: true });
}, delay);
retryTimers.set(key, { timer, delay });
}
async function discover(key: string, client: SharedTagsClient, accountId: string): Promise<void> {
setEntry(key, { discoveredAt: Date.now() });
try {
const scan = await client.discoverKeywords({ accountId, limit: DISCOVERY_SCAN_LIMIT });
if (clients.get(key) !== client) return;
const tags: Record<string, number> = {};
for (const [keyword, count] of Object.entries(scan.keywords)) {
if (tagIdFromKeyword(keyword)) tags[keyword] = count;
}
setEntry(key, { discovered: tags });
} catch {
// The tags on the messages still show on the messages themselves.
}
}
async function read(key: string, client: SharedTagsClient, accountId: string): Promise<void> {
if (!groupConfirmed.has(key)) {
if (typeof client.getPrincipalTypes !== 'function') {
// No way to confirm a group: nothing shared is written for it.
setEntry(key, { status: 'personal', loadedAt: Date.now() });
return;
}
const types = await client.getPrincipalTypes([accountId]).catch(() => null);
if (clients.get(key) !== client) return;
if (!types) {
// Never guess: stay on the user's own tags and ask again later.
scheduleGroupRetry(key);
return;
}
const retry = retryTimers.get(key);
if (retry) clearTimeout(retry.timer);
retryTimers.delete(key);
if (types[accountId] !== 'group') {
setEntry(key, { status: 'personal', loadedAt: Date.now() });
return;
}
groupConfirmed.add(key);
}
const advertised = typeof client.advertisesBulwarkMetadata === 'function'
? client.advertisesBulwarkMetadata(accountId)
: mayAskMetadata(client, accountId);
if (!advertised || typeof client.getBulwarkMetadata !== 'function') {
// 0.16: no request at all.
setEntry(key, { status: 'unsupported', loadedAt: Date.now() });
return;
}
// getBulwarkMetadata sends its request on its own and answers null on any
// failure, a dropped connection included; it then pauses the account.
const metadata = mayAskMetadata(client, accountId) ? await client.getBulwarkMetadata(accountId) : null;
if (clients.get(key) !== client) return;
if (!metadata) {
// A failed read keeps the last good tags.
setEntry(key, { status: entryOf(key)?.status === 'ready' ? 'ready' : 'unavailable', readFailedAt: Date.now() });
return;
}
setEntry(key, { status: 'ready', stored: readSharedTags(metadata[SHARED_TAGS_KEY]), loadedAt: Date.now(), readFailedAt: null });
if ((entryOf(key)?.pending ?? []).length > 0) {
await writeChanges(key, accountId, client, []).catch(() => {});
}
const discoveredAt = entryOf(key)?.discoveredAt ?? 0;
if (!discoveredAt || Date.now() - discoveredAt >= DISCOVERY_INTERVAL_MS) await discover(key, client, accountId);
}
function startWatching(): void {
if (!unsubscribePush) {
unsubscribePush = subscribePushStateChanges((change, client) => {
for (const [key, known] of clients) {
if (known !== (client as unknown as SharedTagsClient)) continue;
const accountId = accountIds.get(key);
const state = accountId ? change.changed[accountId]?.Mailbox : undefined;
if (!accountId || !state) continue;
// The push of our own save.
if (ownStates.get(key)?.delete(state)) continue;
const status = entryOf(key)?.status;
if (status !== 'ready' && status !== 'unavailable') continue;
const waiting = pushTimers.get(key);
if (waiting) clearTimeout(waiting);
pushTimers.set(key, setTimeout(() => {
pushTimers.delete(key);
if (mayAskMetadata(known, accountId)) void loadSharedTags(key, accountId, known, { force: true });
}, PUSH_DEBOUNCE_MS));
}
});
}
if (!visibilityHandler && typeof document !== 'undefined') {
visibilityHandler = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
for (const [key, entry] of Object.entries(useSharedTagsStore.getState().entries)) {
const client = clients.get(key);
const accountId = accountIds.get(key);
if (!client || !accountId) continue;
const failed = entry.status === 'unavailable' || entry.readFailedAt !== null || entry.pending.length > 0;
const due = entry.status === 'loading'
|| (failed && mayAskMetadata(client, accountId))
|| (entry.status === 'ready' && now - entry.loadedAt >= STALE_AFTER_MS);
if (due) void loadSharedTags(key, accountId, client, { force: true });
}
};
document.addEventListener('visibilitychange', visibilityHandler);
}
}
/**
* Loads an account's shared tags once per client; `force` reads the
* definitions again. The scan for unnamed tags runs with the first load of
* a sign-in and then at most every 30 minutes.
*/
export function loadSharedTags(
key: string,
accountId: string,
client: SharedTagsClient,
options: { force?: boolean } = {},
): Promise<void> {
const running = inflight.get(key);
if (running && clients.get(key) === client) return running;
const sameClient = clients.get(key) === client;
if (sameClient && !options.force && entryOf(key)) return Promise.resolve();
if (!sameClient) {
// Another sign-in of this login: nothing of the old one carries over.
groupConfirmed.delete(key);
ownStates.delete(key);
useSharedTagsStore.setState((state) => {
const entries = { ...state.entries };
delete entries[key];
return { entries };
});
}
clients.set(key, client);
accountIds.set(key, accountId);
startWatching();
if (!entryOf(key)) setEntry(key, {});
const run = read(key, client, accountId)
.catch(() => {
if (clients.get(key) === client) setEntry(key, { readFailedAt: Date.now() });
})
.finally(() => {
if (inflight.get(key) === run) inflight.delete(key);
});
inflight.set(key, run);
return run;
}
/** Sends the pending changes plus `changes`; the stored tags follow. */
async function writeChanges(
key: string,
accountId: string,
client: SharedTagsClient,
changes: SharedTagChange[],
): Promise<void> {
const entry = entryOf(key);
const all = [...(entry?.pending ?? []), ...changes];
let stored = entry?.stored ?? EMPTY_SHARED_TAGS;
// Throws MetadataTooLargeError before anything is sent; nothing is kept then.
planSharedTagsSave(stored, all);
try {
if (typeof client.patchBulwarkMetadata !== 'function' || typeof client.getBulwarkMetadata !== 'function') {
throw new MetadataUnsupportedError();
}
// With nothing stored, patch first anyway: another member may have just
// created the namespace, and writing it whole would drop their tag.
const nothingStored = !stored.patchable && Object.keys(stored.entries).length === 0 && Object.keys(stored.extra).length === 0;
if (nothingStored) stored = { ...stored, patchable: true };
for (let attempt = 0; ; attempt++) {
const plan = planSharedTagsSave(stored, all);
try {
const { newState } = await client.patchBulwarkMetadata(accountId, SHARED_TAGS_KEY, plan.patches);
if (newState) {
const own = ownStates.get(key) ?? new Set<string>();
own.add(newState);
ownStates.set(key, own);
}
setEntry(key, { status: 'ready', stored: plan.result, pending: [] });
return;
} catch (error) {
if (!(error instanceof MetadataPatchError) || attempt > 0) throw error;
// Not (or no longer) the map shape: read it, convert it, write it whole.
const metadata = await client.getBulwarkMetadata(accountId);
if (!metadata) throw new MetadataUnsupportedError();
stored = readSharedTags(metadata[SHARED_TAGS_KEY]);
}
}
} catch (error) {
if (error instanceof MetadataTooLargeError) throw error;
// Kept, shown, and sent again with the next good read.
setEntry(key, { pending: all });
throw new SharedTagsSaveDeferredError(error);
}
}
/**
* Changes the shared definitions of an account, one patch per changed tag,
* so the other tags (and what other members change meanwhile) stay as they
* are. Throws MetadataTooLargeError when the list no longer fits (nothing
* kept), SharedTagsSaveDeferredError when the server didn't take it (the
* change is kept, shown and sent again with the next good read).
*/
export async function saveSharedTags(
key: string,
accountId: string,
client: SharedTagsClient,
changes: SharedTagChange[],
): Promise<void> {
clients.set(key, client);
accountIds.set(key, accountId);
startWatching();
await writeChanges(key, accountId, client, changes);
}
/** For tests: forget every account. */
export function resetSharedTags(): void {
clients.clear();
inflight.clear();
accountIds.clear();
groupConfirmed.clear();
ownStates.clear();
for (const timer of pushTimers.values()) clearTimeout(timer);
pushTimers.clear();
for (const { timer } of retryTimers.values()) clearTimeout(timer);
retryTimers.clear();
unsubscribePush?.();
unsubscribePush = null;
if (visibilityHandler && typeof document !== 'undefined') document.removeEventListener('visibilitychange', visibilityHandler);
visibilityHandler = null;
useSharedTagsStore.setState({ entries: {} });
}