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..8b19486e1 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/components/email/__tests__/email-context-menu-shared-tags.test.tsx b/components/email/__tests__/email-context-menu-shared-tags.test.tsx new file mode 100644 index 000000000..9f9e340a3 --- /dev/null +++ b/components/email/__tests__/email-context-menu-shared-tags.test.tsx @@ -0,0 +1,96 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +import { EmailContextMenu } from '../email-context-menu'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { loadSharedTags, resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store'; + +// The context menu's tag list for a selection offers the tags of the +// selection's account (commonTagScope, like the toolbar), not those of the +// row that was right-clicked. + +const TEAM = { loginId: 'login-1', accountId: 'team' }; + +// A message in the group account, right-clicked. +const email = { + id: 'e1', + threadId: 't1', + mailboxIds: { 'team:inbox': true }, + keywords: { $seen: true }, + from: [{ email: 'alice@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + size: 100, + sourceClientAccountId: 'login-1', + sourceAccountId: 'team', +} as unknown as Email; + +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; + +function openTags(batchTagScope: typeof TEAM | null) { + render( + , + ); + act(() => { fireEvent.mouseEnter(screen.getByTestId('ctx-tag')); }); +} + +beforeEach(async () => { + resetSharedTags(); + // The group's tags come through the real loading path. + const client = { + getAccountId: () => 'me', + getSieveAccountId: () => 'me', + supportsSieve: () => false, + getSharedAccounts: () => [], + getAccountInfo: () => ({ isPersonal: false }), + getPrincipalTypes: async () => ({ team: 'group' }), + advertisesBulwarkMetadata: () => true, + supportsBulwarkMetadata: () => true, + getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }), + discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }), + }; + useAuthStore.setState({ + client: client as never, + activeAccountId: 'login-1', + getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never, + }); + useSettingsStore.setState({ emailKeywords: [{ id: 'mine', label: 'Mine', color: 'blue' }], nestedTags: false }); + await loadSharedTags(sharedTagsKey(TEAM.loginId, TEAM.accountId), TEAM.accountId, client); + expect(useSharedTagsStore.getState().entries[sharedTagsKey(TEAM.loginId, TEAM.accountId)]?.status).toBe('ready'); +}); +afterEach(() => resetSharedTags()); + +describe('EmailContextMenu tags for a selection', () => { + it('offers the group\'s shared tags when the whole selection is in the group', async () => { + openTags(TEAM); + expect(await screen.findByText('Team urgent')).toBeInTheDocument(); + expect(screen.queryByText('Mine')).not.toBeInTheDocument(); + }); + + it('offers the own tags when the selection mixes accounts, even from a group row', async () => { + openTags(null); + expect(await screen.findByText('Mine')).toBeInTheDocument(); + expect(screen.queryByText('Team urgent')).not.toBeInTheDocument(); + }); +}); 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 084ecd5bc..eb7acbfd8 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, type TagScope } from "@/hooks/use-account-tags"; import { Email, Mailbox } from "@/lib/jmap/types"; import { ContextMenu, @@ -112,6 +113,11 @@ interface EmailContextMenuProps { /** Tags every selected message carries, and those only some of them do. */ batchTagIds?: string[]; batchPartialTagIds?: string[]; + /** + * The account of the whole selection (commonTagScope): its tags are + * offered. Null when the selection mixes accounts: the user's own tags. + */ + batchTagScope?: TagScope | null; /** Takes the tag off when the whole selection has it, else puts it on all. */ onBatchToggleTag?: (tagId: string) => void; onBatchDelete?: () => void; @@ -179,6 +185,7 @@ export function EmailContextMenu({ onBatchMarkAsRead, batchTagIds, batchPartialTagIds, + batchTagScope = null, onBatchToggleTag, onBatchDelete, onBatchArchive, @@ -206,6 +213,8 @@ export function EmailContextMenu({ const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; const currentTagIds = getEmailTagIds(email.keywords); + // The row's account names its tags; a selection uses batchTagScope. + const tagScope = useEmailTagScope(email); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action @@ -503,11 +512,13 @@ export function EmailContextMenu({ selectedIds={batchTagIds ?? []} partialIds={batchPartialTagIds} onToggle={(tagId) => onBatchToggleTag?.(tagId)} + scope={batchTagScope} /> ) : ( onSetTag?.(tagId)} + scope={tagScope} /> )} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 53c098492..9f7c63c3c 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, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions"; @@ -111,6 +112,7 @@ export function EmailHoverActions({ spamApplicable = true, mailboxRole = null, }: EmailHoverActionsProps) { + const tagScope = useEmailTagScope(email); const configuredHoverActions = useSettingsStore((state) => state.hoverActions); const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); const archiveActionEnabled = useArchiveActionEnabled(); @@ -294,6 +296,7 @@ export function EmailHoverActions({ > onSetTag(tagId)} /> , diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 7cbdd5389..68d50a308 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -44,6 +44,7 @@ import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; import { unreadSectionRowCount } from "@/lib/unread-first"; import { MvListSectionHeader } from "./mv-list-section-header"; import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results"; +import { commonTagScope } from "@/hooks/use-account-tags"; interface EmailListProps { emails: Email[]; @@ -256,6 +257,7 @@ export function EmailList({ const listHold = useEmailStore((state) => state.listHold); const viewingAccountId = useEmailStore((state) => state.viewingAccountId); const selectedKeyword = useEmailStore((state) => state.selectedKeyword); + const selectedKeywordAccountId = useEmailStore((state) => state.selectedKeywordAccountId); const searchMailboxId = useEmailStore((state) => state.searchMailboxId); const activeAccountId = useAuthStore((state) => state.activeAccountId); const activeTabId = useMessageListTabsStore((state) => state.activeTabId); @@ -511,6 +513,7 @@ export function EmailList({ viewingAccountId, selectedMailbox, selectedKeyword, + selectedKeywordAccountId, isUnifiedView && (crossView ?? unifiedRole), isScheduledView, activeTabId, @@ -587,7 +590,8 @@ export function EmailList({ const all: string[] = []; const some: string[] = []; for (const [tagId, count] of counts) (count === selected.length ? all : some).push(tagId); - return { all, some }; + // A selection within one group account offers that account's shared tags. + return { all, some, scope: commonTagScope(selected) }; }, [emails, selectedEmailKeys]); // A tag every selected message has comes off; any other goes onto all of @@ -860,6 +864,7 @@ export function EmailList({ title={tContextMenu('tag')} selectedIds={selectionTags.all} partialIds={selectionTags.some} + scope={selectionTags.scope} onToggle={handleBatchToggleTag} active={hasSelection && !isScheduledView} disabled={isProcessing} @@ -1182,6 +1187,7 @@ export function EmailList({ onBatchMarkAsRead={(read) => markSelectionRead(read)} batchTagIds={selectionTags.all} batchPartialTagIds={selectionTags.some} + batchTagScope={selectionTags.scope} onBatchToggleTag={handleBatchToggleTag} onBatchDelete={async () => { if (!client) return; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 4e1025735..821c94418 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); @@ -4110,6 +4113,7 @@ export function EmailViewer({
{ if (email) onSetTag?.(email.id, tagId); }} />
@@ -4404,6 +4408,7 @@ export function EmailViewer({ > { if (email) onSetTag?.(email.id, tagId); }} /> @@ -4865,6 +4870,7 @@ export function EmailViewer({ { if (email) onSetTag?.(email.id, tagId); }} /> )} @@ -4948,6 +4954,7 @@ export function EmailViewer({ key={tagId} tagId={tagId} variant={headerTagVariant} + scope={tagScope} onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined} /> ))} 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/__tests__/sidebar-shared-tags.test.tsx b/components/layout/__tests__/sidebar-shared-tags.test.tsx new file mode 100644 index 000000000..612982a17 --- /dev/null +++ b/components/layout/__tests__/sidebar-shared-tags.test.tsx @@ -0,0 +1,110 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// A group account's shared tags under its folders in the Shared section: +// counted in that account only, and picking one lists that account's mail +// with the tag, without selecting the user's own tag of the same id. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); + +const { Sidebar } = await import('../sidebar'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useEmailStore } = await import('@/stores/email-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { usePolicyStore } = await import('@/stores/policy-store'); +const { DEFAULT_POLICY } = await import('@/lib/admin/types'); +const { loadSharedTags, resetSharedTags, sharedTagsKey } = await import('@/stores/shared-tags-store'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } }, + { id: 'team:a', originalId: 'a', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, accountId: 'team', accountName: 'org-team@example.org', isShared: true }, + { id: 'team:t', originalId: 't', name: 'Trash', role: 'trash', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, accountId: 'team', accountName: 'org-team@example.org', isShared: true }, +] as unknown as Mailbox[]; + +const client = { + getAccountId: () => 'me', + getAccountInfo: () => ({ isPersonal: false }), + getPrincipalTypes: async () => ({ team: 'group' }), + advertisesBulwarkMetadata: () => true, + supportsBulwarkMetadata: () => true, + getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }), + discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }), + getTagCounts: vi.fn(async () => ({ urgent: { total: 5, unread: 2 } })), +}; + +async function renderSidebar(onTagSelect = vi.fn()) { + render(); + const account = screen.getByTestId('section-shared-account'); + if (account.getAttribute('data-expanded') !== 'true') fireEvent.click(account); + return within(await screen.findByTestId('shared-account-tags')); +} + +beforeEach(async () => { + localStorage.clear(); + localStorage.setItem('sidebarSharedExpanded', 'true'); + resetSharedTags(); + client.getTagCounts.mockClear(); + useAuthStore.setState({ + client: client as never, + activeAccountId: 'login-1', + getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never, + getAllConnectedClients: (() => new Map([['login-1', client]])) as never, + }); + useEmailStore.setState({ mailboxes, selectedKeyword: null, selectedKeywordAccountId: null, tagCounts: {} }); + useSettingsStore.setState({ + hideAccountSwitcher: true, + enableUnifiedMailbox: false, + showSharedMailAccounts: true, + hiddenSharedMailAccounts: {}, + nestedTags: false, + emailKeywords: [{ id: 'urgent', label: 'My urgent', color: 'blue' }], + } as never); + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY } }); + await loadSharedTags(sharedTagsKey('login-1', 'team'), 'team', client); +}); +afterEach(() => resetSharedTags()); + +describe('Sidebar shared tags', () => { + it('counts a shared tag in its group account only, outside Trash', async () => { + const tags = await renderSidebar(); + expect(tags.getByText('Team urgent')).toBeInTheDocument(); + await waitFor(() => expect(client.getTagCounts).toHaveBeenCalledWith(['urgent'], 'team', ['t'])); + await waitFor(() => expect(tags.getByText('2')).toBeInTheDocument()); + }); + + it('opens the tag view of that account and leaves the own tag of the same id unselected', async () => { + const onTagSelect = vi.fn(); + const tags = await renderSidebar(onTagSelect); + fireEvent.click(tags.getByText('Team urgent')); + expect(onTagSelect).toHaveBeenCalledWith('urgent', 'team'); + + // The store now lists that account only; the sidebar marks the group's row. + act(() => useEmailStore.getState().selectKeyword('urgent', 'team')); + expect(useEmailStore.getState().selectedKeywordAccountId).toBe('team'); + cleanup(); + const after = await renderSidebar(onTagSelect); + expect(after.getByText('Team urgent').closest('[data-testid="folder-row"]')).toHaveAttribute('data-selected', 'true'); + expect(screen.getByText('My urgent').closest('[data-testid="folder-row"]')).not.toHaveAttribute('data-selected'); + + // The own tag picked from the own list: the other way round. + act(() => useEmailStore.getState().selectKeyword('urgent')); + cleanup(); + const own = await renderSidebar(onTagSelect); + expect(own.getByText('Team urgent').closest('[data-testid="folder-row"]')).not.toHaveAttribute('data-selected'); + expect(screen.getByText('My urgent').closest('[data-testid="folder-row"]')).toHaveAttribute('data-selected', 'true'); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index d9019376e..e2ab5151f 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useState, useEffect, useMemo, Fragment, ReactNode } from "react"; +import { memo, useState, useEffect, useMemo, useCallback, Fragment, ReactNode } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { PluginSlot } from "@/components/plugins/plugin-slot"; @@ -52,6 +52,7 @@ import { } from "@/lib/keyword-nesting"; import { useShortenedText } from "@/hooks/use-shortened-text"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { TagScopeContext, useAccountTags } from "@/hooks/use-account-tags"; import { isEditableEventTarget } from "@/lib/keyboard"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -70,7 +71,8 @@ import { unifiedLoadProgress } from "@/lib/unified-mailbox"; import { useVacationStore } from "@/stores/vacation-store"; import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; -import { useEmailStore } from "@/stores/email-store"; +import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store"; +import { trashAndJunkIds } from "@/lib/unified-mailbox"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; import { AccountSwitcher } from "./account-switcher"; @@ -96,7 +98,7 @@ interface SidebarProps { selectedMailbox?: string; selectedKeyword?: string | null; onMailboxSelect?: (mailboxId: string) => void; - onTagSelect?: (keywordId: string | null) => void; + onTagSelect?: (keywordId: string | null, accountId?: string | null) => void; onCompose?: () => void; /** * Mountain View, desktop only: the rail grows a Compose pill at the top and @@ -743,7 +745,7 @@ function TagItem({ selectedKeyword: string | null; expandedTags: Set; isCollapsed: boolean; - onTagSelect?: (keywordId: string | null) => void; + onTagSelect?: (keywordId: string | null, accountId?: string | null) => void; onToggleExpand: (keywordId: string) => void; tagCounts: Record; colorful: boolean; @@ -822,6 +824,106 @@ function TagItem({ ); } +type TagCounts = Record; +const NO_TAG_COUNTS: TagCounts = {}; + +/** + * Counts of a group account's shared tags in that account only, outside + * Trash and Junk like the tag view. Asked again whenever the sidebar's own + * tag counts are (every mail change), and when the tags change. + */ +function useSharedTagCounts(accountId: string, ids: string[], enabled: boolean): TagCounts { + const client = useAuthStore((s) => s.client); + const refresh = useEmailStore((s) => s.tagCounts); + const idsKey = ids.join('\n'); + const [counts, setCounts] = useState(NO_TAG_COUNTS); + useEffect(() => { + if (!enabled || !client || !idsKey) return; + const account = buildTagViewAccountClients(client).find((a) => a.jmapAccountId === accountId); + if (!account) return; + let cancelled = false; + client.getTagCounts(idsKey.split('\n'), accountId, trashAndJunkIds(account)) + .then((next) => { if (!cancelled) setCounts(next); }) + .catch(() => { /* the rows show no count */ }); + return () => { cancelled = true; }; + }, [client, accountId, idsKey, refresh, enabled]); + return counts; +} + +/** + * The tags of a group account, under its folders in the Shared section: the + * shared definitions plus the tags found on its messages (useAccountTags), + * with their counts in that account. Picking one lists that account's + * messages with the tag. Nothing for a mailbox a person shared or a server + * without metadata, where the user's own tags above stand for every account + * as before. + */ +function SharedAccountTags({ + loginId, + accountId, + selectedKeyword, + expandedTags, + onTagSelect, + onToggleExpand, + colorful, +}: { + loginId: string; + accountId: string; + /** The tag picked in this account's list, if any. */ + selectedKeyword: string | null; + expandedTags: Set; + onTagSelect?: (keywordId: string | null, accountId?: string | null) => void; + onToggleExpand: (keywordId: string) => void; + colorful: boolean; +}) { + const t = useTranslations('shared_tags'); + const router = useRouter(); + const scope = useMemo(() => ({ loginId, accountId }), [loginId, accountId]); + const { definitions, shared } = useAccountTags(scope); + const nestedTags = useSettingsStore((s) => s.nestedTags); + const [expanded, setExpanded] = useState(true); + const tree = useMemo( + () => (nestedTags ? buildKeywordTree(definitions) : definitions.map((kw) => ({ ...kw, children: [], depth: 0 }))), + [definitions, nestedTags], + ); + const ids = useMemo(() => definitions.map((kw) => kw.id), [definitions]); + const counts = useSharedTagCounts(accountId, ids, shared && expanded); + const select = useCallback( + (keywordId: string | null) => onTagSelect?.(keywordId, keywordId ? accountId : null), + [onTagSelect, accountId], + ); + if (!shared || definitions.length === 0) return null; + return ( + +
+ setExpanded((open) => !open)} + onSettings={() => router.push(buildSettingsPath('organize', 'shared-tags'))} + settingsTitle={t('edit')} + testId="section-shared-tags" + 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 }))); @@ -1013,6 +1115,9 @@ function SidebarImpl({ const emailKeywords = useSettingsStore(s => s.emailKeywords); const nestedTags = useSettingsStore(s => s.nestedTags); const tagsAboveShared = usePolicyStore(s => s.isFeatureEnabled('tagsAboveSharedEnabled')); + // A tag picked in a group account's shared tags (see SharedAccountTags). + const selectedKeywordAccountId = useEmailStore(s => s.selectedKeywordAccountId); + const personalSelectedKeyword = selectedKeywordAccountId ? null : selectedKeyword; const isEmbedded = useIsEmbedded(); // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's // own AccountSwitcher would be a redundant second account UI in the same @@ -1174,7 +1279,7 @@ function SidebarImpl({ // filling it back in. A tag the server answered for with zero unread hides, // which is the point of the setting. const isTagVisible = (node: KeywordNode) => { - if (showAllTags || node.id === selectedKeyword) return true; + if (showAllTags || node.id === personalSelectedKeyword) return true; const visibility = getKeywordVisibility(node); if (visibility === 'hide') return false; if (visibility === 'unread') { @@ -1312,7 +1417,7 @@ function SidebarImpl({ child.role === 'drafts') && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} + {menuAccountId && activeAccountId && ( + + )} )}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 46d7d0201..3d8d3e4f7 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -3311,9 +3311,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }; - const handleTagSelect = async (keywordId: string | null) => { + const handleTagSelect = async (keywordId: string | null, accountId: string | null = null) => { setScheduledView(false); - selectKeyword(keywordId); + selectKeyword(keywordId, accountId); // On mobile, close sidebar and go to list view if (isMobile) { 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..6d582d9c9 --- /dev/null +++ b/components/settings/__tests__/shared-tag-settings.test.tsx @@ -0,0 +1,192 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Names with their tag in them: the global mock returns the bare key. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: { name?: string }) => (params?.name ? `${key}:${params.name}` : key), + useLocale: () => 'en', +})); + +import { SharedTagSettings, maySetSharedTags } from '../shared-tag-settings'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store'; + +// Settings > Folders & tags > Shared tags: the editor of each group +// mailbox's tags, read-only when the admin locked them, and a note instead of +// an editor where the server keeps no metadata or couldn't be read. + +const URGENT = { label: 'Urgent', color: 'red' }; +const RIGHTS = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true }; + +let stored: Record | null; +let keywords: Record; +let advertised: boolean; +let principalType: string; +const client = { + getAccountId: () => 'me', + getAccountInfo: (id: string) => ({ isPersonal: id === 'me' }), + getSharedAccounts: () => [ + { id: 'me', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } }, + { id: 'team', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } }, + ], + getPrincipalTypes: async () => ({ team: principalType }), + advertisesBulwarkMetadata: () => advertised, + supportsBulwarkMetadata: () => advertised, + getBulwarkMetadata: vi.fn(async () => (stored ? { tags: stored } : null)), + patchBulwarkMetadata: vi.fn(async (_id: string | undefined, _key: string, patches: Array<{ path: string[]; value: unknown }>) => { + const tags = { ...((stored?.tags ?? {}) as Record) }; + for (const { path, value } of patches) { + if (value === null) delete tags[path[1]]; + else tags[path[1]] = value; + } + stored = { tags }; + return { newState: 'sX' }; + }), + discoverKeywords: async () => ({ keywords, scanned: 0, total: 0, complete: true }), + getMailboxes: vi.fn(async () => []), +}; + +function setRights(rights: Record) { + useEmailStore.setState({ mailboxes: [{ id: 'team:a', name: 'Inbox', role: 'inbox', accountId: 'team', isShared: true, myRights: rights } as never] }); +} + +function setLocked(locked: boolean) { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: locked ? { sharedTags: { locked: true } } : {} } }); +} + +beforeEach(() => { + resetSharedTags(); + stored = { tags: { urgent: { ...URGENT } } }; + keywords = {}; + advertised = true; + principalType = 'group'; + client.getBulwarkMetadata.mockClear(); + client.patchBulwarkMetadata.mockClear(); + client.getMailboxes.mockClear(); + useSettingsStore.setState({ emailKeywords: [], nestedTags: false }); + useAuthStore.setState({ + client: client as never, + activeAccountId: 'login-1', + getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never, + }); + setLocked(false); + setRights({ ...RIGHTS, maySetMetadata: true }); +}); +afterEach(() => resetSharedTags()); + +describe('SharedTagSettings', () => { + it('lets a member who may change the Inbox add a shared tag, as one patch', async () => { + render(); + expect(await screen.findByText('Urgent')).toBeInTheDocument(); + fireEvent.click(screen.getByText('add')); + fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Waiting' } }); + fireEvent.click(screen.getByText('save')); + await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [ + { path: ['tags', 'waiting'], value: { label: 'Waiting', color: 'blue' } }, + ])); + expect(await screen.findByText('Waiting')).toBeInTheDocument(); + }); + + it('names the buttons after their tag and puts focus back on the edit button after saving', async () => { + render(); + 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_tag/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^delete_tag/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^name_it/)).not.toBeInTheDocument(); + }); + + it('is read-only with a note for a member without the right to change the Inbox', async () => { + setRights({ ...RIGHTS, maySetMetadata: false }); + render(); + expect(await screen.findByTestId('shared-tags-no-rights')).toHaveTextContent('no_rights'); + expect(screen.queryByText('add')).not.toBeInTheDocument(); + }); + + it('asks for no rights of a mailbox a person shared', async () => { + principalType = 'individual'; + useEmailStore.setState({ mailboxes: [] }); + render(); + await waitFor(() => expect(useSharedTagsStore.getState().entries[sharedTagsKey('login-1', 'team')]?.status).toBe('personal')); + expect(client.getMailboxes).not.toHaveBeenCalled(); + expect(screen.queryByTestId('shared-tags-account')).not.toBeInTheDocument(); + }); + + it('says in one line that the server keeps no shared tags, with no editor', async () => { + advertised = false; + render(); + 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:waiting-for-customer')); + fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Customer' } }); + fireEvent.click(screen.getByText('save')); + await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [ + { path: ['tags', 'waiting-for-customer'], value: { label: 'Customer', color: expect.any(String) } }, + ])); + }); +}); + +describe('maySetSharedTags', () => { + it('follows maySetMetadata, and renaming where the server has no such right', () => { + expect(maySetSharedTags({ maySetMetadata: true, mayRename: false })).toBe(true); + expect(maySetSharedTags({ maySetMetadata: false, mayRename: true })).toBe(false); + expect(maySetSharedTags({ mayRename: true })).toBe(true); + expect(maySetSharedTags(null)).toBe(false); + }); +}); 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..7b7da3701 --- /dev/null +++ b/components/settings/shared-tag-settings.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Check, Loader2, Pencil, Plus, Trash2, Users, X } from "@/components/icons"; +import { TagBadge } from "@/components/email/tag-badge"; +import { useAccountTags, TagScopeContext, clientForLogin, type TagScope } from "@/hooks/use-account-tags"; +import { useRestriction } from "@/hooks/use-restriction"; +import { MetadataTooLargeError } from "@/lib/jmap/bulwark-metadata"; +import type { MailboxRights } from "@/lib/jmap/types"; +import { composeKeywordId, MAX_KEYWORD_ID_LENGTH } from "@/lib/keyword-nesting"; +import { MAX_SHARED_TAG_LABEL, groupAccountTags, type SharedTagChange } from "@/lib/shared-tags"; +import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { KEYWORD_PALETTE, useSettingsStore, type KeywordDefinition } from "@/stores/settings-store"; +import { SharedTagsSaveDeferredError, saveSharedTags, sharedTagsKey, useSharedTagsStore } from "@/stores/shared-tags-store"; +import { toast } from "@/stores/toast-store"; +import { toUnicodeEmail } from "@/lib/idn"; +import { KeywordColorPicker } from "./keyword-settings"; +import { OrgLockedNote } from "./org-locked-note"; +import { SettingsSection } from "./settings-section"; + +/** Whether the rights on a group's Inbox let the user change its metadata. */ +export function maySetSharedTags(rights: Partial | null | undefined): boolean { + if (!rights) return false; + // Stalwart 1.0 names the right; a server that doesn't counts renaming. + return typeof rights.maySetMetadata === "boolean" ? rights.maySetMetadata : rights.mayRename === true; +} + +/** + * The user's rights on the Inbox of a confirmed group account, from the + * folder list or asked once. Nothing is asked for any other account. + */ +function useInboxRights(scope: TagScope, enabled: boolean): Partial | 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 (!enabled || 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; }; + }, [enabled, fromStore, scope.loginId, scope.accountId]); + return enabled ? fromStore ?? fetched : undefined; +} + +function SharedTagForm({ + initial, + fixedId, + takenIds, + saving, + onSave, + onCancel, +}: { + initial?: KeywordDefinition; + /** The id stays: an existing tag or one found on messages. */ + fixedId?: string; + takenIds: string[]; + saving: boolean; + /** `color` only when it was picked (or the tag is new), so a color this version doesn't know survives a rename. */ + onSave: (id: string, value: { label: string; color?: string }) => void; + onCancel: () => void; +}) { + const t = useTranslations("shared_tags"); + const [label, setLabel] = useState(initial?.label ?? ""); + const [color, setColor] = useState(initial?.color ?? "blue"); + const [colorPicked, setColorPicked] = useState(false); + const id = fixedId ?? composeKeywordId(null, label); + const duplicate = !fixedId && id.length > 0 && takenIds.includes(id.toLowerCase()); + const tooLong = !fixedId && id.length > MAX_KEYWORD_ID_LENGTH; + const valid = label.trim().length > 0 && id.length > 0 && !duplicate && !tooLong; + const keepsColor = !!initial && !colorPicked; + const save = () => { + if (valid && !saving) onSave(id, keepsColor ? { label: label.trim() } : { label: label.trim(), color }); + }; + + return ( +
+
+ + 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("color")}

+ { setColor(next); setColorPicked(true); }} /> +
+
+ + +
+
+ ); +} + +const ICON_BUTTON = "grid h-11 w-11 place-items-center rounded-md text-muted-foreground hover:bg-muted sm:h-8 sm:w-8"; + +/** + * The shared tags of one group account. Read-only with "Set by your + * organisation" when the admin locked `sharedTags`, read-only with a note + * when the user may not change the group's Inbox, and a one-line note + * instead of the editor when the server keeps no metadata or couldn't be + * read. + */ +function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string }) { + const t = useTranslations("shared_tags"); + const key = sharedTagsKey(scope.loginId, scope.accountId); + const { status } = useAccountTags(scope); + const entry = useSharedTagsStore((state) => state.entries[key]); + const nested = useSettingsStore((state) => state.nestedTags); + const { locked } = useRestriction("sharedTags"); + const rights = useInboxRights(scope, status === "ready"); + const canWrite = maySetSharedTags(rights); + const editable = !locked && canWrite; + const [editing, setEditing] = useState<{ id?: string } | null>(null); + const [confirmDelete, setConfirmDelete] = useState(null); + const [saving, setSaving] = useState(false); + const [focusId, setFocusId] = useState(null); + const editButtons = useRef(new Map()); + + const { shared, discovered } = useMemo(() => { + if (status !== "ready" || !entry) return { shared: [] as KeywordDefinition[], discovered: [] as KeywordDefinition[] }; + const { definitions, discoveredIds } = groupAccountTags(entry.tags, entry.discovered, nested); + const found = new Set(discoveredIds); + return { shared: entry.tags, discovered: definitions.filter((tag) => found.has(tag.id)) }; + }, [status, entry, nested]); + const takenIds = useMemo(() => [...shared, ...discovered].map((tag) => tag.id.toLowerCase()), [shared, discovered]); + + // Back to the row's edit button once a change is saved. + useEffect(() => { + if (!focusId) return; + editButtons.current.get(focusId)?.focus(); + setFocusId(null); + }, [focusId, shared]); + + if (status === "personal") return null; + + const save = async (change: SharedTagChange) => { + const client = clientForLogin(scope.loginId); + if (!client) return false; + setSaving(true); + try { + await saveSharedTags(key, scope.accountId, client, [change]); + return true; + } catch (error) { + if (error instanceof SharedTagsSaveDeferredError) { + // Kept and shown; sent again when the server answers. + toast.warning(t("save_failed")); + return true; + } + toast.error(error instanceof MetadataTooLargeError ? t("too_many") : t("save_failed")); + return false; + } finally { + setSaving(false); + } + }; + + const heading = ( +
+
+ ); + + if (status === "loading") { + return ( +
+ {heading} +

+ + {t("loading")} +

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

{t(status)}

+
+ ); + } + + const row = (tag: KeywordDefinition, found: boolean) => { + if (editing && editing.id === tag.id) { + const raw = entry?.stored?.entries[tag.id]; + // The form shows the stored color when this version knows it. + const initial = { ...tag, color: typeof raw?.color === "string" && KEYWORD_PALETTE[raw.color] ? raw.color : tag.color }; + return ( + { setEditing(null); setFocusId(tag.id); }} + onSave={async (id, value) => { + const next = found ? { label: value.label, color: value.color ?? tag.color } : value; + if (await save({ id, value: next })) { setEditing(null); setFocusId(id); } + }} + /> + ); + } + const confirming = editable && confirmDelete === tag.id; + return ( +
+
+ + {found && {t("found_on_messages")}} +
+ {confirming ? ( +
+ {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))} + {(entry?.pending.length ?? 0) > 0 && ( +

{t("save_pending")}

+ )} + {locked ? ( + + ) : !canWrite ? ( +

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

+ ) : editing && !editing.id ? ( + setEditing(null)} + onSave={async (id, value) => { if (await save({ id, value })) { setEditing(null); setFocusId(id); } }} + /> + ) : ( + + )} +
+
+ ); +} + +/** + * Settings > Folders & tags > Shared tags: the tags of each group mailbox + * the user is a member of. Hidden without group accounts. The account list + * is worked out again whenever the folder list changes, which it does after + * every session change. + */ +export function SharedTagSettings() { + const t = useTranslations("shared_tags"); + const client = useAuthStore((state) => state.client); + const loginId = useAuthStore((state) => state.activeAccountId); + const mailboxes = useEmailStore((state) => state.mailboxes); + const accounts = useMemo( + () => { + void mailboxes; + return client && typeof client.getSharedAccounts === "function" + ? client.getSharedAccounts().filter((account) => !account.isPrimary && account.capabilities.mail && client.getAccountInfo?.(account.id)?.isPersonal === false) + : []; + }, + [client, mailboxes], + ); + const entries = useSharedTagsStore((state) => state.entries); + if (!loginId || accounts.length === 0) return null; + // Sharing by a person isn't a group: shown only once known not to be one. + const groups = accounts.filter((account) => entries[sharedTagsKey(loginId, account.id)]?.status !== "personal"); + return ( +
+ +
+ {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..6e6652153 --- /dev/null +++ b/hooks/__tests__/use-account-tags.test.tsx @@ -0,0 +1,132 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { TagBadge } from '@/components/email/tag-badge'; +import { TagPicker } from '@/components/email/tag-picker'; +import { TagScopeContext, tagScopeOf, type TagScope } from '../use-account-tags'; +import { useAuthStore } from '@/stores/auth-store'; +import { KEYWORD_PALETTE, useSettingsStore } from '@/stores/settings-store'; +import { resetSharedTags, sharedTagsKey, useSharedTagsStore, type SharedTagsEntry } from '@/stores/shared-tags-store'; + +// A message in a group account shows the group's shared tag names and +// colours; everywhere else, and wherever the shared ones aren't known, the +// user's own tags apply as before. + +const TEAM: TagScope = { loginId: 'login-1', accountId: 'team' }; +const OTHER_TEAM: TagScope = { loginId: 'login-1', accountId: 'other-team' }; + +function setEntry(scope: TagScope, entry: Partial) { + useSharedTagsStore.setState((state) => ({ + entries: { + ...state.entries, + [sharedTagsKey(scope.loginId, scope.accountId)]: { status: 'ready', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 1, discoveredAt: 1, readFailedAt: null, ...entry }, + }, + })); +} + +function badge(tagId: string, scope?: TagScope | null) { + return render(); +} + +beforeEach(() => { + resetSharedTags(); + // No signed-in client: the entries below stay what the test sets. + useAuthStore.setState({ client: null, activeAccountId: null, getClientForAccount: (() => undefined) as never }); + useSettingsStore.setState({ emailKeywords: [{ id: 'urgent', label: 'My urgent', color: 'blue' }], nestedTags: false }); +}); +afterEach(() => resetSharedTags()); + +describe('tag names and colours per account', () => { + it('uses the shared definition in a group account and the own one elsewhere', () => { + setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }); + badge('urgent', TEAM); + expect(screen.getByTitle('Urgent')).toHaveClass(KEYWORD_PALETTE.red.fill); + badge('urgent', null); + expect(screen.getByText('My urgent')).toBeInTheDocument(); + }); + + it('keeps the own tags in a group account whose server keeps no metadata', () => { + setEntry(TEAM, { status: 'unsupported' }); + badge('urgent', TEAM); + expect(screen.getByText('My urgent')).toBeInTheDocument(); + }); + + it('keeps the own tags while the shared ones couldn\'t be read', () => { + setEntry(TEAM, { status: 'unavailable', tags: [{ id: 'urgent', label: 'Stale', color: 'red' }] }); + badge('urgent', TEAM); + expect(screen.getByText('My urgent')).toBeInTheDocument(); + }); + + it('keeps the own tags in a mailbox a person shared', () => { + setEntry(TEAM, { status: 'personal' }); + badge('urgent', TEAM); + expect(screen.getByText('My urgent')).toBeInTheDocument(); + }); + + it('applies definitions that arrive after the message was drawn', () => { + setEntry(TEAM, { status: 'loading' }); + badge('urgent', TEAM); + expect(screen.getByText('My urgent')).toBeInTheDocument(); + act(() => setEntry(TEAM, { status: 'ready', tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] })); + expect(screen.getByText('Urgent')).toBeInTheDocument(); + expect(screen.queryByText('My urgent')).not.toBeInTheDocument(); + }); + + it('names each message by its own account in a mixed list', () => { + setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Team urgent', color: 'red' }] }); + setEntry(OTHER_TEAM, { tags: [{ id: 'urgent', label: 'Sales urgent', color: 'green' }] }); + render( + <> + + + + , + ); + 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/integration/tests/19-org-tags.spec.ts b/integration/tests/19-org-tags.spec.ts new file mode 100644 index 000000000..59c1193af --- /dev/null +++ b/integration/tests/19-org-tags.spec.ts @@ -0,0 +1,370 @@ +import { test, expect, type Page } from '@playwright/test'; +import { JMAP_URL } from './helpers/config'; +import { expandSharedFolders, emailItem, folderRow } from './helpers/app'; +import { + ORG, + ORG_PASSWORD, + ORG_SERVER, + adminContext, + getPolicy, + putPolicy, + requireOrgEnv, + shot, + userPage, +} from './helpers/org'; + +/** + * Shared tags in group mailboxes (organisation feature 6), through the real + * UI. On the v1.0.0 release (label 1.0.0) the definitions live in the + * metadata of org-team's Inbox: org-alice defines "Urgent" in red and tags a + * message, org-bob sees the same name and colour, and a group scope that + * locks `sharedTags` makes the editor read-only. On servers without working + * metadata (0.16, the 1.0 test build) there is no shared editor, the tags on + * group messages still show, and the app stays online. + * + * Cleanup: the test message (and with it its tags) is destroyed, the + * `org.bulwarkmail.tags` metadata entry is set back to null, the policy is + * put back. Other `org.bulwarkmail.*` entries (templates) are never touched. + */ + +test.describe.configure({ mode: 'serial' }); + +const SUBJECT = `Shared tags ${Date.now()}`; +const OWN_SUBJECT = `Own tags ${Date.now()}`; +const HAS_METADATA = ORG_SERVER === '1.0.0'; + +// --- JMAP, as org-alice, straight to the server -------------------------- + +type Call = [string, Record, string]; +let api: { url: string; team: string; inbox: string; own: string; ownInbox: string } | null = null; +let messageId: string | null = null; +/** A message in org-alice's own Inbox with the same keyword, for the unified inbox. */ +let ownMessageId: string | null = null; + +const auth = () => `Basic ${Buffer.from(`${ORG.alice}:${ORG_PASSWORD}`).toString('base64')}`; + +/** The API url, org-team's account id and its Inbox id, looked up once. */ +async function ensureApi(): Promise> { + 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]; + const own = session.primaryAccounts['urn:ietf:params:jmap:mail'] as string; + api = { url: `${JMAP_URL}${new URL(session.apiUrl).pathname}`, team, inbox: '', own, ownInbox: '' }; + const res = await jmapRaw([ + ['Mailbox/query', { accountId: team, filter: { role: 'inbox' } }, 'q'], + ['Mailbox/query', { accountId: own, filter: { role: 'inbox' } }, 'o'], + ]); + api.inbox = res[0][1].ids[0]; + api.ownInbox = res[1][1].ids[0]; + } + return api; +} + +async function jmap(calls: Call[], using?: string[]) { + await ensureApi(); + return jmapRaw(calls, using); +} + +async function jmapRaw(calls: Call[], using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']) { + const res = await fetch(api!.url, { + method: 'POST', + headers: { Authorization: auth(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ using, methodCalls: calls }), + }); + expect(res.ok, `JMAP ${res.status}`).toBe(true); + return (await res.json()).methodResponses as Array<[string, any, string]>; +} + +const META_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata']; + +async function sharedTagsOnServer(): Promise { + 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, 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(); + } + const admin = await adminContext(browser); + await putPolicy(admin, original); + await admin.close(); +}); + +test.describe('on a server with metadata', () => { + test.skip(!HAS_METADATA, 'shared definitions need Stalwart v1.0.0 metadata'); + + test('org-alice defines "Urgent" in red for org-team and tags a message with it', async ({ browser }) => { + const alice = await userPage(browser, ORG.alice); + const account = await openSharedTagSettings(alice); + await account.getByRole('button', { name: 'Add shared tag' }).click(); + const form = account.getByTestId('shared-tag-form'); + await form.getByLabel('Name').fill('Urgent'); + await form.getByRole('button', { name: 'red', exact: true }).click(); + await form.getByRole('button', { name: 'Save' }).click(); + await expect(account.locator('[data-testid="shared-tag-row"][data-tag-id="urgent"]')).toBeVisible(); + await shot(alice, 'tags-settings-editor'); + await expect.poll(sharedTagsOnServer).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } }); + + await alice.goto('/'); + await openTeamInbox(alice); + // The group's tags are listed under its folders. + await expect(alice.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible(); + + const row = emailItem(alice, SUBJECT).first(); + await expect(async () => { + await row.click({ button: 'right' }); + await alice.getByTestId('ctx-tag').waitFor({ state: 'visible', timeout: 2000 }); + }).toPass({ timeout: 15000 }); + await alice.getByTestId('ctx-tag').hover(); + await expect(alice.getByTestId('tag-picker-shared')).toBeVisible(); + await shot(alice, 'tags-picker'); + await alice.getByRole('menuitemcheckbox', { name: 'Urgent' }).click(); + await expect.poll(async () => (await messageKeywords())['$label:urgent']).toBe(true); + await alice.keyboard.press('Escape'); + await expect(row.getByTitle('Urgent')).toBeVisible(); + + // The group's tag in the sidebar counts and lists that group's mail + // only: her own message with the same keyword stays out. + const sharedRow = alice.getByTestId('shared-account-tags').getByTestId('folder-row').filter({ hasText: 'Urgent' }); + await expect(sharedRow.getByTestId('folder-counts')).toHaveAttribute('data-total', '1', { timeout: 30000 }); + await sharedRow.click(); + await expect(emailItem(alice, SUBJECT).first()).toBeVisible({ timeout: 30000 }); + await expect(emailItem(alice, OWN_SUBJECT)).toHaveCount(0); + await expect(sharedRow).toHaveAttribute('data-selected', 'true'); + await shot(alice, 'tags-sidebar-group-view'); + await alice.context().close(); + }); + + test('org-bob sees the same name and colour on that message', async ({ browser }) => { + const bob = await userPage(browser, ORG.bob); + await openTeamInbox(bob); + const chip = emailItem(bob, SUBJECT).first().getByTitle('Urgent'); + await expect(chip).toBeVisible(); + await expect(chip).toHaveClass(/bg-red-500\/10/); + await expect(bob.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible(); + await shot(bob, 'tags-member-view'); + + // Another member's edit arrives without a reload (push: the Inbox's + // Mailbox state changes with its metadata). + await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Very urgent', color: 'orange' } } } }, 's']], META_USING); + const renamed = emailItem(bob, SUBJECT).first().getByTitle('Very urgent'); + await expect(renamed).toBeVisible({ timeout: 30000 }); + await expect(renamed).toHaveClass(/bg-orange-500\/10/); + await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Urgent', color: 'red' } } } }, 's']], META_USING); + await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 }); + await bob.context().close(); + }); + + test('the unified inbox names the tags of each message by its own account', async ({ browser }) => { + // Unified inbox with the group inbox in it, pinned by the policy so a + // synced setting of the account can't turn it off. + const admin = await adminContext(browser); + const policy = await getPolicy(admin); + await putPolicy(admin, { + ...policy, + restrictions: { + ...(policy.restrictions as Record), + enableUnifiedMailbox: { locked: true, value: true }, + includeGroupInUnified: { locked: true, value: true }, + }, + }); + const alice = await userPage(browser, ORG.alice); + await folderRow(alice, { name: 'unified-inbox' }).first().click({ timeout: 60000 }); + // The same keyword: the group's shared name on the team message, the + // bare keyword on her own (she has no tag of that name). + await expect(emailItem(alice, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 }); + await expect(emailItem(alice, OWN_SUBJECT).first().getByTitle('urgent', { exact: true })).toBeVisible(); + await shot(alice, 'tags-unified-inbox'); + await alice.context().close(); + await putPolicy(admin, policy); + await admin.close(); + }); + + test('deleting a shared tag warns that it is for everyone, at phone width too', async ({ browser }) => { + const alice = await userPage(browser, ORG.alice, true); + const account = await openSharedTagSettings(alice, true); + await account.getByRole('button', { name: 'Delete Urgent' }).click(); + const confirm = account.getByTestId('shared-tag-delete-confirm'); + await expect(confirm.getByText('Delete for everyone? The tag stays on its messages.')).toBeVisible(); + for (const name of ['Delete', 'Cancel']) { + const box = await confirm.getByRole('button', { name, exact: true }).boundingBox(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + await shot(alice, 'tags-delete-confirm-phone'); + await confirm.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(account.getByRole('button', { name: 'Edit Urgent' })).toBeFocused(); + expect(await sharedTagsOnServer()).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } }); + await alice.context().close(); + }); + + test('a lock applies only to the scope that sets it, read-only with the reason in place', async ({ browser }) => { + const admin = await adminContext(browser); + let policy = await getPolicy(admin); + await putPolicy(admin, { + ...policy, + scopes: [ + { id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { restrictions: { sharedTags: { locked: true } } } }, + { id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} }, + ], + }); + + // A domain scope for another domain leaves org-team's members alone. + const alice = await userPage(browser, ORG.alice); + const aliceTags = await openSharedTagSettings(alice); + await expect(aliceTags.getByRole('button', { name: 'Add shared tag' })).toBeVisible(); + await alice.context().close(); + + // The admin locks shared tags for the org-team group on Organisation > Sharing. + const page = await admin.newPage(); + await page.goto('/admin/org-sharing'); + await expect(page.getByRole('heading', { name: 'Sharing' }).first()).toBeVisible(); + await page.getByRole('button', { name: 'Org team' }).click(); + await page.waitForLoadState('networkidle'); + const section = page.getByTestId('org-shared-tags-section'); + await section.getByLabel('Lock').check(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved.')).toBeVisible(); + await section.scrollIntoViewIfNeeded(); + await shot(page, 'tags-admin-lock'); + policy = await getPolicy(admin); + const team = (policy.scopes as Array<{ id: string; policy: Record }>).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|Delete|Name) / })).toHaveCount(0); + await expect(bobTags.locator('[data-tag-id="urgent"]')).toBeVisible(); + await shot(bob, 'tags-settings-locked'); + // Tagging with the shared tags still works. + await bob.goto('/'); + await openTeamInbox(bob); + await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible(); + await bob.context().close(); + }); + + test('the shared tags work at phone width', async ({ browser }) => { + const bob = await userPage(browser, ORG.bob, true); + const account = await openSharedTagSettings(bob, true); + await expect(account.getByText('Set by your organisation')).toBeVisible(); + await expect(account.locator('[data-tag-id="urgent"]')).toBeVisible(); + const overflow = await bob.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); + await shot(bob, 'tags-settings-phone'); + await bob.context().close(); + + const admin = await adminContext(browser, true); + const page = await admin.newPage(); + await page.goto('/admin/org-sharing'); + await expect(page.getByTestId('org-shared-tags-section')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); + await page.getByTestId('org-shared-tags-section').scrollIntoViewIfNeeded(); + await shot(page, 'tags-admin-phone'); + await admin.close(); + }); +}); + +test.describe('on a server without metadata', () => { + test.skip(HAS_METADATA, 'covered above'); + + test('no shared editor, the tags on group messages still show, and the app stays online', async ({ browser }) => { + const alice = await userPage(browser, ORG.alice); + const requests: string[] = []; + alice.on('request', (request) => { + const body = request.postData() ?? ''; + if (body.includes('urn:ietf:params:jmap:metadata')) requests.push(body); + }); + await openTeamInbox(alice); + await expect(emailItem(alice, SUBJECT).first().getByTitle('from-another-client')).toBeVisible(); + await expect(alice.getByTestId('shared-account-tags')).toHaveCount(0); + + const account = await openSharedTagSettings(alice); + // 0.16 has no metadata; the 1.0 test build has it but drops the request. + await expect(account.getByTestId(ORG_SERVER === '1.0' ? 'shared-tags-unavailable' : 'shared-tags-unsupported')).toBeVisible(); + await expect(account.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0); + await shot(alice, 'tags-settings-unsupported'); + + // The 1.0 test build drops the connection on Mailbox/get with metadata; + // that must not leave the app reconnecting. Had the failed read reported + // the login offline (onConnectionChange), the banner would stay until the + // next good keep-alive ping, 30 s later. 0.16 gets no such request. + await alice.goto('/'); + await openTeamInbox(alice); + await alice.waitForTimeout(3000); + await expect(alice.getByText('Connection lost. Attempting to reconnect')).toHaveCount(0); + if (ORG_SERVER === '0.16') expect(requests).toEqual([]); + // The 1.0 test build advertises metadata, so it was asked (and dropped it). + if (ORG_SERVER === '1.0') expect(requests.length).toBeGreaterThan(0); + await alice.context().close(); + }); +}); diff --git a/lib/__tests__/shared-tags.test.ts b/lib/__tests__/shared-tags.test.ts new file mode 100644 index 000000000..32d70dc1d --- /dev/null +++ b/lib/__tests__/shared-tags.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { + EMPTY_SHARED_TAGS, + groupAccountTags, + parseSharedTags, + planSharedTagsSave, + readSharedTags, + sharedDefinitions, +} from '../shared-tags'; +import { MetadataTooLargeError } from '../jmap/bulwark-metadata'; + +// Shared tag definitions of a group account, as they sit in the metadata of +// its Inbox: a map by tag id under `tags`, changed one tag at a time. + +const MAP = { + tags: { + urgent: { label: 'Urgent', color: 'red' }, + other: { label: 'Other', color: 'chartreuse', icon: 'flame' }, + }, + note: 'kept', +}; + +describe('readSharedTags', () => { + it('reads the map by id and keeps fields it doesn\'t know', () => { + const stored = readSharedTags(MAP); + expect(stored.patchable).toBe(true); + expect(stored.entries.other).toEqual({ label: 'Other', color: 'chartreuse', icon: 'flame' }); + expect(stored.extra).toEqual({ note: 'kept' }); + }); + + it('reads the earlier list and bare-array shapes, for converting', () => { + const fromList = readSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }, { id: 'URGENT', label: 'Twice' }] }); + expect(fromList).toEqual({ entries: { urgent: { label: 'Urgent', color: 'red' } }, extra: {}, patchable: false }); + expect(readSharedTags([{ id: 'later', label: 'Later', color: 'blue' }]).entries).toEqual({ later: { label: 'Later', color: 'blue' } }); + }); + + it('drops what cannot be a tag', () => { + const stored = readSharedTags({ tags: { 'has space': { label: 'x' }, 'ctl\u0001': { label: 'x' }, fine: 'not an object', ok: {} } }); + expect(Object.keys(stored.entries)).toEqual(['ok']); + expect(readSharedTags(undefined)).toEqual(EMPTY_SHARED_TAGS); + }); +}); + +describe('sharedDefinitions', () => { + it('names each tag, shows a color it doesn\'t know as grey, and sorts by name', () => { + expect(sharedDefinitions(readSharedTags(MAP))).toEqual([ + { id: 'other', label: 'Other', color: 'gray' }, + { id: 'urgent', label: 'Urgent', color: 'red' }, + ]); + expect(parseSharedTags({ tags: { x: { color: 'blue' } } })).toEqual([{ id: 'x', label: 'x', color: 'blue' }]); + }); +}); + +describe('planSharedTagsSave', () => { + it('patches only the tags that change', () => { + const { patches, result } = planSharedTagsSave(readSharedTags(MAP), [ + { id: 'new/child', value: { label: 'Child', color: 'blue' } }, + { id: 'URGENT', value: null }, + ]); + expect(patches).toEqual([ + { path: ['tags', 'new/child'], value: { label: 'Child', color: 'blue' } }, + { path: ['tags', 'urgent'], value: null }, + ]); + expect(Object.keys(result.entries).sort()).toEqual(['new/child', 'other']); + }); + + it('keeps the fields of a changed tag it doesn\'t know, and its color unless one was picked', () => { + const { patches } = planSharedTagsSave(readSharedTags(MAP), [{ id: 'other', value: { label: 'Renamed' } }]); + expect(patches).toEqual([{ path: ['tags', 'other'], value: { label: 'Renamed', color: 'chartreuse', icon: 'flame' } }]); + }); + + it('writes an earlier shape once, whole, as the map', () => { + const { patches, result } = planSharedTagsSave( + readSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }), + [{ id: 'later', value: { label: 'Later', color: 'blue' } }], + ); + expect(patches).toEqual([{ path: [], value: { tags: { urgent: { label: 'Urgent', color: 'red' }, later: { label: 'Later', color: 'blue' } } } }]); + expect(result.patchable).toBe(true); + }); + + it('refuses more than 8 KB of UTF-8, counted in bytes not characters', () => { + // 100 names of 30 two-byte characters: about 4.9 K characters, over 8 K bytes. + const changes = Array.from({ length: 100 }, (_, i) => ({ id: `t${i}`, value: { label: 'ä'.repeat(30), color: 'red' } })); + const json = JSON.stringify({ tags: Object.fromEntries(changes.map((c) => [c.id, c.value])) }); + expect(json.length).toBeLessThan(8192); + expect(() => planSharedTagsSave(EMPTY_SHARED_TAGS, changes)).toThrow(MetadataTooLargeError); + expect(() => planSharedTagsSave(EMPTY_SHARED_TAGS, changes.slice(0, 50))).not.toThrow(); + }); +}); + +describe('groupAccountTags', () => { + it('lists the shared definitions, then every tag on the messages nobody named', () => { + const { definitions, discoveredIds } = groupAccountTags( + [{ id: 'urgent', label: 'Urgent', color: 'red' }], + { '$label:urgent': 4, '$label:q3-invoices': 2 }, + false, + ); + expect(definitions.map((t) => t.id)).toEqual(['urgent', 'q3-invoices']); + expect(definitions[1].label).toBe('Q3 Invoices'); + expect(discoveredIds).toEqual(['q3-invoices']); + }); + + it('names a tag by its shared definition whatever case the message uses', () => { + const { definitions } = groupAccountTags([{ id: 'urgent', label: 'Urgent', color: 'red' }], { '$label:Urgent': 1 }, false); + expect(definitions).toHaveLength(1); + }); +}); diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index 5eb5fd01d..366c8805f 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -69,6 +69,15 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [ page: 'org-templates', modes: ['locked'], }, + { + key: 'sharedTags', + label: 'Shared tags', + description: "Lock: users can tag mail with the shared tags of their group mailboxes but can't add, rename or delete them. Unlock it in a group scope for the people who look after the tags.", + category: 'Sharing', + type: 'boolean', + page: 'org-sharing', + modes: ['locked'], + }, { key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] }, { key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] }, ]; diff --git a/lib/jmap/bulwark-metadata.ts b/lib/jmap/bulwark-metadata.ts index 74fa585a5..ad6376a48 100644 --- a/lib/jmap/bulwark-metadata.ts +++ b/lib/jmap/bulwark-metadata.ts @@ -46,6 +46,23 @@ export class MetadataUnsupportedError extends Error { } } +/** A patch inside an entry whose parent value doesn't exist (yet, or any more). */ +export class MetadataPatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'MetadataPatchError'; + } +} + +/** + * One segment of a patch path inside a metadata entry, escaped the JSON + * Pointer way (`~` as `~0`, `/` as `~1`), which Stalwart v1.0.0 decodes: + * a tag id `work/clients` is the segment `work~1clients`. + */ +export function escapeMetadataPathSegment(segment: string): string { + return segment.replace(/~/g, '~0').replace(/\//g, '~1'); +} + /** `tags`, `template.`: lowercase, digits, dashes, dot-separated. */ export function isValidMetadataKey(key: string): boolean { return key.length <= 120 && KEY_RE.test(key); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index c4990bdd2..2870b50f7 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -297,6 +297,8 @@ export interface IJMAPClient { limit?: number; onProgress?: (scanned: number, total: number) => void; signal?: AbortSignal; + /** Another account of this login, e.g. a group account (default: the own one). */ + accountId?: string; }): Promise<{ keywords: Record; 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>; @@ -622,6 +624,8 @@ export interface IJMAPClient { supportsBulwarkMetadata?(accountId?: string): boolean; getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise | null>; setBulwarkMetadata?(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise; + advertisesBulwarkMetadata?(accountId?: string): boolean; + patchBulwarkMetadata?(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }>; setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise; setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 9ce429c2e..ba8ed1921 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,4 @@ -import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata"; +import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata"; import { IncompleteDeleteError } from "./delete-error"; import { ArchiveEmailsError } from "./archive-error"; import { generateUUID } from '@/lib/utils'; @@ -2673,7 +2673,10 @@ export class JMAPClient implements IJMAPClient { limit?: number; onProgress?: (scanned: number, total: number) => void; signal?: AbortSignal; + /** Another account of this login, e.g. a group account (default: the own one). */ + accountId?: string; }): Promise<{ keywords: Record; 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 = {}; @@ -2687,14 +2690,14 @@ export class JMAPClient implements IJMAPClient { try { const response = await this.request([ ["Email/query", { - accountId: this.accountId, + accountId, sort: [{ property: "receivedAt", isAscending: false }], limit: Math.min(pageSize, cap - scanned), position: scanned, calculateTotal: scanned === 0, }, "0"], ["Email/get", { - accountId: this.accountId, + accountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: ["keywords"], }, "1"], @@ -7160,6 +7163,40 @@ export class JMAPClient implements IJMAPClient { if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) { throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`); } + await this.updateInboxMetadata(id, { [`${property}/${namespaceFor(key)}`]: value }, options); + } + + /** + * Whether the account advertises vendor metadata on mailboxes, whatever + * its last request did (supportsBulwarkMetadata also pauses after a + * failure). False means the server keeps no metadata for this account. + */ + advertisesBulwarkMetadata(accountId?: string): boolean { + const id = accountId || this.accountId; + return advertisesMailboxMetadata(this.accounts[id]?.accountCapabilities as Record | undefined); + } + + /** + * Changes values inside one Bulwark metadata entry of the account's Inbox + * and nothing else: each patch path (segments, escaped here) is set, null + * removes it, an empty path is the whole entry. Returns the Mailbox state + * after the change, so a caller can recognise the push of its own write. + * Throws MetadataTooLargeError (the server's size limit), + * MetadataPatchError (a parent of a path doesn't exist) and + * MetadataUnsupportedError. + */ + async patchBulwarkMetadata(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }> { + const id = accountId || this.accountId; + if (!this.supportsBulwarkMetadata(id)) throw new MetadataUnsupportedError(); + const update: Record = {}; + for (const { path, value } of patches) { + update[[`metadata/${namespaceFor(key)}`, ...path.map(escapeMetadataPathSegment)].join("/")] = value; + } + return { newState: await this.updateInboxMetadata(id, update) }; + } + + /** One Mailbox/set of the Inbox's metadata; answers the new Mailbox state. */ + private async updateInboxMetadata(id: string, update: Record, options?: { private?: boolean }): Promise { let inboxId = this.metadataInboxIds.get(id); if (!inboxId) { await this.getBulwarkMetadata(id, options); @@ -7167,7 +7204,7 @@ export class JMAPClient implements IJMAPClient { if (!inboxId) throw new MetadataUnsupportedError(); } const response = await this.request([ - ["Mailbox/set", { accountId: id, update: { [inboxId]: { [`${property}/${namespaceFor(key)}`]: value } } }, "0"], + ["Mailbox/set", { accountId: id, update: { [inboxId]: update } }, "0"], ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== "Mailbox/set") { @@ -7176,8 +7213,11 @@ export class JMAPClient implements IJMAPClient { const notUpdated = (result as { notUpdated?: Record }).notUpdated?.[inboxId]; if (notUpdated) { if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!); + if (notUpdated.type === "invalidPatch") throw new MetadataPatchError(notUpdated.description || "A parent of the patched value does not exist"); throw new MetadataRefusedError(notUpdated.type ?? "unknown", notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`); } + const newState = (result as { newState?: unknown }).newState; + return typeof newState === "string" ? newState : undefined; } /** 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..85e4bbdca --- /dev/null +++ b/lib/shared-tags.ts @@ -0,0 +1,195 @@ +/** + * Shared tags of a group account: the tag names and colors every member of + * a shared mailbox sees, kept in the metadata of the group's Inbox + * (namespace `org.bulwarkmail.tags`, lib/jmap/bulwark-metadata.ts). + * + * Keywords in a group account are the same for every member + * (local-data/org-features-facts.md §1), so `$label:urgent` on a team + * message is the team's tag, not one member's. A personal definition only + * names it for the member who made it; the shared definitions name it for + * everyone. + * + * Stored as a map by tag id, `{ "tags": { "": { "label", "color", ... } } }`, + * so a change patches one tag (`metadata/org.bulwarkmail.tags/tags/`) + * and two members adding different tags at the same time both keep theirs. + * Fields this version doesn't know are kept. Earlier shapes (`{ "list": [...] }` + * or a bare array) are read, and converted once by the first save. + * + * Pure functions only. + */ + +import { + FALLBACK_KEYWORD_COLOR, + KEYWORD_PALETTE, + type KeywordDefinition, + type KeywordVisibility, +} from '@/stores/settings-store'; +import { findUnrecognizedKeywords } from './keyword-discovery'; +import { MAX_KEYWORD_ID_LENGTH } from './keyword-nesting'; +import { MAX_METADATA_ENTRY_BYTES, MetadataTooLargeError, metadataValueBytes } from './jmap/bulwark-metadata'; + +/** The metadata key (namespace `org.bulwarkmail.tags`). */ +export const SHARED_TAGS_KEY = 'tags'; + +/** Longest name a shared tag keeps, as in the personal tag editor. */ +export const MAX_SHARED_TAG_LABEL = 30; + +const VISIBILITIES: readonly KeywordVisibility[] = ['show', 'hide', 'unread']; + +/** + * Characters RFC 8621 keywords can't hold (and whitespace), so a tag id + * from the server that could never be on a message is dropped. + */ +const BAD_ID_CHARS = /[\s(){\]%*"\\]/; + +/** Whether a tag id from the server can be a keyword (no control characters either). */ +function isKeywordSafe(id: string): boolean { + if (!id || id.length > MAX_KEYWORD_ID_LENGTH || BAD_ID_CHARS.test(id)) return false; + for (let i = 0; i < id.length; i++) { + const code = id.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** What the server holds, as read. */ +export interface StoredSharedTags { + /** The raw entry of each tag by id, unknown fields included. */ + entries: Record>; + /** Other values of the namespace next to `tags`, kept as they are. */ + extra: Record; + /** The map shape is on the server, so changes can patch one tag. */ + patchable: boolean; +} + +export const EMPTY_SHARED_TAGS: StoredSharedTags = { entries: {}, extra: {}, patchable: false }; + +/** + * Reads the namespace value: the map shape, or the earlier `{ list }` and + * bare-array shapes (converted to entries; `patchable` stays false so the + * first save writes the map shape). + */ +export function readSharedTags(value: unknown): StoredSharedTags { + if (isObject(value) && isObject(value.tags)) { + const entries: Record> = {}; + for (const [id, entry] of Object.entries(value.tags)) { + if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry; + } + const { tags: _tags, ...extra } = value; + void _tags; + return { entries, extra, patchable: true }; + } + const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : []; + const entries: Record> = {}; + const seen = new Set(); + for (const item of items) { + if (!isObject(item) || typeof item.id !== 'string') continue; + const id = item.id.trim(); + if (!isKeywordSafe(id) || seen.has(id.toLowerCase())) continue; + seen.add(id.toLowerCase()); + const { id: _id, ...rest } = item; + void _id; + entries[id] = rest; + } + return { entries, extra: {}, patchable: false }; +} + +/** One tag as the app shows it; an unknown color shows grey but stays stored. */ +function definitionOf(id: string, entry: Record): KeywordDefinition { + const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : ''; + const color = typeof entry.color === 'string' && KEYWORD_PALETTE[entry.color] ? entry.color : FALLBACK_KEYWORD_COLOR; + const definition: KeywordDefinition = { id, label: label || id, color }; + if (typeof entry.visibility === 'string' && (VISIBILITIES as readonly string[]).includes(entry.visibility)) { + definition.visibility = entry.visibility as KeywordVisibility; + } + return definition; +} + +/** The shared definitions, by name (a map has no order of its own). */ +export function sharedDefinitions(stored: StoredSharedTags): KeywordDefinition[] { + return Object.entries(stored.entries) + .map(([id, entry]) => definitionOf(id, entry)) + .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); +} + +/** The shared definitions in a metadata value (any shape). */ +export function parseSharedTags(value: unknown): KeywordDefinition[] { + return sharedDefinitions(readSharedTags(value)); +} + +/** + * One change: a tag's new name and, when the member picked one, its color; + * null deletes the tag. Other fields of the entry are kept. + */ +export interface SharedTagChange { + id: string; + value: { label: string; color?: string } | null; +} + +/** The id under which `id` is stored, in any case, or `id` itself. */ +function storedId(entries: Record, id: string): string { + const folded = id.toLowerCase(); + return Object.keys(entries).find((key) => key.toLowerCase() === folded) ?? id; +} + +/** The stored tags with the changes applied, nothing else touched. */ +export function applySharedTagChanges(stored: StoredSharedTags, changes: SharedTagChange[]): StoredSharedTags { + const entries = { ...stored.entries }; + for (const change of changes) { + const id = storedId(entries, change.id); + if (change.value === null) { + delete entries[id]; + continue; + } + const { label, color } = change.value; + entries[id] = { ...(entries[id] ?? {}), label, ...(color !== undefined ? { color } : {}) }; + } + return { ...stored, entries }; +} + +/** + * The Mailbox/set patches for the changes, and what the server holds after + * them. On the map shape each changed tag is one patch and no other tag is + * written; on an older shape (or none) the whole namespace is written once + * in the map shape. Throws MetadataTooLargeError when the result would take + * more than the 8 KB of one namespace (UTF-8 bytes of its JSON). + */ +export function planSharedTagsSave( + stored: StoredSharedTags, + changes: SharedTagChange[], +): { patches: Array<{ path: string[]; value: unknown }>; result: StoredSharedTags } { + const next = applySharedTagChanges(stored, changes); + const namespace = { ...next.extra, tags: next.entries }; + if (metadataValueBytes(namespace) > MAX_METADATA_ENTRY_BYTES) { + throw new MetadataTooLargeError(`The shared tags take more than ${MAX_METADATA_ENTRY_BYTES} bytes`); + } + if (!stored.patchable) { + return { patches: [{ path: [], value: namespace }], result: { ...next, patchable: true } }; + } + const ids = [...new Set(changes.map((change) => storedId(stored.entries, change.id)))]; + return { + patches: ids.map((id) => ({ path: ['tags', id], value: next.entries[id] ?? null })), + result: next, + }; +} + +/** + * What a group account shows as its tags: the shared definitions, then + * every tag found on its messages that no definition names, with the name + * and color keyword discovery proposes for it. A keyword that is on a + * message is never hidden because nobody defined it. + */ +export function groupAccountTags( + shared: KeywordDefinition[], + discovered: Record, + 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 25bf5cc1c..05198f409 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -326,6 +326,34 @@ "cancel_and_compose_again": "Abbrechen und neu verfassen", "reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30" }, + "shared_tags": { + "title": "Geteilte Labels", + "description": "Labels Ihrer Gruppenpostfächer. Alle in der Gruppe sehen dieselben Namen und Farben an ihren Nachrichten.", + "picker_heading": "Mit allen in diesem Postfach geteilt", + "sidebar_heading": "Labels", + "edit": "Geteilte Labels bearbeiten", + "edit_tag": "{name} bearbeiten", + "delete_tag": "{name} löschen", + "name_it": "{name} benennen", + "add": "Geteiltes Label hinzufügen", + "name": "Name", + "color": "Farbe", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "delete_confirm": "Für alle löschen? Das Label bleibt an seinen Nachrichten.", + "found_on_messages": "An Nachrichten, noch ohne Namen", + "empty": "Noch keine geteilten Labels.", + "exists": "Dieses Postfach hat dieses Label schon.", + "too_long": "Dieser Name ist zu lang.", + "loading": "Wird geladen…", + "unsupported": "Dieser Mailserver kann keine geteilten Labels speichern, daher zeigt dieses Postfach Ihre eigenen Labels.", + "unavailable": "Die geteilten Labels dieses Postfachs konnten nicht geladen werden, daher zeigt es Ihre eigenen Labels. Bulwark versucht es später erneut.", + "no_rights": "Sie können diese Labels verwenden, aber in diesem Postfach nicht ändern.", + "too_many": "Es gibt zu viele geteilte Labels. Löschen Sie einige, bevor Sie weitere hinzufügen.", + "save_failed": "Die geteilten Labels konnten nicht gespeichert werden. Bulwark versucht es erneut, sobald der Server antwortet.", + "save_pending": "Noch nicht gespeichert. Bulwark versucht es erneut, sobald der Server antwortet." + }, "email_viewer": { "read_receipt": { "prompt": "Der Absender bittet um eine Lesebestätigung:", diff --git a/locales/en/common.json b/locales/en/common.json index 9230ac2ac..aa46eb8bd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -326,6 +326,34 @@ "cancel_and_compose_again": "Cancel and compose again", "reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30" }, + "shared_tags": { + "title": "Shared tags", + "description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.", + "picker_heading": "Shared with everyone in this mailbox", + "sidebar_heading": "Tags", + "edit": "Edit shared tags", + "edit_tag": "Edit {name}", + "delete_tag": "Delete {name}", + "name_it": "Name {name}", + "add": "Add shared tag", + "name": "Name", + "color": "Color", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "delete_confirm": "Delete for everyone? The tag stays on its messages.", + "found_on_messages": "On messages, no name yet", + "empty": "No shared tags yet.", + "exists": "This mailbox already has this tag.", + "too_long": "This name is too long.", + "loading": "Loading…", + "unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags.", + "unavailable": "Couldn't load the shared tags of this mailbox, so it shows your own tags. Bulwark tries again later.", + "no_rights": "You can use these tags, but you can't change them in this mailbox.", + "too_many": "There are too many shared tags. Delete some before adding more.", + "save_failed": "Couldn't save the shared tags. Bulwark tries again when the server answers.", + "save_pending": "Not saved yet. Bulwark tries again when the server answers." + }, "email_viewer": { "read_receipt": { "prompt": "The sender asked to be notified when you open this message.", diff --git a/stores/__tests__/email-store-tag-view-accounts.test.ts b/stores/__tests__/email-store-tag-view-accounts.test.ts index e4e767530..159bf92a2 100644 --- a/stores/__tests__/email-store-tag-view-accounts.test.ts +++ b/stores/__tests__/email-store-tag-view-accounts.test.ts @@ -112,6 +112,21 @@ describe('tag view across the own and group accounts (#1038)', () => { expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []); }); + it('lists the group account only for a tag picked from its shared tags', async () => { + useEmailStore.setState({ selectedMailbox: ownInbox.id }); + useEmailStore.getState().selectKeyword(label, 'group'); + + await useEmailStore.getState().fetchEmails(client); + + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1']); + expect(client.getEmails).toHaveBeenCalledTimes(1); + expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []); + + // A tag from the own list spans every account again. + useEmailStore.getState().selectKeyword(label); + expect(useEmailStore.getState().selectedKeywordAccountId).toBeNull(); + }); + it('lists the same set while an own folder is selected', async () => { useEmailStore.setState({ selectedMailbox: ownInbox.id }); useEmailStore.getState().selectKeyword(label); diff --git a/stores/__tests__/shared-tags-store.test.ts b/stores/__tests__/shared-tags-store.test.ts new file mode 100644 index 000000000..7f0b96d87 --- /dev/null +++ b/stores/__tests__/shared-tags-store.test.ts @@ -0,0 +1,363 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { MAX_METADATA_ENTRY_BYTES, MetadataPatchError, MetadataTooLargeError, metadataValueBytes } from '@/lib/jmap/bulwark-metadata'; +import { emitPushStateChange } from '@/lib/push-events'; +import { + DISCOVERY_INTERVAL_MS, + GROUP_CHECK_RETRY_MS, + PUSH_DEBOUNCE_MS, + STALE_AFTER_MS, + SharedTagsSaveDeferredError, + loadSharedTags, + resetSharedTags, + saveSharedTags, + sharedTagsKey, + useSharedTagsStore, + type SharedTagsClient, +} from '../shared-tags-store'; + +const KEY = sharedTagsKey('login-1', 'team'); +const URGENT = { label: 'Urgent', color: 'red' }; + +/** org-team's Inbox metadata on a v1.0.0 server, shared by every member's client. */ +class FakeServer { + namespace: Record | null = { tags: { urgent: { ...URGENT } } }; + state = 1; + failReads = false; + failWrites = false; + + patch(patches: Array<{ path: string[]; value: unknown }>): { newState: string } { + if (this.failWrites) throw new TypeError('Failed to fetch'); + let next = this.namespace === null ? null : structuredClone(this.namespace); + for (const { path, value } of patches) { + if (path.length === 0) { next = value as Record | null; continue; } + if (!next) throw new MetadataPatchError('The namespace does not exist.'); + let parent: Record = next; + for (const segment of path.slice(0, -1)) { + const child = parent[segment]; + if (!child || typeof child !== 'object' || Array.isArray(child)) throw new MetadataPatchError('A parent of the patched value does not exist or is not an object.'); + parent = child as Record; + } + const last = path[path.length - 1]; + if (value === null) delete parent[last]; + else parent[last] = value; + } + if (next && metadataValueBytes(next) > MAX_METADATA_ENTRY_BYTES) throw new MetadataTooLargeError('exceeds the maximum'); + this.namespace = next; + this.state += 1; + return { newState: `s${this.state}` }; + } +} + +function member(server: FakeServer, options: { type?: string | null; advertised?: boolean; keywords?: Record } = {}) { + let paused = false; + const client = { + getAccountInfo: vi.fn(() => ({ isPersonal: false })), + getPrincipalTypes: vi.fn(async (ids: string[]) => + options.type === null ? null : Object.fromEntries(ids.map((id) => [id, options.type ?? 'group']))), + advertisesBulwarkMetadata: vi.fn(() => options.advertised !== false), + supportsBulwarkMetadata: vi.fn(() => options.advertised !== false && !paused), + getBulwarkMetadata: vi.fn(async () => { + if (server.failReads) { paused = true; return null; } + return server.namespace ? { tags: structuredClone(server.namespace) } : {}; + }), + patchBulwarkMetadata: vi.fn(async (_id: string | undefined, _key: string, patches: Array<{ path: string[]; value: unknown }>) => server.patch(patches)), + discoverKeywords: vi.fn(async () => ({ keywords: options.keywords ?? {}, scanned: 0, total: 0, complete: true })), + endPause: () => { paused = false; }, + }; + return client as typeof client & SharedTagsClient; +} + +const entry = (key = KEY) => useSharedTagsStore.getState().entries[key]; +const push = (client: SharedTagsClient, state: string, account = 'team') => + emitPushStateChange({ '@type': 'StateChange', changed: { [account]: { Mailbox: state } } }, client as never, 'active'); +function becomeVisible() { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); +} + +let server: FakeServer; +beforeEach(() => { resetSharedTags(); server = new FakeServer(); }); +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); + +describe('loading', () => { + it('reads the shared definitions of a confirmed group and scans its messages', async () => { + const client = member(server, { keywords: { '$label:found': 3, $seen: 9 } }); + await loadSharedTags(KEY, 'team', client); + expect(client.getBulwarkMetadata).toHaveBeenCalledWith('team'); + expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] }); + expect(client.discoverKeywords).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'team' })); + expect(entry().discovered).toEqual({ '$label:found': 3 }); + }); + + it('leaves a mailbox a person shared to the user\'s own tags, without asking for metadata', async () => { + const client = member(server, { type: 'individual' }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('personal'); + expect(client.getBulwarkMetadata).not.toHaveBeenCalled(); + }); + + it('never guesses a group when the principal lookup fails, and asks again later', async () => { + vi.useFakeTimers(); + const client = member(server, { type: null }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('loading'); + expect(client.getBulwarkMetadata).not.toHaveBeenCalled(); + + client.getPrincipalTypes.mockImplementation(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, 'group']))); + await vi.advanceTimersByTimeAsync(GROUP_CHECK_RETRY_MS); + await vi.waitFor(() => expect(entry().status).toBe('ready')); + }); + + it('sends no metadata request where the server advertises none (0.16)', async () => { + const client = member(server, { advertised: false }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('unsupported'); + expect(client.getBulwarkMetadata).not.toHaveBeenCalled(); + }); + + it('tells a failed read apart from no metadata, and reads again after the client\'s pause', async () => { + server.failReads = true; + const client = member(server); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('unavailable'); + + // Still paused: coming back to the tab asks nothing. + server.failReads = false; + becomeVisible(); + await Promise.resolve(); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1); + + client.endPause(); + becomeVisible(); + await vi.waitFor(() => expect(entry().status).toBe('ready')); + }); + + it('keeps the last good tags when a later read fails', async () => { + const client = member(server); + await loadSharedTags(KEY, 'team', client); + server.failReads = true; + await loadSharedTags(KEY, 'team', client, { force: true }); + expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] }); + expect(entry().readFailedAt).not.toBeNull(); + }); + + it('loads once per client and from scratch for a new sign-in', async () => { + const client = member(server); + await loadSharedTags(KEY, 'team', client); + await loadSharedTags(KEY, 'team', client); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1); + server.namespace = { tags: {} }; + await loadSharedTags(KEY, 'team', member(server)); + expect(entry().tags).toEqual([]); + }); +}); + +describe('refreshing', () => { + it('reads again once push has been quiet for 5 s, once for a burst', async () => { + const client = member(server); + await loadSharedTags(KEY, 'team', client); + vi.useFakeTimers(); + server.patch([{ path: ['tags', 'later'], value: { label: 'Later', color: 'blue' } }]); + + // Another account of the login doesn't concern it. + push(client, 'x9', 'own'); + push(client, 's2'); + await vi.advanceTimersByTimeAsync(2_000); + push(client, 's3'); + await vi.advanceTimersByTimeAsync(PUSH_DEBOUNCE_MS - 1); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await vi.waitFor(() => expect(entry().tags.map((t) => t.id)).toEqual(['later', 'urgent'])); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(2); + }); + + it('skips the push of its own save', async () => { + const client = member(server); + await loadSharedTags(KEY, 'team', client); + await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]); + vi.useFakeTimers(); + push(client, `s${server.state}`); + await vi.advanceTimersByTimeAsync(PUSH_DEBOUNCE_MS * 2); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1); + }); + + it('reloads only the definitions when the tab comes back, and scans the messages at most every 30 minutes', async () => { + let now = 1_000_000; + vi.spyOn(Date, 'now').mockImplementation(() => now); + const client = member(server); + await loadSharedTags(KEY, 'team', client); + expect(client.discoverKeywords).toHaveBeenCalledTimes(1); + + now += STALE_AFTER_MS; + becomeVisible(); + await vi.waitFor(() => expect(entry().loadedAt).toBe(now)); + // Let that load finish before the tab comes back again. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(2); + expect(client.discoverKeywords).toHaveBeenCalledTimes(1); + + now += DISCOVERY_INTERVAL_MS; + becomeVisible(); + await vi.waitFor(() => expect(client.discoverKeywords).toHaveBeenCalledTimes(2)); + }); +}); + +describe('saving', () => { + it('keeps the tags two members add at the same time', async () => { + const alice = member(server); + const bob = member(server); + const bobKey = sharedTagsKey('login-2', 'team'); + await loadSharedTags(KEY, 'team', alice); + await loadSharedTags(bobKey, 'team', bob); + await Promise.all([ + saveSharedTags(KEY, 'team', alice, [{ id: 'alice-tag', value: { label: 'Alice', color: 'green' } }]), + saveSharedTags(bobKey, 'team', bob, [{ id: 'bob-tag', value: { label: 'Bob', color: 'blue' } }]), + ]); + expect(Object.keys((server.namespace as { tags: object }).tags).sort()).toEqual(['alice-tag', 'bob-tag', 'urgent']); + }); + + it('leaves a color another client set alone when it edits something else, or renames that tag', async () => { + server.namespace = { tags: { urgent: { ...URGENT }, other: { label: 'Other', color: 'chartreuse', icon: 'flame' } } }; + const client = member(server); + await loadSharedTags(KEY, 'team', client); + await saveSharedTags(KEY, 'team', client, [{ id: 'urgent', value: { label: 'Very urgent', color: 'red-dark' } }]); + await saveSharedTags(KEY, 'team', client, [{ id: 'other', value: { label: 'Renamed' } }]); + expect(server.namespace).toEqual({ tags: { + urgent: { label: 'Very urgent', color: 'red-dark' }, + other: { label: 'Renamed', color: 'chartreuse', icon: 'flame' }, + } }); + expect(client.patchBulwarkMetadata.mock.calls.map((c) => c[2])).toEqual([ + [{ path: ['tags', 'urgent'], value: { label: 'Very urgent', color: 'red-dark' } }], + [{ path: ['tags', 'other'], value: { label: 'Renamed', color: 'chartreuse', icon: 'flame' } }], + ]); + }); + + it('converts the earlier list shape once, then patches', async () => { + server.namespace = { list: [{ id: 'urgent', ...URGENT }] }; + const client = member(server); + await loadSharedTags(KEY, 'team', client); + await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]); + expect(server.namespace).toEqual({ tags: { urgent: URGENT, later: { label: 'Later', color: 'blue' } } }); + await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: null }]); + expect(client.patchBulwarkMetadata.mock.calls[1][2]).toEqual([{ path: ['tags', 'later'], value: null }]); + expect(server.namespace).toEqual({ tags: { urgent: URGENT } }); + }); + + it('creates the namespace when there is none, keeping a tag another member just created it with', async () => { + server.namespace = null; + const client = member(server); + await loadSharedTags(KEY, 'team', client); + await saveSharedTags(KEY, 'team', client, [{ id: 'first', value: { label: 'First', color: 'blue' } }]); + expect(server.namespace).toEqual({ tags: { first: { label: 'First', color: 'blue' } } }); + + // Bob read nothing, Alice created it meanwhile: Bob's patch lands next to hers. + server.namespace = null; + const bob = member(server); + const bobKey = sharedTagsKey('login-2', 'team'); + await loadSharedTags(bobKey, 'team', bob); + server.namespace = { tags: { alice: { label: 'Alice', color: 'green' } } }; + await saveSharedTags(bobKey, 'team', bob, [{ id: 'bob', value: { label: 'Bob', color: 'red' } }]); + expect(Object.keys((server.namespace as { tags: object }).tags).sort()).toEqual(['alice', 'bob']); + }); + + it('refuses a list that no longer fits and sends nothing', async () => { + const client = member(server); + await loadSharedTags(KEY, 'team', client); + const many = Array.from({ length: 200 }, (_, i) => ({ id: `tag-number-${i}`, value: { label: `Tag number ${i}`, color: 'blue' } })); + await expect(saveSharedTags(KEY, 'team', client, many)).rejects.toBeInstanceOf(MetadataTooLargeError); + expect(client.patchBulwarkMetadata).not.toHaveBeenCalled(); + expect(entry().pending).toEqual([]); + }); + + it('keeps a change the server didn\'t take, shows it, and sends it with the next good read', async () => { + const client = member(server); + await loadSharedTags(KEY, 'team', client); + server.failWrites = true; + await expect(saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }])) + .rejects.toBeInstanceOf(SharedTagsSaveDeferredError); + expect(entry().pending).toHaveLength(1); + expect(entry().tags.map((t) => t.id)).toEqual(['later', 'urgent']); + + server.failWrites = false; + becomeVisible(); + await vi.waitFor(() => expect(entry().pending).toEqual([])); + expect((server.namespace as { tags: object }).tags).toHaveProperty('later'); + }); +}); + +// Through the real JMAP client and recorded Stalwart v1.0.0 answers. +describe('with the JMAP client', () => { + const METADATA_CAP = { + 'urn:ietf:params:jmap:metadata': { + dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } }, + }, + }; + const session = { + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals': {}, 'urn:ietf:params:jmap:metadata': {} }, + accounts: { + me: { name: 'org-alice@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } }, + team: { name: 'org-team@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals': {}, ...METADATA_CAP } }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'me', 'urn:ietf:params:jmap:principals': 'me' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: '', + }; + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + type Body = { using: string[]; methodCalls: Array<[string, Record, string]> }; + + 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', { oldState: 's1', newState: 's2', updated: { a: null } }, '0']] }); + if (method === 'Email/query') return json({ methodResponses: [['Email/query', { ids: [], total: 0 }, '0'], ['Email/get', { list: [] }, '1']] }); + throw new Error(`unexpected ${method}`); + }; + + it('reads org.bulwarkmail.tags of the group Inbox and patches one tag, its id escaped', async () => { + const { client, requests } = await connected((body) => answers(body, { metadata: { 'org.bulwarkmail.tags': { tags: { urgent: URGENT } }, 'org.bulwarkmail.template.x': {} } })); + await loadSharedTags(KEY, 'team', client); + expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] }); + // Tags nobody named are looked for in the group account, not the own one. + const scan = requests.find((r) => r.methodCalls[0][0] === 'Email/query')!; + expect(scan.methodCalls.map((c) => c[1].accountId)).toEqual(['team', 'team']); + + await saveSharedTags(KEY, 'team', client, [{ id: 'work/clients', value: { label: 'Clients', color: 'blue' } }]); + const set = requests.find((r) => r.methodCalls[0][0] === 'Mailbox/set')!; + expect(set.methodCalls[0][1]).toEqual({ + accountId: 'team', + update: { a: { 'metadata/org.bulwarkmail.tags/tags/work~1clients': { label: 'Clients', color: 'blue' } } }, + }); + }); + + it('counts a dropped metadata request as unavailable, not as no metadata', async () => { + const { client } = await connected((body) => { + if (body.methodCalls[0][0] === 'Mailbox/query') throw new TypeError('Failed to fetch'); + return answers(body, {}); + }); + await loadSharedTags(KEY, 'team', client); + expect(entry().status).toBe('unavailable'); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index dcece4e31..8fe96e0a9 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -192,6 +192,12 @@ interface EmailStore { // Keyword/tag filter selectedKeyword: string | null; + /** + * A tag picked from a group account's shared tags in the sidebar: the tag + * view lists that JMAP account (of the active login) only. Null: every + * account the login reaches (#1038). + */ + selectedKeywordAccountId: string | null; tagCounts: Record; // Advanced search state @@ -303,7 +309,7 @@ interface EmailStore { setError: (error: string | null) => void; setSearchQuery: (query: string) => void; setQuota: (quota: StorageQuota | null) => void; - selectKeyword: (keyword: string | null) => void; + selectKeyword: (keyword: string | null, accountId?: string | null) => void; fetchTagCounts: (client: IJMAPClient) => Promise; /** Take the email, not its id: the id alone collides across accounts. */ toggleEmailSelection: (email: SelectableEmail) => void; @@ -1843,6 +1849,16 @@ export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAc return built; } +/** + * The accounts a tag view lists: all of them, or only the group account + * whose shared tag was picked in the sidebar (selectedKeywordAccountId). + */ +function tagViewAccounts(passedClient: IJMAPClient): UnifiedAccountClient[] { + const built = buildTagViewAccountClients(passedClient); + const scoped = useEmailStore.getState().selectedKeywordAccountId; + return scoped ? built.filter((account) => account.jmapAccountId === scoped) : built; +} + /** * Whether a search is running under a folder-less scope (the default "All * folders except Spam and Trash" or "All folders", see @@ -3096,6 +3112,7 @@ export const useEmailStore = create((set, get) => ({ // Keyword/tag filter selectedKeyword: null, + selectedKeywordAccountId: null, tagCounts: {}, // Advanced search state @@ -3161,6 +3178,7 @@ export const useEmailStore = create((set, get) => ({ selectedEmail: null, selectedEmailKeys: new Set(), selectedKeyword: null, + selectedKeywordAccountId: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), threadEmailCounts: new Map(), @@ -3251,8 +3269,9 @@ export const useEmailStore = create((set, get) => ({ } set({ listHold: { rowKey, keywords } }); }, - selectKeyword: (keyword) => set(state => ({ + selectKeyword: (keyword, accountId = null) => set(state => ({ selectedKeyword: keyword, + selectedKeywordAccountId: keyword ? accountId : null, isLoadingMore: false, selectedEmail: null, selectedEmailKeys: new Set(), @@ -3309,6 +3328,7 @@ export const useEmailStore = create((set, get) => ({ selectedEmail: null, selectedEmailKeys: new Set(), selectedKeyword: null, + selectedKeywordAccountId: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), threadEmailCounts: new Map(), @@ -3603,7 +3623,7 @@ export const useEmailStore = create((set, get) => ({ const { selectedKeyword } = get(); if (selectedKeyword) { const order = getMessageListOrderFor(null); - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); // Selecting a tag keeps an active search (`handleTagSelect` does not // clear it), and the search paths narrow the tag rather than replace // it — so honour the query here too, or the list would flip between @@ -3907,7 +3927,7 @@ export const useEmailStore = create((set, get) => ({ // Searching inside a tag view narrows the tag (see searchEmails), so // page 2 must carry the same keyword AND the same query — otherwise it // appends unrelated all-folders hits to a tag list. - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await fetchTagEmails( built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null), hasFilters @@ -3922,7 +3942,7 @@ export const useEmailStore = create((set, get) => ({ if (isAllFoldersSearchScope(searchMailboxId)) { // Folder-less scope: the next page of the same own + group account // fan-out the search ran (#1082). - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); const scope = acrossAccountsSearchOptions(searchMailboxId); result = hasFilters ? await advancedSearchAcrossAccounts(built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, positionsByAccount(emails), scope) @@ -3945,7 +3965,7 @@ export const useEmailStore = create((set, get) => ({ // Tag view: the next page of the same cross-account fan-out the // initial fetch ran (same keyword, no folder constraint, same order), // so pagination spans the own and group accounts alike (#1038). - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await fetchTagEmails( built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null), ); @@ -4979,7 +4999,7 @@ export const useEmailStore = create((set, get) => ({ // NARROWS the tag rather than silently replacing the list with an // unrelated all-folders result that the next push refresh would throw // away again. - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await fetchTagEmails( built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null), textFilter(query), @@ -4992,7 +5012,7 @@ export const useEmailStore = create((set, get) => ({ // views use (#1038). A single own-account query silently skipped the // shared accounts' mail (#1082). The default scope leaves every // account's Trash and Junk out; "All folders" searches them too. - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await searchAcrossAccounts(built, query, emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true }); unifiedErrors = result.errors; @@ -5094,7 +5114,7 @@ export const useEmailStore = create((set, get) => ({ } else if (selectedKeyword) { // Narrow the tag rather than replace it — see searchEmails. - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await fetchTagEmails( built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null), buildJMAPFilter(searchQuery, searchFilters, undefined), @@ -5104,7 +5124,7 @@ export const useEmailStore = create((set, get) => ({ } else if (isAllFoldersSearchScope(searchMailboxId)) { // Folder-less scope: fan out over the own + group/shared accounts with // a filter that carries no inMailbox clause (#1082). - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await advancedSearchAcrossAccounts( built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true }, @@ -6501,7 +6521,7 @@ export const useEmailStore = create((set, get) => ({ // They cannot establish a folder delta baseline. // An active search narrows the tag, so carry it or the refresh // would silently widen the list back to the whole tag. - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await fetchTagEmails( built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null), hasFilters @@ -6518,7 +6538,7 @@ export const useEmailStore = create((set, get) => ({ if (isAllFoldersSearchScope(searchMailboxId)) { // Folder-less scope: the same own + group account fan-out the // search ran, so a change in a group account reaches the list (#1082). - const built = buildTagViewAccountClients(client); + const built = tagViewAccounts(client); result = await advancedSearchAcrossAccounts( built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0, acrossAccountsSearchOptions(searchMailboxId), @@ -7484,6 +7504,7 @@ export const useEmailStore = create((set, get) => ({ unifiedRole: role, crossView: null, selectedKeyword: null, + selectedKeywordAccountId: null, retainedInViewIds: new Set(), listHold: null, viewToken: token, @@ -7570,6 +7591,7 @@ export const useEmailStore = create((set, get) => ({ unifiedRole: null, crossView: view, selectedKeyword: null, + selectedKeywordAccountId: null, retainedInViewIds: new Set(), listHold: null, viewToken: token, diff --git a/stores/shared-tags-store.ts b/stores/shared-tags-store.ts new file mode 100644 index 000000000..a26ec1720 --- /dev/null +++ b/stores/shared-tags-store.ts @@ -0,0 +1,363 @@ +/** + * The shared tags of each group account a signed-in login reaches + * (lib/shared-tags.ts): loaded the first time something on screen shows a + * message or the sidebar of that account, kept per login and account. + * + * Refreshing: Stalwart bumps the Mailbox state of the group account when + * its Inbox metadata changes, and every member's push channel reports the + * state changes of the accounts it reaches. So another member's edit + * arrives as a Mailbox change of the group account; reloads are debounced + * per account (5 s, a Mailbox change also comes with new mail) and the push + * of our own save is recognised by its state and skipped. Push can be off, + * so a tab that comes back reads definitions older than two minutes again. + * The scan for tags nobody named runs once per sign-in and then at most + * every 30 minutes. + * + * Status per account: + * - `loading`: not known yet, or the server wouldn't say whether the + * account is a group (asked again later; never guessed, since members + * write here). The user's own tags show meanwhile. + * - `personal`: not a group principal (a mailbox a person shared). + * - `unsupported`: a group, but the server advertises no metadata (0.16). + * - `unavailable`: a group whose metadata couldn't be read yet (the 1.0 + * test build drops the request). Read again after the client's pause. + * - `ready`: the shared definitions. A later failed read keeps them. + */ + +import { create } from 'zustand'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { MetadataPatchError, MetadataTooLargeError, MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata'; +import { tagIdFromKeyword } from '@/lib/keyword-discovery'; +import { + EMPTY_SHARED_TAGS, + SHARED_TAGS_KEY, + applySharedTagChanges, + planSharedTagsSave, + readSharedTags, + sharedDefinitions, + type SharedTagChange, + type StoredSharedTags, +} from '@/lib/shared-tags'; +import { subscribePushStateChanges } from '@/lib/push-events'; +import type { KeywordDefinition } from '@/stores/settings-store'; + +export type SharedTagsStatus = 'loading' | 'personal' | 'unsupported' | 'unavailable' | 'ready'; + +export interface SharedTagsEntry { + status: SharedTagsStatus; + /** The shared definitions with unsaved changes applied (status `ready`). */ + tags: KeywordDefinition[]; + /** What the server holds, as last read or written. */ + stored: StoredSharedTags | null; + /** Changes a failed save left; sent again with the next good read. */ + pending: SharedTagChange[]; + /** Tag keywords found on the account's recent messages, with counts. */ + discovered: Record; + loadedAt: number; + discoveredAt: number; + /** When the last read failed; null after a good one. */ + readFailedAt: number | null; +} + +export type SharedTagsClient = Pick + & Partial>; + +/** Messages scanned for tags nobody named: one request's worth. */ +export const DISCOVERY_SCAN_LIMIT = 500; +/** A tab that comes back reads definitions older than this again. */ +export const STALE_AFTER_MS = 2 * 60_000; +/** The scan for unnamed tags runs again after this, at the earliest. */ +export const DISCOVERY_INTERVAL_MS = 30 * 60_000; +/** Push-triggered reloads of one account wait this long for quiet. */ +export const PUSH_DEBOUNCE_MS = 5_000; +/** First retry when the server wouldn't say whether an account is a group. */ +export const GROUP_CHECK_RETRY_MS = 30_000; + +/** A save that didn't reach the server; its changes are kept and sent again. */ +export class SharedTagsSaveDeferredError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : 'The shared tags could not be saved'); + this.name = 'SharedTagsSaveDeferredError'; + } +} + +interface SharedTagsState { + entries: Record; +} + +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(); +const groupConfirmed = new Set(); +const ownStates = new Map>(); +const pushTimers = new Map>(); +const retryTimers = new Map; delay: number }>(); +let unsubscribePush: (() => void) | null = null; +let visibilityHandler: (() => void) | null = null; + +const NEW_ENTRY: SharedTagsEntry = { + status: 'loading', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 0, discoveredAt: 0, readFailedAt: null, +}; + +const entryOf = (key: string): SharedTagsEntry | undefined => useSharedTagsStore.getState().entries[key]; + +function setEntry(key: string, patch: Partial): void { + useSharedTagsStore.setState((state) => { + const previous = state.entries[key] ?? NEW_ENTRY; + const next = { ...previous, ...patch }; + if (next.stored) next.tags = sharedDefinitions(applySharedTagChanges(next.stored, next.pending)); + return { entries: { ...state.entries, [key]: next } }; + }); +} + +/** Whether the client's metadata pause is over for the account. */ +const mayAskMetadata = (client: SharedTagsClient, accountId: string) => + typeof client.supportsBulwarkMetadata === 'function' && client.supportsBulwarkMetadata(accountId); + +function scheduleGroupRetry(key: string): void { + const previous = retryTimers.get(key); + if (previous) clearTimeout(previous.timer); + const delay = previous ? Math.min(previous.delay * 2, 10 * 60_000) : GROUP_CHECK_RETRY_MS; + const timer = setTimeout(() => { + const client = clients.get(key); + const accountId = accountIds.get(key); + if (client && accountId && entryOf(key)?.status === 'loading') void loadSharedTags(key, accountId, client, { force: true }); + }, delay); + retryTimers.set(key, { timer, delay }); +} + +async function discover(key: string, client: SharedTagsClient, accountId: string): Promise { + setEntry(key, { discoveredAt: Date.now() }); + try { + const scan = await client.discoverKeywords({ accountId, limit: DISCOVERY_SCAN_LIMIT }); + if (clients.get(key) !== client) return; + const tags: Record = {}; + for (const [keyword, count] of Object.entries(scan.keywords)) { + if (tagIdFromKeyword(keyword)) tags[keyword] = count; + } + setEntry(key, { discovered: tags }); + } catch { + // The tags on the messages still show on the messages themselves. + } +} + +async function read(key: string, client: SharedTagsClient, accountId: string): Promise { + if (!groupConfirmed.has(key)) { + if (typeof client.getPrincipalTypes !== 'function') { + // No way to confirm a group: nothing shared is written for it. + setEntry(key, { status: 'personal', loadedAt: Date.now() }); + return; + } + const types = await client.getPrincipalTypes([accountId]).catch(() => null); + if (clients.get(key) !== client) return; + if (!types) { + // Never guess: stay on the user's own tags and ask again later. + scheduleGroupRetry(key); + return; + } + const retry = retryTimers.get(key); + if (retry) clearTimeout(retry.timer); + retryTimers.delete(key); + if (types[accountId] !== 'group') { + setEntry(key, { status: 'personal', loadedAt: Date.now() }); + return; + } + groupConfirmed.add(key); + } + + const advertised = typeof client.advertisesBulwarkMetadata === 'function' + ? client.advertisesBulwarkMetadata(accountId) + : mayAskMetadata(client, accountId); + if (!advertised || typeof client.getBulwarkMetadata !== 'function') { + // 0.16: no request at all. + setEntry(key, { status: 'unsupported', loadedAt: Date.now() }); + return; + } + + // getBulwarkMetadata sends its request on its own and answers null on any + // failure, a dropped connection included; it then pauses the account. + const metadata = mayAskMetadata(client, accountId) ? await client.getBulwarkMetadata(accountId) : null; + if (clients.get(key) !== client) return; + if (!metadata) { + // A failed read keeps the last good tags. + setEntry(key, { status: entryOf(key)?.status === 'ready' ? 'ready' : 'unavailable', readFailedAt: Date.now() }); + return; + } + setEntry(key, { status: 'ready', stored: readSharedTags(metadata[SHARED_TAGS_KEY]), loadedAt: Date.now(), readFailedAt: null }); + + if ((entryOf(key)?.pending ?? []).length > 0) { + await writeChanges(key, accountId, client, []).catch(() => {}); + } + const discoveredAt = entryOf(key)?.discoveredAt ?? 0; + if (!discoveredAt || Date.now() - discoveredAt >= DISCOVERY_INTERVAL_MS) await discover(key, client, accountId); +} + +function startWatching(): void { + if (!unsubscribePush) { + unsubscribePush = subscribePushStateChanges((change, client) => { + for (const [key, known] of clients) { + if (known !== (client as unknown as SharedTagsClient)) continue; + const accountId = accountIds.get(key); + const state = accountId ? change.changed[accountId]?.Mailbox : undefined; + if (!accountId || !state) continue; + // The push of our own save. + if (ownStates.get(key)?.delete(state)) continue; + const status = entryOf(key)?.status; + if (status !== 'ready' && status !== 'unavailable') continue; + const waiting = pushTimers.get(key); + if (waiting) clearTimeout(waiting); + pushTimers.set(key, setTimeout(() => { + pushTimers.delete(key); + if (mayAskMetadata(known, accountId)) void loadSharedTags(key, accountId, known, { force: true }); + }, PUSH_DEBOUNCE_MS)); + } + }); + } + if (!visibilityHandler && typeof document !== 'undefined') { + visibilityHandler = () => { + if (document.visibilityState !== 'visible') return; + const now = Date.now(); + for (const [key, entry] of Object.entries(useSharedTagsStore.getState().entries)) { + const client = clients.get(key); + const accountId = accountIds.get(key); + if (!client || !accountId) continue; + const failed = entry.status === 'unavailable' || entry.readFailedAt !== null || entry.pending.length > 0; + const due = entry.status === 'loading' + || (failed && mayAskMetadata(client, accountId)) + || (entry.status === 'ready' && now - entry.loadedAt >= STALE_AFTER_MS); + if (due) void loadSharedTags(key, accountId, client, { force: true }); + } + }; + document.addEventListener('visibilitychange', visibilityHandler); + } +} + +/** + * Loads an account's shared tags once per client; `force` reads the + * definitions again. The scan for unnamed tags runs with the first load of + * a sign-in and then at most every 30 minutes. + */ +export function loadSharedTags( + key: string, + accountId: string, + client: SharedTagsClient, + options: { force?: boolean } = {}, +): Promise { + const running = inflight.get(key); + if (running && clients.get(key) === client) return running; + const sameClient = clients.get(key) === client; + if (sameClient && !options.force && entryOf(key)) return Promise.resolve(); + if (!sameClient) { + // Another sign-in of this login: nothing of the old one carries over. + groupConfirmed.delete(key); + ownStates.delete(key); + useSharedTagsStore.setState((state) => { + const entries = { ...state.entries }; + delete entries[key]; + return { entries }; + }); + } + clients.set(key, client); + accountIds.set(key, accountId); + startWatching(); + if (!entryOf(key)) setEntry(key, {}); + const run = read(key, client, accountId) + .catch(() => { + if (clients.get(key) === client) setEntry(key, { readFailedAt: Date.now() }); + }) + .finally(() => { + if (inflight.get(key) === run) inflight.delete(key); + }); + inflight.set(key, run); + return run; +} + +/** Sends the pending changes plus `changes`; the stored tags follow. */ +async function writeChanges( + key: string, + accountId: string, + client: SharedTagsClient, + changes: SharedTagChange[], +): Promise { + const entry = entryOf(key); + const all = [...(entry?.pending ?? []), ...changes]; + let stored = entry?.stored ?? EMPTY_SHARED_TAGS; + // Throws MetadataTooLargeError before anything is sent; nothing is kept then. + planSharedTagsSave(stored, all); + try { + if (typeof client.patchBulwarkMetadata !== 'function' || typeof client.getBulwarkMetadata !== 'function') { + throw new MetadataUnsupportedError(); + } + // With nothing stored, patch first anyway: another member may have just + // created the namespace, and writing it whole would drop their tag. + const nothingStored = !stored.patchable && Object.keys(stored.entries).length === 0 && Object.keys(stored.extra).length === 0; + if (nothingStored) stored = { ...stored, patchable: true }; + for (let attempt = 0; ; attempt++) { + const plan = planSharedTagsSave(stored, all); + try { + const { newState } = await client.patchBulwarkMetadata(accountId, SHARED_TAGS_KEY, plan.patches); + if (newState) { + const own = ownStates.get(key) ?? new Set(); + own.add(newState); + ownStates.set(key, own); + } + setEntry(key, { status: 'ready', stored: plan.result, pending: [] }); + return; + } catch (error) { + if (!(error instanceof MetadataPatchError) || attempt > 0) throw error; + // Not (or no longer) the map shape: read it, convert it, write it whole. + const metadata = await client.getBulwarkMetadata(accountId); + if (!metadata) throw new MetadataUnsupportedError(); + stored = readSharedTags(metadata[SHARED_TAGS_KEY]); + } + } + } catch (error) { + if (error instanceof MetadataTooLargeError) throw error; + // Kept, shown, and sent again with the next good read. + setEntry(key, { pending: all }); + throw new SharedTagsSaveDeferredError(error); + } +} + +/** + * Changes the shared definitions of an account, one patch per changed tag, + * so the other tags (and what other members change meanwhile) stay as they + * are. Throws MetadataTooLargeError when the list no longer fits (nothing + * kept), SharedTagsSaveDeferredError when the server didn't take it (the + * change is kept, shown and sent again with the next good read). + */ +export async function saveSharedTags( + key: string, + accountId: string, + client: SharedTagsClient, + changes: SharedTagChange[], +): Promise { + clients.set(key, client); + accountIds.set(key, accountId); + startWatching(); + await writeChanges(key, accountId, client, changes); +} + +/** For tests: forget every account. */ +export function resetSharedTags(): void { + clients.clear(); + inflight.clear(); + accountIds.clear(); + groupConfirmed.clear(); + ownStates.clear(); + for (const timer of pushTimers.values()) clearTimeout(timer); + pushTimers.clear(); + for (const { timer } of retryTimers.values()) clearTimeout(timer); + retryTimers.clear(); + unsubscribePush?.(); + unsubscribePush = null; + if (visibilityHandler && typeof document !== 'undefined') document.removeEventListener('visibilitychange', visibilityHandler); + visibilityHandler = null; + useSharedTagsStore.setState({ entries: {} }); +}