From b049b6f470d3566ee5dc912bf13a03c6ce57b5a2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:43:46 +0100 Subject: [PATCH 01/10] feat: share tag names and colours in group mailboxes #1310 --- components/email/batch-tag-button.tsx | 6 +- components/email/email-context-menu.tsx | 5 + components/email/email-hover-actions.tsx | 3 + components/email/email-list.tsx | 5 +- components/email/email-viewer.tsx | 11 +- components/email/mv-list-toolbar.tsx | 3 +- components/email/tag-badge.tsx | 6 +- components/email/tag-picker.tsx | 21 +- components/email/thread-email-item.tsx | 8 +- components/email/thread-list-item.tsx | 19 +- components/layout/sidebar.tsx | 78 +++++ .../__tests__/shared-tag-settings.test.tsx | 119 +++++++ components/settings/keyword-settings.tsx | 2 +- components/settings/organize-settings.tsx | 2 + components/settings/shared-tag-settings.tsx | 331 ++++++++++++++++++ hooks/__tests__/use-account-tags.test.tsx | 126 +++++++ hooks/use-account-tags.ts | 141 ++++++++ hooks/use-keyword-format.ts | 9 +- lib/__tests__/shared-tags.test.ts | 96 +++++ lib/jmap/client-interface.ts | 2 + lib/jmap/client.ts | 7 +- lib/jmap/types.ts | 2 + lib/shared-tags.ts | 140 ++++++++ locales/de/common.json | 24 ++ locales/en/common.json | 24 ++ stores/__tests__/shared-tags-store.test.ts | 210 +++++++++++ stores/shared-tags-store.ts | 229 ++++++++++++ 27 files changed, 1605 insertions(+), 24 deletions(-) create mode 100644 components/settings/__tests__/shared-tag-settings.test.tsx create mode 100644 components/settings/shared-tag-settings.tsx create mode 100644 hooks/__tests__/use-account-tags.test.tsx create mode 100644 hooks/use-account-tags.ts create mode 100644 lib/__tests__/shared-tags.test.ts create mode 100644 lib/shared-tags.ts create mode 100644 stores/__tests__/shared-tags-store.test.ts create mode 100644 stores/shared-tags-store.ts diff --git a/components/email/batch-tag-button.tsx b/components/email/batch-tag-button.tsx index dbcd227e6..2ffa6c661 100644 --- a/components/email/batch-tag-button.tsx +++ b/components/email/batch-tag-button.tsx @@ -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(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 }} > - + , document.body, )} diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 0050253c4..1ec0402d4 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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 } from "@/hooks/use-account-tags"; import { Email, Mailbox } from "@/lib/jmap/types"; import { ContextMenu, @@ -206,6 +207,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 the tags, for a selection too: it is part of it. + 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 @@ -497,11 +500,13 @@ export function EmailContextMenu({ selectedIds={batchTagIds ?? []} partialIds={batchPartialTagIds} onToggle={(tagId) => onBatchToggleTag?.(tagId)} + scope={tagScope} /> ) : ( onSetTag?.(tagId)} + scope={tagScope} /> )} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 6f00141e6..27a5945cc 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -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, 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(); @@ -290,6 +292,7 @@ export function EmailHoverActions({ > onSetTag(tagId)} /> , diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index af703af36..7b5c35044 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -43,6 +43,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[]; @@ -585,7 +586,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 @@ -858,6 +860,7 @@ export function EmailList({ title={tContextMenu('tag')} selectedIds={selectionTags.all} partialIds={selectionTags.some} + scope={selectionTags.scope} onToggle={handleBatchToggleTag} active={hasSelection && !isScheduledView} disabled={isProcessing} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 52e2307bc..80c782e5a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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); @@ -4108,6 +4111,7 @@ export function EmailViewer({
{ if (email) onSetTag?.(email.id, tagId); }} />
@@ -4402,6 +4406,7 @@ export function EmailViewer({ > { if (email) onSetTag?.(email.id, tagId); }} /> @@ -4862,6 +4867,7 @@ export function EmailViewer({ { if (email) onSetTag?.(email.id, tagId); }} /> )} @@ -4945,6 +4951,7 @@ export function EmailViewer({ key={tagId} tagId={tagId} variant={headerTagVariant} + scope={tagScope} onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined} /> ))} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index 77cfc8b98..7d2c2dc50 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -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" > - + )} diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx index 9359c307a..f6a283496 100644 --- a/components/email/tag-badge.tsx +++ b/components/email/tag-badge.tsx @@ -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); diff --git a/components/email/tag-picker.tsx b/components/email/tag-picker.tsx index fb03d368e..1880d333d 100644 --- a/components/email/tag-picker.tsx +++ b/components/email/tag-picker.tsx @@ -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 && ( +

+ {tShared("picker_heading")} +

+ )} {showSearch && (
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 877461273..8374a73dc 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -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({
{tagIds.map((id) => ( - + ))} {cleanPreview(email.preview) || t('no_preview_available')} @@ -295,7 +297,7 @@ export function ThreadEmailItem({ )} {tagIds.map((id) => ( - + ))}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 0634c7334..7d805c878 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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( 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( {tagIds.length > 0 && ( {tagIds.map((id) => ( - + ))} )} @@ -775,7 +777,7 @@ const SingleEmailItem = React.forwardRef( {tagPlacement === 'sender' && tagIds.length > 0 && ( {tagIds.map((id) => ( - + ))} )} @@ -837,7 +839,7 @@ const SingleEmailItem = React.forwardRef( {tagPlacement === 'subject' && tagIds.length > 0 && ( {tagIds.map((id) => ( - + ))} )} @@ -1003,7 +1005,8 @@ export const ThreadListItem = React.forwardRef state.tintListRowsByTag); const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount); @@ -1345,7 +1348,7 @@ export const ThreadListItem = React.forwardRef )} {tagIds.map((id) => ( - + ))} )} @@ -1434,7 +1437,7 @@ export const ThreadListItem = React.forwardRef {tagPlacement === 'sender' && tagIds.map((id) => ( - + ))}
@@ -1495,7 +1498,7 @@ export const ThreadListItem = React.forwardRef 0 && ( {tagIds.map((id) => ( - + ))} )} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index d9019376e..e75782412 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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"; @@ -822,6 +823,72 @@ function TagItem({ ); } +const NO_TAG_COUNTS: Record = {}; + +/** + * The tags of a group account, under its folders in the Shared section: the + * shared definitions plus the tags found on its messages (useAccountTags). + * 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; + selectedKeyword: string | null; + expandedTags: Set; + onTagSelect?: (keywordId: 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], + ); + if (!shared || definitions.length === 0) return null; + return ( + +
+ setExpanded((open) => !open)} + onSettings={() => router.push(buildSettingsPath('organize', 'shared-tags'))} + settingsTitle={t('edit')} + isCollapsed={false} + sub + /> + {expanded && tree.map((node) => ( + + ))} +
+
+ ); +} + function DemoBanner() { const t = useTranslations('sidebar'); const { isDemoMode, loginDemo } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode, loginDemo: state.loginDemo }))); @@ -1882,6 +1949,17 @@ function SidebarImpl({ ))} {menuAccountId && !account.children.some((child) => child.role === 'drafts') && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} + {menuAccountId && activeAccountId && ( + + )} )}
diff --git a/components/settings/__tests__/shared-tag-settings.test.tsx b/components/settings/__tests__/shared-tag-settings.test.tsx new file mode 100644 index 000000000..114a61cb4 --- /dev/null +++ b/components/settings/__tests__/shared-tag-settings.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +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 } 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. + +const URGENT = { id: '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; +let keywords: Record; +let metadata: boolean; +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: 'group' }), + supportsBulwarkMetadata: () => metadata, + getBulwarkMetadata: vi.fn(async () => stored), + setBulwarkMetadata: vi.fn(async (_id: string | undefined, key: string, value: unknown) => { stored = { ...stored, [key]: value }; }), + discoverKeywords: async () => ({ keywords, scanned: 0, total: 0, complete: true }), + getMailboxes: async () => [], +}; + +function setRights(rights: Record) { + 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: { list: [URGENT] } }; + keywords = {}; + metadata = true; + client.getBulwarkMetadata.mockClear(); + client.setBulwarkMetadata.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', async () => { + render(); + 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.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', { + list: [URGENT, { id: 'waiting', label: 'Waiting', color: 'blue' }], + })); + expect(await screen.findByText('Waiting')).toBeInTheDocument(); + }); + + it('shows the tags read-only with "Set by your organisation" when the admin locked them', async () => { + setLocked(true); + const { container } = render(); + expect(await screen.findByText('Urgent')).toBeInTheDocument(); + expect(container.querySelector('[data-org-locked]')).not.toBeNull(); + expect(screen.queryByText('add')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('edit')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('delete')).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(); + expect(await screen.findByTestId('shared-tags-no-rights')).toHaveTextContent('no_rights'); + expect(screen.queryByText('add')).not.toBeInTheDocument(); + }); + + it('says in one line that the server keeps no shared tags, with no editor', async () => { + metadata = false; + render(); + expect(await screen.findByTestId('shared-tags-unsupported')).toHaveTextContent('unsupported'); + expect(screen.queryByText('add')).not.toBeInTheDocument(); + expect(client.getBulwarkMetadata).not.toHaveBeenCalled(); + }); + + it('offers to name a tag found on the messages, keeping its id', async () => { + keywords = { '$label:waiting-for-customer': 2 }; + render(); + fireEvent.click(await screen.findByLabelText('name_it')); + fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Customer' } }); + fireEvent.click(screen.getByText('save')); + await waitFor(() => expect(client.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', { + list: [URGENT, { id: 'waiting-for-customer', 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); + }); +}); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index 76dfb5a93..ded144fcc 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -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, }: { diff --git a/components/settings/organize-settings.tsx b/components/settings/organize-settings.tsx index f2a4ce412..1c1bcf515 100644 --- a/components/settings/organize-settings.tsx +++ b/components/settings/organize-settings.tsx @@ -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() {
{tagsEnabled && } + {tagsEnabled && }
); } diff --git a/components/settings/shared-tag-settings.tsx b/components/settings/shared-tag-settings.tsx new file mode 100644 index 000000000..694c690a4 --- /dev/null +++ b/components/settings/shared-tag-settings.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useEffect, useMemo, 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, removeSharedTag, upsertSharedTag } from "@/lib/shared-tags"; +import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useSettingsStore, type KeywordDefinition } from "@/stores/settings-store"; +import { 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 | 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 group account, from the folder list or asked once. */ +function useInboxRights(scope: TagScope): Partial | null | undefined { + const fromStore = useEmailStore((state) => + state.mailboxes.find((mailbox) => mailbox.accountId === scope.accountId && mailbox.role === "inbox")?.myRights); + const [fetched, setFetched] = useState | null | undefined>(undefined); + useEffect(() => { + if (fromStore) return; + let cancelled = false; + const client = clientForLogin(scope.loginId) as unknown as { getMailboxes?: (accountId?: string) => Promise> } | 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; }; + }, [fromStore, scope.loginId, scope.accountId]); + return fromStore ?? fetched; +} + +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; + onSave: (tag: KeywordDefinition) => void; + onCancel: () => void; +}) { + const t = useTranslations("shared_tags"); + const [label, setLabel] = useState(initial?.label ?? ""); + const [color, setColor] = useState(initial?.color ?? "blue"); + 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 save = () => { if (valid && !saving) onSave({ id, label: label.trim(), color }); }; + + return ( +
+
+ + 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 &&

{t("exists")}

} + {tooLong &&

{t("too_long")}

} +
+
+

{t("colour")}

+ +
+
+ + +
+
+ ); +} + +/** + * 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. + */ +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); + const canWrite = maySetSharedTags(rights); + const editable = !locked && canWrite; + const [editing, setEditing] = useState<{ id?: string; fixed?: boolean } | null>(null); + const [confirmDelete, setConfirmDelete] = useState(null); + const [saving, setSaving] = useState(false); + + const shared = useMemo(() => (status === "ready" ? entry?.tags ?? [] : []), [status, entry]); + const discovered = useMemo( + () => (status === "ready" && entry ? groupAccountTags(entry.tags, entry.discovered, nested).definitions.slice(entry.tags.length) : []), + [status, entry, nested], + ); + const takenIds = useMemo(() => [...shared, ...discovered].map((tag) => tag.id.toLowerCase()), [shared, discovered]); + + if (status === "personal") return null; + + const save = async (change: (list: KeywordDefinition[]) => KeywordDefinition[]) => { + const client = clientForLogin(scope.loginId); + if (!client) return false; + setSaving(true); + try { + await saveSharedTags(key, scope.accountId, client, change); + return true; + } catch (error) { + toast.error(error instanceof MetadataTooLargeError ? t("too_many") : t("save_failed")); + return false; + } finally { + setSaving(false); + } + }; + + const heading = ( +
+
+ ); + + if (status === "loading") { + return ( +
+ {heading} +

+ + {t("loading")} +

+
+ ); + } + + if (status === "unsupported") { + return ( +
+ {heading} +

{t("unsupported")}

+
+ ); + } + + const row = (tag: KeywordDefinition, found: boolean) => { + if (editing && editing.id === tag.id) { + return ( + setEditing(null)} + onSave={async (next) => { if (await save((list) => upsertSharedTag(list, next))) setEditing(null); }} + /> + ); + } + return ( +
+
+ + {found && {t("found_on_messages")}} +
+ {editable && confirmDelete === tag.id ? ( +
+ {t("delete_confirm")} + + +
+ ) : editable && ( +
+ + {!found && ( + + )} +
+ )} +
+ ); + }; + + return ( + +
+ {heading} + {shared.length === 0 && discovered.length === 0 && ( +

{t("empty")}

+ )} + {shared.map((tag) => row(tag, false))} + {discovered.map((tag) => row(tag, true))} + {locked ? ( + + ) : !canWrite ? ( +

+ {rights === undefined ? t("loading") : t("no_rights")} +

+ ) : editing && !editing.id ? ( + setEditing(null)} + onSave={async (next) => { if (await save((list) => upsertSharedTag(list, next))) setEditing(null); }} + /> + ) : ( + + )} +
+
+ ); +} + +/** + * Settings > Folders & tags > Shared tags: the tags of each group mailbox + * the user is a member of. Hidden without group accounts. + */ +export function SharedTagSettings() { + const t = useTranslations("shared_tags"); + const client = useAuthStore((state) => state.client); + const loginId = useAuthStore((state) => state.activeAccountId); + const accounts = useMemo( + () => (client && typeof client.getSharedAccounts === "function" + ? client.getSharedAccounts().filter((account) => !account.isPrimary && account.capabilities.mail && client.getAccountInfo?.(account.id)?.isPersonal === false) + : []), + [client], + ); + 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 ( +
+ +
+ {accounts.map((account) => ( + + ))} +
+
+
+ ); +} diff --git a/hooks/__tests__/use-account-tags.test.tsx b/hooks/__tests__/use-account-tags.test.tsx new file mode 100644 index 000000000..7f58eab73 --- /dev/null +++ b/hooks/__tests__/use-account-tags.test.tsx @@ -0,0 +1,126 @@ +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) { + useSharedTagsStore.setState((state) => ({ + entries: { + ...state.entries, + [sharedTagsKey(scope.loginId, scope.accountId)]: { status: 'ready', tags: [], discovered: {}, loadedAt: 1, ...entry }, + }, + })); +} + +function badge(tagId: string, scope?: TagScope | null) { + return render(); +} + +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 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( + <> + + + + , + ); + 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( {}} 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( {}} 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(); + }); +}); diff --git a/hooks/use-account-tags.ts b/hooks/use-account-tags.ts new file mode 100644 index 000000000..b35a774ff --- /dev/null +++ b/hooks/use-account-tags.ts @@ -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(null); + +/** The scope of a message: its own account when that isn't the login's. */ +export function tagScopeOf(email: Pick | 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 | 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>): 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>(); + +/** 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]); +} diff --git a/hooks/use-keyword-format.ts b/hooks/use-keyword-format.ts index ae82caa65..3d44c934b 100644 --- a/hooks/use-keyword-format.ts +++ b/hooks/use-keyword-format.ts @@ -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( diff --git a/lib/__tests__/shared-tags.test.ts b/lib/__tests__/shared-tags.test.ts new file mode 100644 index 000000000..3bc9c8b7f --- /dev/null +++ b/lib/__tests__/shared-tags.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + groupAccountTags, + parseSharedTags, + removeSharedTag, + sharedTagsValue, + upsertSharedTag, +} 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: `{ list: KeywordDefinition[] }` (Stalwart takes only objects as +// namespace values). + +describe('parseSharedTags', () => { + it('reads the definitions under list', () => { + expect(parseSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }] })) + .toEqual([{ id: 'urgent', label: 'Urgent', color: 'red' }]); + }); + + it('takes a bare array another client may have written', () => { + expect(parseSharedTags([{ id: 'urgent', label: 'Urgent', color: 'red' }])).toHaveLength(1); + }); + + it('drops what cannot be a tag and keeps the first of an id in any case', () => { + const tags = parseSharedTags({ + list: [ + { id: 'urgent', label: 'Urgent', color: 'red' }, + { id: 'URGENT', label: 'Twice', color: 'blue' }, + { id: 'has space', label: 'Bad', color: 'red' }, + { id: 'ctl\u0001', label: 'Bad', color: 'red' }, + { label: 'No id', color: 'red' }, + 'urgent', + null, + { id: 'later', label: ' ', color: 'not-a-colour', visibility: 'sometimes' }, + ], + }); + expect(tags).toEqual([ + { id: 'urgent', label: 'Urgent', color: 'red' }, + // An empty name falls back to the id, an unknown colour to grey. + { id: 'later', label: 'later', color: 'gray' }, + ]); + }); + + it('reads nothing from a missing or odd value', () => { + expect(parseSharedTags(undefined)).toEqual([]); + expect(parseSharedTags({ list: 'x' })).toEqual([]); + expect(parseSharedTags(7)).toEqual([]); + }); +}); + +describe('sharedTagsValue', () => { + it('writes id, name, colour and visibility only', () => { + expect(sharedTagsValue([{ id: 'a', label: 'A', color: 'red', addedBy: 'plugin-x', visibility: 'unread' }])) + .toEqual({ list: [{ id: 'a', label: 'A', color: 'red', visibility: 'unread' }] }); + }); + + it('refuses a list larger than one metadata entry', () => { + const many = Array.from({ length: 200 }, (_, i) => ({ id: `tag-number-${i}`, label: `Tag number ${i}`, color: 'blue' })); + expect(() => sharedTagsValue(many)).toThrow(MetadataTooLargeError); + expect(() => sharedTagsValue(many.slice(0, 20))).not.toThrow(); + }); +}); + +describe('upsertSharedTag and removeSharedTag', () => { + const list = [{ id: 'urgent', label: 'Urgent', color: 'red' }, { id: 'later', label: 'Later', color: 'blue' }]; + + it('adds a new tag at the end and replaces one with the same id in place', () => { + expect(upsertSharedTag(list, { id: 'new', label: 'New', color: 'green' }).map((t) => t.id)).toEqual(['urgent', 'later', 'new']); + expect(upsertSharedTag(list, { id: 'Urgent', label: 'Very urgent', color: 'red-dark' })[0]) + .toEqual({ id: 'urgent', label: 'Very urgent', color: 'red-dark' }); + }); + + it('removes by id in any case', () => { + expect(removeSharedTag(list, 'URGENT')).toEqual([{ id: 'later', label: 'Later', color: 'blue' }]); + }); +}); + +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[0]).toEqual({ id: 'urgent', label: 'Urgent', color: 'red' }); + 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); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 32c3fff43..8e1101ee5 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -289,6 +289,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; 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 | null }>, accountId?: string): Promise>; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 3032b4477..f59e5de86 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2671,7 +2671,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; 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 = {}; @@ -2685,14 +2688,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"], diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index d06f4ab77..1aafbd709 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -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 { diff --git a/lib/shared-tags.ts b/lib/shared-tags.ts new file mode 100644 index 000000000..93222b196 --- /dev/null +++ b/lib/shared-tags.ts @@ -0,0 +1,140 @@ +/** + * Shared tags of a group account: the tag names and colours 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. Stalwart takes only objects as namespace values, so the list + * sits under `list`. + * + * Pure functions only: reading and checking what the server holds, the + * value written back, and the list a group account shows (the shared + * definitions plus the tags found on its messages, which are never hidden). + */ + +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 (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; +} + +export interface SharedTagsValue { + list: KeywordDefinition[]; +} + +/** One definition from the server, or null when it can't be one. */ +function parseDefinition(value: unknown): KeywordDefinition | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Record; + const id = typeof raw.id === 'string' ? raw.id.trim() : ''; + if (!id || id.length > MAX_KEYWORD_ID_LENGTH || !isKeywordSafe(id)) return null; + const label = typeof raw.label === 'string' ? raw.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : ''; + const color = typeof raw.color === 'string' && KEYWORD_PALETTE[raw.color] ? raw.color : FALLBACK_KEYWORD_COLOR; + const definition: KeywordDefinition = { id, label: label || id, color }; + if (typeof raw.visibility === 'string' && (VISIBILITIES as readonly string[]).includes(raw.visibility)) { + definition.visibility = raw.visibility as KeywordVisibility; + } + return definition; +} + +/** + * The shared definitions in a metadata value: `{ list: [...] }`, or a bare + * array from another client. Anything that isn't a definition is dropped, + * and an id the list already has (in any case) keeps its first entry. + */ +export function parseSharedTags(value: unknown): KeywordDefinition[] { + const items = Array.isArray(value) + ? value + : value && typeof value === 'object' && Array.isArray((value as { list?: unknown }).list) + ? (value as { list: unknown[] }).list + : []; + const seen = new Set(); + const out: KeywordDefinition[] = []; + for (const item of items) { + const definition = parseDefinition(item); + if (!definition) continue; + const folded = definition.id.toLowerCase(); + if (seen.has(folded)) continue; + seen.add(folded); + out.push(definition); + } + return out; +} + +/** + * The value written to the server: id, label, colour and visibility only (a + * plugin's `addedBy` is about one member's settings). Throws + * MetadataTooLargeError when it would not fit the 8 KB of one namespace. + */ +export function sharedTagsValue(list: KeywordDefinition[]): SharedTagsValue { + const value: SharedTagsValue = { + list: parseSharedTags(list).map(({ id, label, color, visibility }) => (visibility ? { id, label, color, visibility } : { id, label, color })), + }; + if (metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) { + throw new MetadataTooLargeError(`The shared tags take more than ${MAX_METADATA_ENTRY_BYTES} bytes`); + } + return value; +} + +/** The list with `tag` added, or replacing the definition with its id. */ +export function upsertSharedTag(list: KeywordDefinition[], tag: KeywordDefinition): KeywordDefinition[] { + const folded = tag.id.toLowerCase(); + const index = list.findIndex((item) => item.id.toLowerCase() === folded); + if (index === -1) return [...list, tag]; + return list.map((item, i) => (i === index ? { ...tag, id: item.id } : item)); +} + +/** The list without the definition of `id`. The keyword stays on its messages. */ +export function removeSharedTag(list: KeywordDefinition[], id: string): KeywordDefinition[] { + const folded = id.toLowerCase(); + return list.filter((item) => item.id.toLowerCase() !== folded); +} + +/** + * 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 colour 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, + 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), + }; +} diff --git a/locales/de/common.json b/locales/de/common.json index 25b479847..665df692d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -326,6 +326,30 @@ "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": "Tags", + "edit": "Geteilte Labels bearbeiten", + "add": "Geteiltes Label hinzufügen", + "name": "Name", + "colour": "Farbe", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "delete_confirm": "Für alle löschen? Das Label bleibt an seinen Nachrichten.", + "name_it": "Namen vergeben", + "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.", + "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." + }, "email_viewer": { "read_receipt": { "prompt": "Der Absender bittet um eine Lesebestätigung:", diff --git a/locales/en/common.json b/locales/en/common.json index 35c16805e..258aa53a5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -326,6 +326,30 @@ "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 colours on its mail.", + "picker_heading": "Shared with everyone in this mailbox", + "sidebar_heading": "Tags", + "edit": "Edit shared tags", + "add": "Add shared tag", + "name": "Name", + "colour": "Colour", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "delete_confirm": "Delete for everyone? The tag stays on its messages.", + "name_it": "Give it a name", + "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.", + "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." + }, "email_viewer": { "read_receipt": { "prompt": "The sender asked to be notified when you open this message.", diff --git a/stores/__tests__/shared-tags-store.test.ts b/stores/__tests__/shared-tags-store.test.ts new file mode 100644 index 000000000..32a9ab298 --- /dev/null +++ b/stores/__tests__/shared-tags-store.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { MetadataTooLargeError, MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata'; +import { emitPushStateChange } from '@/lib/push-events'; +import { + loadSharedTags, + resetSharedTags, + saveSharedTags, + sharedTagsKey, + useSharedTagsStore, + type SharedTagsClient, +} from '../shared-tags-store'; + +const KEY = sharedTagsKey('login-1', 'team'); +const URGENT = { id: 'urgent', label: 'Urgent', color: 'red' }; + +function fakeClient(options: { + type?: string | null; + metadata?: boolean; + shared?: Record | null; + keywords?: Record; +} = {}) { + let stored: Record | null = options.shared === undefined ? { tags: { list: [URGENT] } } : options.shared; + 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']))), + supportsBulwarkMetadata: vi.fn(() => options.metadata !== false), + getBulwarkMetadata: vi.fn(async () => stored), + setBulwarkMetadata: vi.fn(async (_id: string | undefined, key: string, value: unknown) => { + stored = { ...(stored ?? {}), [key]: value }; + }), + discoverKeywords: vi.fn(async () => ({ keywords: options.keywords ?? {}, scanned: 0, total: 0, complete: true })), + setStored: (value: Record | null) => { stored = value; }, + }; + return client as typeof client & SharedTagsClient; +} + +const entry = () => useSharedTagsStore.getState().entries[KEY]; + +beforeEach(() => resetSharedTags()); +afterEach(() => vi.restoreAllMocks()); + +describe('loadSharedTags', () => { + it('reads the shared definitions of a group account', async () => { + const client = fakeClient({ keywords: { '$label:found': 3, $seen: 9 } }); + await loadSharedTags(KEY, 'team', client); + expect(client.getBulwarkMetadata).toHaveBeenCalledWith('team'); + expect(entry()).toMatchObject({ status: 'ready', tags: [URGENT] }); + // The recent messages are scanned in the group account, for tags only. + 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 = fakeClient({ type: 'individual' }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('personal'); + expect(client.getBulwarkMetadata).not.toHaveBeenCalled(); + }); + + it('counts any non-personal account as a group when the server won\'t say', async () => { + const client = fakeClient({ type: null }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('ready'); + }); + + it('sends no metadata request to a server without metadata (0.16)', async () => { + const client = fakeClient({ metadata: false }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('unsupported'); + expect(client.getBulwarkMetadata).not.toHaveBeenCalled(); + }); + + it('falls back when the metadata request fails (the 1.0 test build drops it)', async () => { + const client = fakeClient({ shared: null }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('unsupported'); + }); + + it('loads once per client and again for a new sign-in', async () => { + const client = fakeClient(); + await loadSharedTags(KEY, 'team', client); + await loadSharedTags(KEY, 'team', client); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1); + const next = fakeClient({ shared: { tags: { list: [] } } }); + await loadSharedTags(KEY, 'team', next); + expect(entry().tags).toEqual([]); + }); + + it('reads them again when push reports a Mailbox change in the group account', async () => { + const client = fakeClient(); + await loadSharedTags(KEY, 'team', client); + const later = { id: 'later', label: 'Later', color: 'blue' }; + client.setStored({ tags: { list: [URGENT, later] } }); + + // A change in another account of the login doesn't concern it. + emitPushStateChange({ '@type': 'StateChange', changed: { own: { Mailbox: 's2' } } }, client as never, 'active'); + await Promise.resolve(); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1); + + emitPushStateChange({ '@type': 'StateChange', changed: { team: { Mailbox: 's2' } } }, client as never, 'active'); + await vi.waitFor(() => expect(entry().tags).toEqual([URGENT, later])); + }); +}); + +describe('saveSharedTags', () => { + it('applies the change to the newest definitions on the server and writes them back', async () => { + const client = fakeClient(); + await loadSharedTags(KEY, 'team', client); + // Another member added a tag meanwhile. + client.setStored({ tags: { list: [URGENT, { id: 'theirs', label: 'Theirs', color: 'green' }] } }); + await saveSharedTags(KEY, 'team', client, (list) => [...list, { id: 'mine', label: 'Mine', color: 'blue', addedBy: 'x' }]); + expect(client.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', { + list: [URGENT, { id: 'theirs', label: 'Theirs', color: 'green' }, { id: 'mine', label: 'Mine', color: 'blue' }], + }); + expect(entry().tags.map((t) => t.id)).toEqual(['urgent', 'theirs', 'mine']); + }); + + it('refuses a list that no longer fits and writes nothing', async () => { + const client = fakeClient(); + const many = Array.from({ length: 200 }, (_, i) => ({ id: `tag-number-${i}`, label: `Tag number ${i}`, color: 'blue' })); + await expect(saveSharedTags(KEY, 'team', client, () => many)).rejects.toBeInstanceOf(MetadataTooLargeError); + expect(client.setBulwarkMetadata).not.toHaveBeenCalled(); + }); + + it('refuses on a server without metadata', async () => { + const client = fakeClient({ shared: null }); + await expect(saveSharedTags(KEY, 'team', client, (list) => list)).rejects.toBeInstanceOf(MetadataUnsupportedError); + }); +}); + +// The same, through the real JMAP client and recorded Stalwart 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]> }; + + async function connected(answer: (body: Body) => Response | Promise) { + 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) => { + 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', { 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 writes the list as an object', async () => { + const { client, requests } = await connected((body) => answers(body, { metadata: { 'org.bulwarkmail.tags': { list: [URGENT] }, 'org.bulwarkmail.template.x': {} } })); + await loadSharedTags(KEY, 'team', client); + expect(entry()).toMatchObject({ status: 'ready', tags: [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, (list) => [...list, { id: 'later', label: 'Later', 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': { list: [URGENT, { id: 'later', label: 'Later', color: 'blue' }] } } }, + }); + }); + + it('turns a dropped metadata request into "not supported" without marking the login offline', async () => { + const { client } = await connected((body) => { + if (body.methodCalls[0][0] === 'Mailbox/query') throw new TypeError('Failed to fetch'); + return answers(body, {}); + }); + const connection = vi.fn(); + client.onConnectionChange(connection); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('unsupported'); + expect(connection).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/stores/shared-tags-store.ts b/stores/shared-tags-store.ts new file mode 100644 index 000000000..c3dbcba11 --- /dev/null +++ b/stores/shared-tags-store.ts @@ -0,0 +1,229 @@ +/** + * 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, and + * read again when the group's mailboxes change (push) or the tab comes back + * after a while. + * + * Why push and not polling: Stalwart bumps the Mailbox state of the group + * account when its Inbox metadata changes, and every member's push channel + * reports state changes of the accounts it can reach. So another member's + * edit arrives as a Mailbox change for the group account, which costs + * nothing extra to watch. Push can be off or broken (no EventSource, a + * proxy that buffers it), so a tab that comes back to the foreground also + * reads definitions older than two minutes again. + * + * Status per account: + * - `personal`: not a group principal (a mailbox a person shared); it keeps + * the user's own tags. + * - `unsupported`: a group, but the server keeps no metadata (0.16) or the + * request failed (the 1.0 test build drops it); the group keeps the user's + * own tags, as before, and the shared definitions can't be edited. + * - `ready`: the shared definitions, plus the tags found on recent messages. + */ + +import { create } from 'zustand'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata'; +import { tagIdFromKeyword } from '@/lib/keyword-discovery'; +import { SHARED_TAGS_KEY, parseSharedTags, sharedTagsValue } from '@/lib/shared-tags'; +import { subscribePushStateChanges } from '@/lib/push-events'; +import type { KeywordDefinition } from '@/stores/settings-store'; + +export type SharedTagsStatus = 'loading' | 'personal' | 'unsupported' | 'ready'; + +export interface SharedTagsEntry { + status: SharedTagsStatus; + /** The shared definitions (status `ready` only). */ + tags: KeywordDefinition[]; + /** Tag keywords found on the account's recent messages, with counts. */ + discovered: Record; + loadedAt: number; +} + +export type SharedTagsClient = Pick + & Partial>; + +/** Messages scanned for tags nobody defined: 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; + +interface SharedTagsState { + entries: Record; +} + +export const useSharedTagsStore = create()(() => ({ entries: {} })); + +export function sharedTagsKey(loginId: string, accountId: string): string { + return `${loginId}|${accountId}`; +} + +const clients = new Map(); +const inflight = new Map>(); +const accountIds = new Map(); +let unsubscribePush: (() => void) | null = null; +let visibilityBound = false; + +function setEntry(key: string, patch: Partial): void { + useSharedTagsStore.setState((state) => { + const previous = state.entries[key] ?? { status: 'loading' as const, tags: [], discovered: {}, loadedAt: 0 }; + return { entries: { ...state.entries, [key]: { ...previous, ...patch } } }; + }); +} + +/** Whether the account is a group principal (policySubjectForClient's rule). */ +async function isGroupAccount(client: SharedTagsClient, accountId: string): Promise { + const types = typeof client.getPrincipalTypes === 'function' + ? await client.getPrincipalTypes([accountId]).catch(() => null) + : null; + if (types) return types[accountId] === 'group'; + // The server won't say: every non-personal account counts. + return client.getAccountInfo?.(accountId)?.isPersonal === false; +} + +async function discover(key: string, client: SharedTagsClient, accountId: string): Promise { + try { + const scan = await client.discoverKeywords({ accountId, limit: DISCOVERY_SCAN_LIMIT }); + if (clients.get(key) !== client) return; + const tags: Record = {}; + 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, withDiscovery: boolean): Promise { + const known = useSharedTagsStore.getState().entries[key]; + const isGroup = known && known.status !== 'loading' && known.status !== 'personal' + ? true + : known?.status === 'personal' + ? false + : await isGroupAccount(client, accountId); + if (clients.get(key) !== client) return; + if (!isGroup) { + setEntry(key, { status: 'personal', tags: [], loadedAt: Date.now() }); + return; + } + // Checked first: 0.16 has no metadata and gets no request at all. + if (typeof client.supportsBulwarkMetadata !== 'function' || !client.supportsBulwarkMetadata(accountId) || typeof client.getBulwarkMetadata !== 'function') { + setEntry(key, { status: 'unsupported', tags: [], loadedAt: Date.now() }); + return; + } + // getBulwarkMetadata sends its request on its own and answers null on any + // failure, a dropped connection included, so nothing here can mark the + // login offline or take other calls down with it. + const metadata = await client.getBulwarkMetadata(accountId); + if (clients.get(key) !== client) return; + if (!metadata) { + setEntry(key, { status: 'unsupported', tags: [], loadedAt: Date.now() }); + return; + } + setEntry(key, { status: 'ready', tags: parseSharedTags(metadata[SHARED_TAGS_KEY]), loadedAt: Date.now() }); + if (withDiscovery) 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); + if (!accountId || !change.changed[accountId]?.Mailbox) continue; + if (useSharedTagsStore.getState().entries[key]?.status !== 'ready') continue; + void loadSharedTags(key, accountId, known, { force: true }); + } + }); + } + if (!visibilityBound && typeof document !== 'undefined') { + visibilityBound = true; + document.addEventListener('visibilitychange', () => { + 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 || entry.status !== 'ready' || now - entry.loadedAt < STALE_AFTER_MS) continue; + void loadSharedTags(key, accountId, client, { force: true, discover: true }); + } + }); + } +} + +/** + * Loads an account's shared tags once per client; `force` reads them again + * (and `discover` scans the recent messages again too). + */ +export function loadSharedTags( + key: string, + accountId: string, + client: SharedTagsClient, + options: { force?: boolean; discover?: boolean } = {}, +): Promise { + const running = inflight.get(key); + if (running && clients.get(key) === client) return running; + const sameClient = clients.get(key) === client; + if (sameClient && !options.force && useSharedTagsStore.getState().entries[key]) return Promise.resolve(); + if (!sameClient) { + // Another sign-in of this login: nothing of the old one carries over. + useSharedTagsStore.setState((state) => { + const entries = { ...state.entries }; + delete entries[key]; + return { entries }; + }); + } + clients.set(key, client); + accountIds.set(key, accountId); + startWatching(); + if (!useSharedTagsStore.getState().entries[key]) setEntry(key, { status: 'loading' }); + const run = read(key, client, accountId, !sameClient || !!options.discover) + .catch(() => { + if (clients.get(key) === client && useSharedTagsStore.getState().entries[key]?.status === 'loading') { + setEntry(key, { status: 'unsupported', loadedAt: Date.now() }); + } + }) + .finally(() => { + if (inflight.get(key) === run) inflight.delete(key); + }); + inflight.set(key, run); + return run; +} + +/** + * Changes the shared definitions of an account: reads the newest ones from + * the server first, so two members editing at once don't undo each other's + * other tags, applies `change`, and writes the result back. Throws + * MetadataTooLargeError when the list no longer fits, MetadataUnsupportedError + * when the account has no metadata. + */ +export async function saveSharedTags( + key: string, + accountId: string, + client: SharedTagsClient, + change: (current: KeywordDefinition[]) => KeywordDefinition[], +): Promise { + if (typeof client.getBulwarkMetadata !== 'function' || typeof client.setBulwarkMetadata !== 'function') { + throw new MetadataUnsupportedError(); + } + const metadata = await client.getBulwarkMetadata(accountId); + if (!metadata) throw new MetadataUnsupportedError(); + const value = sharedTagsValue(change(parseSharedTags(metadata[SHARED_TAGS_KEY]))); + await client.setBulwarkMetadata(accountId, SHARED_TAGS_KEY, value); + clients.set(key, client); + accountIds.set(key, accountId); + setEntry(key, { status: 'ready', tags: value.list, loadedAt: Date.now() }); + return value.list; +} + +/** For tests: forget every account. */ +export function resetSharedTags(): void { + clients.clear(); + inflight.clear(); + accountIds.clear(); + unsubscribePush?.(); + unsubscribePush = null; + useSharedTagsStore.setState({ entries: {} }); +} From 3d8a0cfd0bb4be596b95c9a1a98966ab96aaccf4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:46:04 +0100 Subject: [PATCH 02/10] feat: let admins lock shared tags per domain or group --- .../__tests__/shared-tags-section.test.tsx | 78 +++++++++++++++++++ .../org/sections/shared-tags-section.tsx | 21 ++++- lib/admin/restriction-keys.ts | 9 +++ 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 components/admin/org/__tests__/shared-tags-section.test.tsx diff --git a/components/admin/org/__tests__/shared-tags-section.test.tsx b/components/admin/org/__tests__/shared-tags-section.test.tsx new file mode 100644 index 000000000..621994ded --- /dev/null +++ b/components/admin/org/__tests__/shared-tags-section.test.tsx @@ -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> = []; +let stored: Record = {}; +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 | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +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(); + 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: {} } } }], + }); + }); +}); diff --git a/components/admin/org/sections/shared-tags-section.tsx b/components/admin/org/sections/shared-tags-section.tsx index 978c1a56d..847748d3c 100644 --- a/components/admin/org/sections/shared-tags-section.tsx +++ b/components/admin/org/sections/shared-tags-section.tsx @@ -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 ( - -

Nothing to set here yet.

+ +
+ {restrictionKeysForPage('org-sharing') + .filter((def) => def.key === 'sharedTags') + .map((def) => )} +
); } diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index 7d00d4103..92b21d081 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -51,6 +51,15 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [ page: 'org-signatures', modes: ['locked', 'hidden'], }, + { + 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'], + }, ]; export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] { From 528450db61140f1fbbeae4b41b604fefd495916e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:10:42 +0100 Subject: [PATCH 03/10] fix: name the edit button of a shared tag after the tag, not the list --- components/admin/org/sections/shared-tags-section.tsx | 2 +- components/layout/sidebar.tsx | 1 + components/settings/shared-tag-settings.tsx | 4 ++-- locales/de/common.json | 1 + locales/en/common.json | 1 + 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/components/admin/org/sections/shared-tags-section.tsx b/components/admin/org/sections/shared-tags-section.tsx index 847748d3c..4bd0c35d5 100644 --- a/components/admin/org/sections/shared-tags-section.tsx +++ b/components/admin/org/sections/shared-tags-section.tsx @@ -15,7 +15,7 @@ export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
{restrictionKeysForPage('org-sharing') diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index e75782412..f61ab4922 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -868,6 +868,7 @@ function SharedAccountTags({ onToggle={() => setExpanded((open) => !open)} onSettings={() => router.push(buildSettingsPath('organize', 'shared-tags'))} settingsTitle={t('edit')} + testId="section-shared-tags" isCollapsed={false} sub /> diff --git a/components/settings/shared-tag-settings.tsx b/components/settings/shared-tag-settings.tsx index 694c690a4..1301b5259 100644 --- a/components/settings/shared-tag-settings.tsx +++ b/components/settings/shared-tag-settings.tsx @@ -237,8 +237,8 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string type="button" onClick={() => { setEditing({ id: tag.id }); setConfirmDelete(null); }} className="grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground" - title={found ? t("name_it") : t("edit")} - aria-label={found ? t("name_it") : t("edit")} + title={found ? t("name_it") : t("edit_tag")} + aria-label={found ? t("name_it") : t("edit_tag")} > {found ? : } diff --git a/locales/de/common.json b/locales/de/common.json index 665df692d..740dc7289 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -332,6 +332,7 @@ "picker_heading": "Mit allen in diesem Postfach geteilt", "sidebar_heading": "Tags", "edit": "Geteilte Labels bearbeiten", + "edit_tag": "Label bearbeiten", "add": "Geteiltes Label hinzufügen", "name": "Name", "colour": "Farbe", diff --git a/locales/en/common.json b/locales/en/common.json index 258aa53a5..75f0e3ab1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -332,6 +332,7 @@ "picker_heading": "Shared with everyone in this mailbox", "sidebar_heading": "Tags", "edit": "Edit shared tags", + "edit_tag": "Edit tag", "add": "Add shared tag", "name": "Name", "colour": "Colour", From a19d95c4bb172fb7cd3d4f0950fb2c9a466631f0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:10:42 +0100 Subject: [PATCH 04/10] test: cover shared tags in group mailboxes against the test servers --- integration/tests/19-org-tags.spec.ts | 295 ++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 integration/tests/19-org-tags.spec.ts diff --git a/integration/tests/19-org-tags.spec.ts b/integration/tests/19-org-tags.spec.ts new file mode 100644 index 000000000..c8da119e0 --- /dev/null +++ b/integration/tests/19-org-tags.spec.ts @@ -0,0 +1,295 @@ +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 HAS_METADATA = ORG_SERVER === '1.0.0'; + +// --- JMAP, as org-alice, straight to the server -------------------------- + +type Call = [string, Record, string]; +let api: { url: string; team: string; inbox: string } | null = null; +let messageId: 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> { + if (!api) { + const session = await (await fetch(`${JMAP_URL}/.well-known/jmap`, { headers: { Authorization: auth() } })).json(); + const team = Object.entries(session.accounts as Record).find(([, a]) => a.name === ORG.team)![0]; + api = { url: `${JMAP_URL}${new URL(session.apiUrl).pathname}`, team, inbox: '' }; + const res = await jmapRaw([['Mailbox/query', { accountId: team, filter: { role: 'inbox' } }, 'q']]); + api.inbox = res[0][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 { + 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 { + await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags': null } } }, 's']], META_USING); +} + +async function messageKeywords(): Promise> { + 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 { + 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; + +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 } = await ensureApi(); + const res = await jmap([['Email/set', { + accountId: team, + create: { + m: { + mailboxIds: { [inbox]: true }, + from: [{ name: 'Org Alice', email: ORG.alice }], + to: [{ email: ORG.team }], + subject: SUBJECT, + keywords, + bodyValues: { b: { value: 'A message for the shared tags spec.' } }, + textBody: [{ partId: 'b', type: 'text/plain' }], + }, + }, + }, 'c']]); + messageId = res[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 (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({ list: [{ id: '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(); + 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'); + await bob.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 }>).find((s) => s.id === 'org-team')!; + expect(team.policy.restrictions.sharedTags).toEqual({ locked: true }); + expect((policy.restrictions as Record).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 tag' })).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); + await expect(account.getByTestId('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. 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([]); + await alice.context().close(); + }); +}); From a049282cc0ea4d1d24469e507172fbe2bd4d7105 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:15:14 +0100 Subject: [PATCH 05/10] test: check live edits and the unified inbox for shared tags --- integration/tests/19-org-tags.spec.ts | 79 +++++++++++++++++++++------ 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/integration/tests/19-org-tags.spec.ts b/integration/tests/19-org-tags.spec.ts index c8da119e0..1dc1d38f8 100644 --- a/integration/tests/19-org-tags.spec.ts +++ b/integration/tests/19-org-tags.spec.ts @@ -30,13 +30,16 @@ import { 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]; -let api: { url: string; team: string; inbox: string } | null = null; +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')}`; @@ -45,9 +48,14 @@ async function ensureApi(): Promise> { if (!api) { const session = await (await fetch(`${JMAP_URL}/.well-known/jmap`, { headers: { Authorization: auth() } })).json(); const team = Object.entries(session.accounts as Record).find(([, a]) => a.name === ORG.team)![0]; - api = { url: `${JMAP_URL}${new URL(session.apiUrl).pathname}`, team, inbox: '' }; - const res = await jmapRaw([['Mailbox/query', { accountId: team, filter: { role: 'inbox' } }, 'q']]); + 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; } @@ -118,27 +126,28 @@ test.beforeAll(async ({ browser }) => { // 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 } = await ensureApi(); - const res = await jmap([['Email/set', { - accountId: team, - create: { - m: { - mailboxIds: { [inbox]: true }, - from: [{ name: 'Org Alice', email: ORG.alice }], - to: [{ email: ORG.team }], - subject: SUBJECT, - keywords, - bodyValues: { b: { value: 'A message for the shared tags spec.' } }, - textBody: [{ partId: 'b', type: 'text/plain' }], - }, - }, - }, 'c']]); + const { team, inbox, own, ownInbox } = await ensureApi(); + const message = (mailbox: string, to: string, subject: string, kw: Record) => ({ + 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(); @@ -191,9 +200,43 @@ test.describe('on a server with metadata', () => { 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': { list: [{ id: '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': { list: [{ id: '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), + 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('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); From 97d686b916b00ca6c774766dcaab7e27ad8bf1e0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:18:16 +0100 Subject: [PATCH 06/10] test: check that the 1.0 test build was asked for shared tags and stayed online --- integration/tests/19-org-tags.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration/tests/19-org-tags.spec.ts b/integration/tests/19-org-tags.spec.ts index 1dc1d38f8..bb7f4019c 100644 --- a/integration/tests/19-org-tags.spec.ts +++ b/integration/tests/19-org-tags.spec.ts @@ -333,6 +333,8 @@ test.describe('on a server without metadata', () => { 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(); }); }); From 33ca8efb0f1cd2a662486b3f03e48bfe6b931726 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:58:11 +0100 Subject: [PATCH 07/10] fix: keep shared tags safe when members edit at once or the server fails The first version wrote the whole tag list on every change, guessed a group when the server wouldn't say, and treated a failed read like a server without metadata. Now: - Definitions are a map by tag id under `tags`, and each change patches one tag (`metadata/org.bulwarkmail.tags/tags/`), so two members adding tags at once both keep theirs. Fields and colors this version doesn't know survive. The `{ list }` and bare-array shapes are still read and converted by the first save. - Shared tags appear only for confirmed group principals. A failed principal lookup keeps the user's own tags and asks again later. - "No metadata advertised" (0.16) and "the read failed" are separate. A failed read keeps the last good tags; a failed save keeps the change, says so, and sends it with the next good read after the client's pause. - Push reloads wait for 5 s of quiet and skip the echo of our own save; a returning tab reloads definitions only, and the scan for unnamed tags runs once per sign-in and then at most every 30 minutes. - The editor names its buttons after the tag, shows the delete warning and 44 px buttons on phones, returns focus to the edit button, and asks for Inbox rights only for confirmed groups. --- .../org/sections/shared-tags-section.tsx | 2 +- .../__tests__/shared-tag-settings.test.tsx | 123 +++++-- components/settings/shared-tag-settings.tsx | 149 +++++--- hooks/__tests__/use-account-tags.test.tsx | 8 +- lib/__tests__/shared-tags.test.ts | 119 ++++--- lib/jmap/bulwark-metadata.ts | 17 + lib/jmap/client-interface.ts | 2 + lib/jmap/client.ts | 41 ++- lib/shared-tags.ts | 183 ++++++---- locales/de/common.json | 13 +- locales/en/common.json | 13 +- stores/__tests__/shared-tags-store.test.ts | 323 +++++++++++++----- stores/shared-tags-store.ts | 310 ++++++++++++----- 13 files changed, 922 insertions(+), 381 deletions(-) diff --git a/components/admin/org/sections/shared-tags-section.tsx b/components/admin/org/sections/shared-tags-section.tsx index 4bd0c35d5..8b19486e1 100644 --- a/components/admin/org/sections/shared-tags-section.tsx +++ b/components/admin/org/sections/shared-tags-section.tsx @@ -15,7 +15,7 @@ export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
{restrictionKeysForPage('org-sharing') diff --git a/components/settings/__tests__/shared-tag-settings.test.tsx b/components/settings/__tests__/shared-tag-settings.test.tsx index 114a61cb4..6d582d9c9 100644 --- a/components/settings/__tests__/shared-tag-settings.test.tsx +++ b/components/settings/__tests__/shared-tag-settings.test.tsx @@ -1,23 +1,31 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +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 } from '@/stores/shared-tags-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. +// an editor where the server keeps no metadata or couldn't be read. -const URGENT = { id: 'urgent', label: 'Urgent', color: 'red' }; +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; +let stored: Record | null; let keywords: Record; -let metadata: boolean; +let advertised: boolean; +let principalType: string; const client = { getAccountId: () => 'me', getAccountInfo: (id: string) => ({ isPersonal: id === 'me' }), @@ -25,12 +33,21 @@ const client = { { 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: 'group' }), - supportsBulwarkMetadata: () => metadata, - getBulwarkMetadata: vi.fn(async () => stored), - setBulwarkMetadata: vi.fn(async (_id: string | undefined, key: string, value: unknown) => { stored = { ...stored, [key]: value }; }), + 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) }; + 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: async () => [], + getMailboxes: vi.fn(async () => []), }; function setRights(rights: Record) { @@ -43,11 +60,13 @@ function setLocked(locked: boolean) { beforeEach(() => { resetSharedTags(); - stored = { tags: { list: [URGENT] } }; + stored = { tags: { urgent: { ...URGENT } } }; keywords = {}; - metadata = true; + advertised = true; + principalType = 'group'; client.getBulwarkMetadata.mockClear(); - client.setBulwarkMetadata.mockClear(); + client.patchBulwarkMetadata.mockClear(); + client.getMailboxes.mockClear(); useSettingsStore.setState({ emailKeywords: [], nestedTags: false }); useAuthStore.setState({ client: client as never, @@ -60,26 +79,53 @@ beforeEach(() => { afterEach(() => resetSharedTags()); describe('SharedTagSettings', () => { - it('lets a member who may change the Inbox add a shared tag', async () => { + it('lets a member who may change the Inbox add a shared tag, as one patch', async () => { render(); 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.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', { - list: [URGENT, { id: 'waiting', label: 'Waiting', color: 'blue' }], - })); + 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(); + 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(); + 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(); 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')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('delete')).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 () => { @@ -89,23 +135,50 @@ describe('SharedTagSettings', () => { expect(screen.queryByText('add')).not.toBeInTheDocument(); }); + it('asks for no rights of a mailbox a person shared', async () => { + principalType = 'individual'; + useEmailStore.setState({ mailboxes: [] }); + render(); + 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 () => { - metadata = false; + advertised = false; render(); 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(); + 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(); + 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(); - fireEvent.click(await screen.findByLabelText('name_it')); + 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.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', { - list: [URGENT, { id: 'waiting-for-customer', label: 'Customer', color: expect.any(String) }], - })); + await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [ + { path: ['tags', 'waiting-for-customer'], value: { label: 'Customer', color: expect.any(String) } }, + ])); }); }); diff --git a/components/settings/shared-tag-settings.tsx b/components/settings/shared-tag-settings.tsx index 1301b5259..7b7da3701 100644 --- a/components/settings/shared-tag-settings.tsx +++ b/components/settings/shared-tag-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +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"; @@ -9,12 +9,12 @@ 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, removeSharedTag, upsertSharedTag } from "@/lib/shared-tags"; +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 { useSettingsStore, type KeywordDefinition } from "@/stores/settings-store"; -import { saveSharedTags, sharedTagsKey, useSharedTagsStore } from "@/stores/shared-tags-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"; @@ -28,13 +28,16 @@ export function maySetSharedTags(rights: Partial | null | undefin return typeof rights.maySetMetadata === "boolean" ? rights.maySetMetadata : rights.mayRename === true; } -/** The user's rights on the Inbox of a group account, from the folder list or asked once. */ -function useInboxRights(scope: TagScope): Partial | null | undefined { +/** + * 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 | null | undefined { const fromStore = useEmailStore((state) => state.mailboxes.find((mailbox) => mailbox.accountId === scope.accountId && mailbox.role === "inbox")?.myRights); const [fetched, setFetched] = useState | null | undefined>(undefined); useEffect(() => { - if (fromStore) return; + if (!enabled || fromStore) return; let cancelled = false; const client = clientForLogin(scope.loginId) as unknown as { getMailboxes?: (accountId?: string) => Promise> } | null; if (!client?.getMailboxes) return; @@ -42,8 +45,8 @@ function useInboxRights(scope: TagScope): Partial | null | undefi .then((mailboxes) => { if (!cancelled) setFetched(mailboxes.find((m) => m.role === "inbox")?.myRights ?? null); }) .catch(() => { if (!cancelled) setFetched(null); }); return () => { cancelled = true; }; - }, [fromStore, scope.loginId, scope.accountId]); - return fromStore ?? fetched; + }, [enabled, fromStore, scope.loginId, scope.accountId]); + return enabled ? fromStore ?? fetched : undefined; } function SharedTagForm({ @@ -59,17 +62,22 @@ function SharedTagForm({ fixedId?: string; takenIds: string[]; saving: boolean; - onSave: (tag: KeywordDefinition) => void; + /** `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 save = () => { if (valid && !saving) onSave({ id, label: label.trim(), color }); }; + const keepsColor = !!initial && !colorPicked; + const save = () => { + if (valid && !saving) onSave(id, keepsColor ? { label: label.trim() } : { label: label.trim(), color }); + }; return (
@@ -94,14 +102,14 @@ function SharedTagForm({ {tooLong &&

{t("too_long")}

}
-

{t("colour")}

- +

{t("color")}

+ { setColor(next); setColorPicked(true); }} />
-
@@ -235,10 +272,11 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
@@ -246,9 +284,9 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string @@ -268,6 +306,9 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string )} {shared.map((tag) => row(tag, false))} {discovered.map((tag) => row(tag, true))} + {(entry?.pending.length ?? 0) > 0 && ( +

{t("save_pending")}

+ )} {locked ? ( ) : !canWrite ? ( @@ -279,14 +320,14 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string takenIds={takenIds} saving={saving} onCancel={() => setEditing(null)} - onSave={async (next) => { if (await save((list) => upsertSharedTag(list, next))) setEditing(null); }} + onSave={async (id, value) => { if (await save({ id, value })) { setEditing(null); setFocusId(id); } }} /> ) : (