mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: keep shared tags safe when members edit at once or the server fails
The first version wrote the whole tag list on every change, guessed a
group when the server wouldn't say, and treated a failed read like a
server without metadata. Now:
- Definitions are a map by tag id under `tags`, and each change patches
one tag (`metadata/org.bulwarkmail.tags/tags/<id>`), so two members
adding tags at once both keep theirs. Fields and colors this version
doesn't know survive. The `{ list }` and bare-array shapes are still
read and converted by the first save.
- Shared tags appear only for confirmed group principals. A failed
principal lookup keeps the user's own tags and asks again later.
- "No metadata advertised" (0.16) and "the read failed" are separate.
A failed read keeps the last good tags; a failed save keeps the change,
says so, and sends it with the next good read after the client's
pause.
- Push reloads wait for 5 s of quiet and skip the echo of our own save;
a returning tab reloads definitions only, and the scan for unnamed tags
runs once per sign-in and then at most every 30 minutes.
- The editor names its buttons after the tag, shows the delete warning
and 44 px buttons on phones, returns focus to the edit button, and
asks for Inbox rights only for confirmed groups.
This commit is contained in:
@@ -15,7 +15,7 @@ export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
<SettingsSection
|
||||
id="shared-tags"
|
||||
title="Shared tags"
|
||||
description="Members of a group mailbox see the same tag names and colours on its mail; members who may change the mailbox can edit them (Stalwart 1.0 or later)."
|
||||
description="Members of a group mailbox see the same tag names and colors on its mail; members who may change the mailbox can edit them (Stalwart v1.0.0 or later)."
|
||||
>
|
||||
<div data-testid="org-shared-tags-section">
|
||||
{restrictionKeysForPage('org-sharing')
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Names with their tag in them: the global mock returns the bare key.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: { name?: string }) => (params?.name ? `${key}:${params.name}` : key),
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import { SharedTagSettings, maySetSharedTags } from '../shared-tag-settings';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { resetSharedTags } from '@/stores/shared-tags-store';
|
||||
import { resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
|
||||
|
||||
// Settings > Folders & tags > Shared tags: the editor of each group
|
||||
// mailbox's tags, read-only when the admin locked them, and a note instead of
|
||||
// an editor where the server keeps no metadata.
|
||||
// an editor where the server keeps no metadata or couldn't be read.
|
||||
|
||||
const URGENT = { id: 'urgent', label: 'Urgent', color: 'red' };
|
||||
const URGENT = { label: 'Urgent', color: 'red' };
|
||||
const RIGHTS = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
||||
|
||||
let stored: Record<string, unknown>;
|
||||
let stored: Record<string, unknown> | null;
|
||||
let keywords: Record<string, number>;
|
||||
let metadata: boolean;
|
||||
let advertised: boolean;
|
||||
let principalType: string;
|
||||
const client = {
|
||||
getAccountId: () => 'me',
|
||||
getAccountInfo: (id: string) => ({ isPersonal: id === 'me' }),
|
||||
@@ -25,12 +33,21 @@ const client = {
|
||||
{ id: 'me', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } },
|
||||
{ id: 'team', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } },
|
||||
],
|
||||
getPrincipalTypes: async () => ({ team: 'group' }),
|
||||
supportsBulwarkMetadata: () => metadata,
|
||||
getBulwarkMetadata: vi.fn(async () => stored),
|
||||
setBulwarkMetadata: vi.fn(async (_id: string | undefined, key: string, value: unknown) => { stored = { ...stored, [key]: value }; }),
|
||||
getPrincipalTypes: async () => ({ team: principalType }),
|
||||
advertisesBulwarkMetadata: () => advertised,
|
||||
supportsBulwarkMetadata: () => advertised,
|
||||
getBulwarkMetadata: vi.fn(async () => (stored ? { tags: stored } : null)),
|
||||
patchBulwarkMetadata: vi.fn(async (_id: string | undefined, _key: string, patches: Array<{ path: string[]; value: unknown }>) => {
|
||||
const tags = { ...((stored?.tags ?? {}) as Record<string, unknown>) };
|
||||
for (const { path, value } of patches) {
|
||||
if (value === null) delete tags[path[1]];
|
||||
else tags[path[1]] = value;
|
||||
}
|
||||
stored = { tags };
|
||||
return { newState: 'sX' };
|
||||
}),
|
||||
discoverKeywords: async () => ({ keywords, scanned: 0, total: 0, complete: true }),
|
||||
getMailboxes: async () => [],
|
||||
getMailboxes: vi.fn(async () => []),
|
||||
};
|
||||
|
||||
function setRights(rights: Record<string, boolean>) {
|
||||
@@ -43,11 +60,13 @@ function setLocked(locked: boolean) {
|
||||
|
||||
beforeEach(() => {
|
||||
resetSharedTags();
|
||||
stored = { tags: { list: [URGENT] } };
|
||||
stored = { tags: { urgent: { ...URGENT } } };
|
||||
keywords = {};
|
||||
metadata = true;
|
||||
advertised = true;
|
||||
principalType = 'group';
|
||||
client.getBulwarkMetadata.mockClear();
|
||||
client.setBulwarkMetadata.mockClear();
|
||||
client.patchBulwarkMetadata.mockClear();
|
||||
client.getMailboxes.mockClear();
|
||||
useSettingsStore.setState({ emailKeywords: [], nestedTags: false });
|
||||
useAuthStore.setState({
|
||||
client: client as never,
|
||||
@@ -60,26 +79,53 @@ beforeEach(() => {
|
||||
afterEach(() => resetSharedTags());
|
||||
|
||||
describe('SharedTagSettings', () => {
|
||||
it('lets a member who may change the Inbox add a shared tag', async () => {
|
||||
it('lets a member who may change the Inbox add a shared tag, as one patch', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
expect(await screen.findByText('Urgent')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Waiting' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
await waitFor(() => expect(client.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', {
|
||||
list: [URGENT, { id: 'waiting', label: 'Waiting', color: 'blue' }],
|
||||
}));
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
|
||||
{ path: ['tags', 'waiting'], value: { label: 'Waiting', color: 'blue' } },
|
||||
]));
|
||||
expect(await screen.findByText('Waiting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the buttons after their tag and puts focus back on the edit button after saving', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
fireEvent.click(await screen.findByLabelText('edit_tag:Urgent'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Very urgent' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
// The color wasn't picked, so it isn't sent: whatever is stored stays.
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
|
||||
{ path: ['tags', 'urgent'], value: { label: 'Very urgent', color: 'red' } },
|
||||
]));
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('edit_tag:Very urgent')));
|
||||
});
|
||||
|
||||
it('warns that deleting is for everyone, in words, before it deletes', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
fireEvent.click(await screen.findByLabelText('delete_tag:Urgent'));
|
||||
const confirm = screen.getByTestId('shared-tag-delete-confirm');
|
||||
const warning = screen.getByText('delete_confirm');
|
||||
expect(confirm).toContainElement(warning);
|
||||
// Shown at every width, not only from the sm breakpoint up.
|
||||
expect(warning.className).not.toMatch(/(^|\s)hidden(\s|$)/);
|
||||
fireEvent.click(screen.getByText('delete'));
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [{ path: ['tags', 'urgent'], value: null }]));
|
||||
});
|
||||
|
||||
it('shows the tags read-only with "Set by your organisation" when the admin locked them', async () => {
|
||||
setLocked(true);
|
||||
keywords = { '$label:found': 1 };
|
||||
const { container } = render(<SharedTagSettings />);
|
||||
expect(await screen.findByText('Urgent')).toBeInTheDocument();
|
||||
await screen.findByText('found_on_messages');
|
||||
expect(container.querySelector('[data-org-locked]')).not.toBeNull();
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('edit')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('delete')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^edit_tag/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^delete_tag/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^name_it/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is read-only with a note for a member without the right to change the Inbox', async () => {
|
||||
@@ -89,23 +135,50 @@ describe('SharedTagSettings', () => {
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks for no rights of a mailbox a person shared', async () => {
|
||||
principalType = 'individual';
|
||||
useEmailStore.setState({ mailboxes: [] });
|
||||
render(<SharedTagSettings />);
|
||||
await waitFor(() => expect(useSharedTagsStore.getState().entries[sharedTagsKey('login-1', 'team')]?.status).toBe('personal'));
|
||||
expect(client.getMailboxes).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('shared-tags-account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says in one line that the server keeps no shared tags, with no editor', async () => {
|
||||
metadata = false;
|
||||
advertised = false;
|
||||
render(<SharedTagSettings />);
|
||||
expect(await screen.findByTestId('shared-tags-unsupported')).toHaveTextContent('unsupported');
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says so in one line when the shared tags couldn\'t be read', async () => {
|
||||
stored = null;
|
||||
render(<SharedTagSettings />);
|
||||
expect(await screen.findByTestId('shared-tags-unavailable')).toHaveTextContent('unavailable');
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a change the server didn\'t take and says it will try again', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
await screen.findByText('Urgent');
|
||||
client.patchBulwarkMetadata.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Waiting' } });
|
||||
await act(async () => { fireEvent.click(screen.getByText('save')); });
|
||||
expect(await screen.findByTestId('shared-tags-pending')).toHaveTextContent('save_pending');
|
||||
expect(screen.getByText('Waiting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers to name a tag found on the messages, keeping its id', async () => {
|
||||
keywords = { '$label:waiting-for-customer': 2 };
|
||||
render(<SharedTagSettings />);
|
||||
fireEvent.click(await screen.findByLabelText('name_it'));
|
||||
fireEvent.click(await screen.findByLabelText('name_it:waiting-for-customer'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Customer' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
await waitFor(() => expect(client.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', {
|
||||
list: [URGENT, { id: 'waiting-for-customer', label: 'Customer', color: expect.any(String) }],
|
||||
}));
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
|
||||
{ path: ['tags', 'waiting-for-customer'], value: { label: 'Customer', color: expect.any(String) } },
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Loader2, Pencil, Plus, Trash2, Users, X } from "@/components/icons";
|
||||
import { TagBadge } from "@/components/email/tag-badge";
|
||||
@@ -9,12 +9,12 @@ import { useRestriction } from "@/hooks/use-restriction";
|
||||
import { MetadataTooLargeError } from "@/lib/jmap/bulwark-metadata";
|
||||
import type { MailboxRights } from "@/lib/jmap/types";
|
||||
import { composeKeywordId, MAX_KEYWORD_ID_LENGTH } from "@/lib/keyword-nesting";
|
||||
import { MAX_SHARED_TAG_LABEL, groupAccountTags, removeSharedTag, upsertSharedTag } from "@/lib/shared-tags";
|
||||
import { MAX_SHARED_TAG_LABEL, groupAccountTags, type SharedTagChange } from "@/lib/shared-tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import { saveSharedTags, sharedTagsKey, useSharedTagsStore } from "@/stores/shared-tags-store";
|
||||
import { KEYWORD_PALETTE, useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import { SharedTagsSaveDeferredError, saveSharedTags, sharedTagsKey, useSharedTagsStore } from "@/stores/shared-tags-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { KeywordColorPicker } from "./keyword-settings";
|
||||
@@ -28,13 +28,16 @@ export function maySetSharedTags(rights: Partial<MailboxRights> | null | undefin
|
||||
return typeof rights.maySetMetadata === "boolean" ? rights.maySetMetadata : rights.mayRename === true;
|
||||
}
|
||||
|
||||
/** The user's rights on the Inbox of a group account, from the folder list or asked once. */
|
||||
function useInboxRights(scope: TagScope): Partial<MailboxRights> | null | undefined {
|
||||
/**
|
||||
* The user's rights on the Inbox of a confirmed group account, from the
|
||||
* folder list or asked once. Nothing is asked for any other account.
|
||||
*/
|
||||
function useInboxRights(scope: TagScope, enabled: boolean): Partial<MailboxRights> | null | undefined {
|
||||
const fromStore = useEmailStore((state) =>
|
||||
state.mailboxes.find((mailbox) => mailbox.accountId === scope.accountId && mailbox.role === "inbox")?.myRights);
|
||||
const [fetched, setFetched] = useState<Partial<MailboxRights> | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (fromStore) return;
|
||||
if (!enabled || fromStore) return;
|
||||
let cancelled = false;
|
||||
const client = clientForLogin(scope.loginId) as unknown as { getMailboxes?: (accountId?: string) => Promise<Array<{ role?: string; myRights?: MailboxRights }>> } | null;
|
||||
if (!client?.getMailboxes) return;
|
||||
@@ -42,8 +45,8 @@ function useInboxRights(scope: TagScope): Partial<MailboxRights> | null | undefi
|
||||
.then((mailboxes) => { if (!cancelled) setFetched(mailboxes.find((m) => m.role === "inbox")?.myRights ?? null); })
|
||||
.catch(() => { if (!cancelled) setFetched(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [fromStore, scope.loginId, scope.accountId]);
|
||||
return fromStore ?? fetched;
|
||||
}, [enabled, fromStore, scope.loginId, scope.accountId]);
|
||||
return enabled ? fromStore ?? fetched : undefined;
|
||||
}
|
||||
|
||||
function SharedTagForm({
|
||||
@@ -59,17 +62,22 @@ function SharedTagForm({
|
||||
fixedId?: string;
|
||||
takenIds: string[];
|
||||
saving: boolean;
|
||||
onSave: (tag: KeywordDefinition) => void;
|
||||
/** `color` only when it was picked (or the tag is new), so a color this version doesn't know survives a rename. */
|
||||
onSave: (id: string, value: { label: string; color?: string }) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations("shared_tags");
|
||||
const [label, setLabel] = useState(initial?.label ?? "");
|
||||
const [color, setColor] = useState(initial?.color ?? "blue");
|
||||
const [colorPicked, setColorPicked] = useState(false);
|
||||
const id = fixedId ?? composeKeywordId(null, label);
|
||||
const duplicate = !fixedId && id.length > 0 && takenIds.includes(id.toLowerCase());
|
||||
const tooLong = !fixedId && id.length > MAX_KEYWORD_ID_LENGTH;
|
||||
const valid = label.trim().length > 0 && id.length > 0 && !duplicate && !tooLong;
|
||||
const save = () => { if (valid && !saving) onSave({ id, label: label.trim(), color }); };
|
||||
const keepsColor = !!initial && !colorPicked;
|
||||
const save = () => {
|
||||
if (valid && !saving) onSave(id, keepsColor ? { label: label.trim() } : { label: label.trim(), color });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-primary/30 bg-accent/30 p-3" data-testid="shared-tag-form">
|
||||
@@ -94,14 +102,14 @@ function SharedTagForm({
|
||||
{tooLong && <p className="mt-1 text-xs text-destructive">{t("too_long")}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-muted-foreground">{t("colour")}</p>
|
||||
<KeywordColorPicker value={color} onChange={setColor} />
|
||||
<p className="mb-1.5 text-xs font-medium text-muted-foreground">{t("color")}</p>
|
||||
<KeywordColorPicker value={color} onChange={(next) => { setColor(next); setColorPicked(true); }} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
|
||||
className="flex min-h-[44px] items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted sm:min-h-0"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
{t("cancel")}
|
||||
@@ -110,7 +118,7 @@ function SharedTagForm({
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!valid || saving}
|
||||
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
className="flex min-h-[44px] items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:min-h-0"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
{t("save")}
|
||||
@@ -120,11 +128,14 @@ function SharedTagForm({
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
* 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");
|
||||
@@ -133,30 +144,45 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
const entry = useSharedTagsStore((state) => state.entries[key]);
|
||||
const nested = useSettingsStore((state) => state.nestedTags);
|
||||
const { locked } = useRestriction("sharedTags");
|
||||
const rights = useInboxRights(scope);
|
||||
const rights = useInboxRights(scope, status === "ready");
|
||||
const canWrite = maySetSharedTags(rights);
|
||||
const editable = !locked && canWrite;
|
||||
const [editing, setEditing] = useState<{ id?: string; fixed?: boolean } | null>(null);
|
||||
const [editing, setEditing] = useState<{ id?: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [focusId, setFocusId] = useState<string | null>(null);
|
||||
const editButtons = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
const shared = useMemo(() => (status === "ready" ? entry?.tags ?? [] : []), [status, entry]);
|
||||
const discovered = useMemo(
|
||||
() => (status === "ready" && entry ? groupAccountTags(entry.tags, entry.discovered, nested).definitions.slice(entry.tags.length) : []),
|
||||
[status, entry, nested],
|
||||
);
|
||||
const { 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: (list: KeywordDefinition[]) => KeywordDefinition[]) => {
|
||||
const save = async (change: SharedTagChange) => {
|
||||
const client = clientForLogin(scope.loginId);
|
||||
if (!client) return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSharedTags(key, scope.accountId, client, change);
|
||||
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 {
|
||||
@@ -183,33 +209,40 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unsupported") {
|
||||
if (status === "unsupported" || status === "unavailable") {
|
||||
return (
|
||||
<div className="space-y-2" data-testid="shared-tags-account" data-status="unsupported">
|
||||
<div className="space-y-2" data-testid="shared-tags-account" data-status={status}>
|
||||
{heading}
|
||||
<p className="text-xs text-muted-foreground" data-testid="shared-tags-unsupported">{t("unsupported")}</p>
|
||||
<p className="text-xs text-muted-foreground" data-testid={`shared-tags-${status}`}>{t(status)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const row = (tag: KeywordDefinition, found: boolean) => {
|
||||
if (editing && editing.id === tag.id) {
|
||||
const raw = entry?.stored?.entries[tag.id];
|
||||
// The form shows the stored color when this version knows it.
|
||||
const initial = { ...tag, color: typeof raw?.color === "string" && KEYWORD_PALETTE[raw.color] ? raw.color : tag.color };
|
||||
return (
|
||||
<SharedTagForm
|
||||
key={tag.id}
|
||||
initial={tag}
|
||||
initial={initial}
|
||||
fixedId={tag.id}
|
||||
takenIds={takenIds}
|
||||
saving={saving}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={async (next) => { if (await save((list) => upsertSharedTag(list, next))) setEditing(null); }}
|
||||
onCancel={() => { setEditing(null); setFocusId(tag.id); }}
|
||||
onSave={async (id, value) => {
|
||||
const next = found ? { label: value.label, color: value.color ?? tag.color } : value;
|
||||
if (await save({ id, value: next })) { setEditing(null); setFocusId(id); }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const confirming = editable && confirmDelete === tag.id;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 sm:min-h-0"
|
||||
className="flex min-h-[44px] flex-wrap items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 sm:min-h-0 sm:flex-nowrap"
|
||||
data-testid="shared-tag-row"
|
||||
data-tag-id={tag.id}
|
||||
>
|
||||
@@ -217,17 +250,21 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
<TagBadge tagId={tag.id} variant="badge" className="text-xs" />
|
||||
{found && <span className="truncate text-xs text-muted-foreground">{t("found_on_messages")}</span>}
|
||||
</div>
|
||||
{editable && confirmDelete === tag.id ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">{t("delete_confirm")}</span>
|
||||
{confirming ? (
|
||||
<div className="flex basis-full flex-wrap items-center justify-end gap-1 sm:basis-auto sm:flex-nowrap" data-testid="shared-tag-delete-confirm">
|
||||
<span className="text-xs text-muted-foreground">{t("delete_confirm")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => { if (await save((list) => removeSharedTag(list, tag.id))) setConfirmDelete(null); }}
|
||||
className="rounded-md px-2 py-1 text-xs text-destructive hover:bg-destructive/10"
|
||||
onClick={async () => { if (await save({ id: tag.id, value: null })) setConfirmDelete(null); }}
|
||||
className="min-h-[44px] rounded-md px-3 py-1 text-xs text-destructive hover:bg-destructive/10 sm:min-h-0"
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirmDelete(null)} className="rounded-md px-2 py-1 text-xs hover:bg-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConfirmDelete(null); setFocusId(tag.id); }}
|
||||
className="min-h-[44px] rounded-md px-3 py-1 text-xs hover:bg-muted sm:min-h-0"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -235,10 +272,11 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => { if (el) editButtons.current.set(tag.id, el); else editButtons.current.delete(tag.id); }}
|
||||
onClick={() => { setEditing({ id: tag.id }); setConfirmDelete(null); }}
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={found ? t("name_it") : t("edit_tag")}
|
||||
aria-label={found ? t("name_it") : t("edit_tag")}
|
||||
className={cn(ICON_BUTTON, "hover:text-foreground")}
|
||||
title={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
|
||||
aria-label={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
|
||||
>
|
||||
{found ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -246,9 +284,9 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConfirmDelete(tag.id); setEditing(null); }}
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-destructive"
|
||||
title={t("delete")}
|
||||
aria-label={t("delete")}
|
||||
className={cn(ICON_BUTTON, "hover:text-destructive")}
|
||||
title={t("delete_tag", { name: tag.label })}
|
||||
aria-label={t("delete_tag", { name: tag.label })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -268,6 +306,9 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
)}
|
||||
{shared.map((tag) => row(tag, false))}
|
||||
{discovered.map((tag) => row(tag, true))}
|
||||
{(entry?.pending.length ?? 0) > 0 && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="shared-tags-pending">{t("save_pending")}</p>
|
||||
)}
|
||||
{locked ? (
|
||||
<OrgLockedNote />
|
||||
) : !canWrite ? (
|
||||
@@ -279,14 +320,14 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
takenIds={takenIds}
|
||||
saving={saving}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={async (next) => { if (await save((list) => upsertSharedTag(list, next))) setEditing(null); }}
|
||||
onSave={async (id, value) => { if (await save({ id, value })) { setEditing(null); setFocusId(id); } }}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing({}); setConfirmDelete(null); }}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors",
|
||||
"flex min-h-[44px] items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors sm:min-h-0",
|
||||
"hover:border-primary hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -301,17 +342,23 @@ function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string
|
||||
|
||||
/**
|
||||
* Settings > Folders & tags > Shared tags: the tags of each group mailbox
|
||||
* the user is a member of. Hidden without group accounts.
|
||||
* 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(
|
||||
() => (client && typeof client.getSharedAccounts === "function"
|
||||
? client.getSharedAccounts().filter((account) => !account.isPrimary && account.capabilities.mail && client.getAccountInfo?.(account.id)?.isPersonal === false)
|
||||
: []),
|
||||
[client],
|
||||
() => {
|
||||
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;
|
||||
|
||||
@@ -18,7 +18,7 @@ function setEntry(scope: TagScope, entry: Partial<SharedTagsEntry>) {
|
||||
useSharedTagsStore.setState((state) => ({
|
||||
entries: {
|
||||
...state.entries,
|
||||
[sharedTagsKey(scope.loginId, scope.accountId)]: { status: 'ready', tags: [], discovered: {}, loadedAt: 1, ...entry },
|
||||
[sharedTagsKey(scope.loginId, scope.accountId)]: { status: 'ready', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 1, discoveredAt: 1, readFailedAt: null, ...entry },
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -50,6 +50,12 @@ describe('tag names and colours per account', () => {
|
||||
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);
|
||||
|
||||
@@ -1,78 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EMPTY_SHARED_TAGS,
|
||||
groupAccountTags,
|
||||
parseSharedTags,
|
||||
removeSharedTag,
|
||||
sharedTagsValue,
|
||||
upsertSharedTag,
|
||||
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: `{ list: KeywordDefinition[] }` (Stalwart takes only objects as
|
||||
// namespace values).
|
||||
// its Inbox: a map by tag id under `tags`, changed one tag at a time.
|
||||
|
||||
describe('parseSharedTags', () => {
|
||||
it('reads the definitions under list', () => {
|
||||
expect(parseSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }))
|
||||
.toEqual([{ id: 'urgent', label: 'Urgent', color: 'red' }]);
|
||||
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('takes a bare array another client may have written', () => {
|
||||
expect(parseSharedTags([{ id: 'urgent', label: 'Urgent', color: 'red' }])).toHaveLength(1);
|
||||
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 and keeps the first of an id in any case', () => {
|
||||
const tags = parseSharedTags({
|
||||
list: [
|
||||
{ id: 'urgent', label: 'Urgent', color: 'red' },
|
||||
{ id: 'URGENT', label: 'Twice', color: 'blue' },
|
||||
{ id: 'has space', label: 'Bad', color: 'red' },
|
||||
{ id: 'ctl\u0001', label: 'Bad', color: 'red' },
|
||||
{ label: 'No id', color: 'red' },
|
||||
'urgent',
|
||||
null,
|
||||
{ id: 'later', label: ' ', color: 'not-a-colour', visibility: 'sometimes' },
|
||||
],
|
||||
});
|
||||
expect(tags).toEqual([
|
||||
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' },
|
||||
// An empty name falls back to the id, an unknown colour to grey.
|
||||
{ id: 'later', label: 'later', color: 'gray' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads nothing from a missing or odd value', () => {
|
||||
expect(parseSharedTags(undefined)).toEqual([]);
|
||||
expect(parseSharedTags({ list: 'x' })).toEqual([]);
|
||||
expect(parseSharedTags(7)).toEqual([]);
|
||||
expect(parseSharedTags({ tags: { x: { color: 'blue' } } })).toEqual([{ id: 'x', label: 'x', color: 'blue' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedTagsValue', () => {
|
||||
it('writes id, name, colour and visibility only', () => {
|
||||
expect(sharedTagsValue([{ id: 'a', label: 'A', color: 'red', addedBy: 'plugin-x', visibility: 'unread' }]))
|
||||
.toEqual({ list: [{ id: 'a', label: 'A', color: 'red', visibility: 'unread' }] });
|
||||
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('refuses a list larger than one metadata entry', () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) => ({ id: `tag-number-${i}`, label: `Tag number ${i}`, color: 'blue' }));
|
||||
expect(() => sharedTagsValue(many)).toThrow(MetadataTooLargeError);
|
||||
expect(() => sharedTagsValue(many.slice(0, 20))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertSharedTag and removeSharedTag', () => {
|
||||
const list = [{ id: 'urgent', label: 'Urgent', color: 'red' }, { id: 'later', label: 'Later', color: 'blue' }];
|
||||
|
||||
it('adds a new tag at the end and replaces one with the same id in place', () => {
|
||||
expect(upsertSharedTag(list, { id: 'new', label: 'New', color: 'green' }).map((t) => t.id)).toEqual(['urgent', 'later', 'new']);
|
||||
expect(upsertSharedTag(list, { id: 'Urgent', label: 'Very urgent', color: 'red-dark' })[0])
|
||||
.toEqual({ id: 'urgent', label: 'Very urgent', color: 'red-dark' });
|
||||
it('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('removes by id in any case', () => {
|
||||
expect(removeSharedTag(list, 'URGENT')).toEqual([{ id: 'later', label: 'Later', color: 'blue' }]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +96,6 @@ describe('groupAccountTags', () => {
|
||||
false,
|
||||
);
|
||||
expect(definitions.map((t) => t.id)).toEqual(['urgent', 'q3-invoices']);
|
||||
expect(definitions[0]).toEqual({ id: 'urgent', label: 'Urgent', color: 'red' });
|
||||
expect(definitions[1].label).toBe('Q3 Invoices');
|
||||
expect(discoveredIds).toEqual(['q3-invoices']);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,23 @@ export class MetadataUnsupportedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A patch inside an entry whose parent value doesn't exist (yet, or any more). */
|
||||
export class MetadataPatchError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MetadataPatchError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One segment of a patch path inside a metadata entry, escaped the JSON
|
||||
* Pointer way (`~` as `~0`, `/` as `~1`), which Stalwart v1.0.0 decodes:
|
||||
* a tag id `work/clients` is the segment `work~1clients`.
|
||||
*/
|
||||
export function escapeMetadataPathSegment(segment: string): string {
|
||||
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
}
|
||||
|
||||
/** `tags`, `template.<id>`: lowercase, digits, dashes, dot-separated. */
|
||||
export function isValidMetadataKey(key: string): boolean {
|
||||
return key.length <= 120 && KEY_RE.test(key);
|
||||
|
||||
@@ -624,6 +624,8 @@ export interface IJMAPClient {
|
||||
supportsBulwarkMetadata?(accountId?: string): boolean;
|
||||
getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise<Record<string, unknown> | null>;
|
||||
setBulwarkMetadata?(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise<void>;
|
||||
advertisesBulwarkMetadata?(accountId?: string): boolean;
|
||||
patchBulwarkMetadata?(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }>;
|
||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
+39
-2
@@ -1,4 +1,4 @@
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { IncompleteDeleteError } from "./delete-error";
|
||||
import { ArchiveEmailsError } from "./archive-error";
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
@@ -7161,6 +7161,40 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
|
||||
throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`);
|
||||
}
|
||||
await this.updateInboxMetadata(id, { [`${property}/${namespaceFor(key)}`]: value }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account advertises vendor metadata on mailboxes, whatever
|
||||
* its last request did (supportsBulwarkMetadata also pauses after a
|
||||
* failure). False means the server keeps no metadata for this account.
|
||||
*/
|
||||
advertisesBulwarkMetadata(accountId?: string): boolean {
|
||||
const id = accountId || this.accountId;
|
||||
return advertisesMailboxMetadata(this.accounts[id]?.accountCapabilities as Record<string, unknown> | undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes values inside one Bulwark metadata entry of the account's Inbox
|
||||
* and nothing else: each patch path (segments, escaped here) is set, null
|
||||
* removes it, an empty path is the whole entry. Returns the Mailbox state
|
||||
* after the change, so a caller can recognise the push of its own write.
|
||||
* Throws MetadataTooLargeError (the server's size limit),
|
||||
* MetadataPatchError (a parent of a path doesn't exist) and
|
||||
* MetadataUnsupportedError.
|
||||
*/
|
||||
async patchBulwarkMetadata(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }> {
|
||||
const id = accountId || this.accountId;
|
||||
if (!this.supportsBulwarkMetadata(id)) throw new MetadataUnsupportedError();
|
||||
const update: Record<string, unknown> = {};
|
||||
for (const { path, value } of patches) {
|
||||
update[[`metadata/${namespaceFor(key)}`, ...path.map(escapeMetadataPathSegment)].join("/")] = value;
|
||||
}
|
||||
return { newState: await this.updateInboxMetadata(id, update) };
|
||||
}
|
||||
|
||||
/** One Mailbox/set of the Inbox's metadata; answers the new Mailbox state. */
|
||||
private async updateInboxMetadata(id: string, update: Record<string, unknown>, options?: { private?: boolean }): Promise<string | undefined> {
|
||||
let inboxId = this.metadataInboxIds.get(id);
|
||||
if (!inboxId) {
|
||||
await this.getBulwarkMetadata(id, options);
|
||||
@@ -7168,7 +7202,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") {
|
||||
@@ -7177,8 +7211,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
const notUpdated = (result as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
|
||||
if (notUpdated) {
|
||||
if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!);
|
||||
if (notUpdated.type === "invalidPatch") throw new MetadataPatchError(notUpdated.description || "A parent of the patched value does not exist");
|
||||
throw new Error(notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
|
||||
}
|
||||
const newState = (result as { newState?: unknown }).newState;
|
||||
return typeof newState === "string" ? newState : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+119
-64
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared tags of a group account: the tag names and colours every member of
|
||||
* 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).
|
||||
*
|
||||
@@ -7,12 +7,15 @@
|
||||
* (local-data/org-features-facts.md §1), so `$label:urgent` on a team
|
||||
* message is the team's tag, not one member's. A personal definition only
|
||||
* names it for the member who made it; the shared definitions name it for
|
||||
* everyone. Stalwart takes only objects as namespace values, so the list
|
||||
* sits under `list`.
|
||||
* everyone.
|
||||
*
|
||||
* Pure functions only: reading and checking what the server holds, the
|
||||
* value written back, and the list a group account shows (the shared
|
||||
* definitions plus the tags found on its messages, which are never hidden).
|
||||
* Stored as a map by tag id, `{ "tags": { "<id>": { "label", "color", ... } } }`,
|
||||
* so a change patches one tag (`metadata/org.bulwarkmail.tags/tags/<id>`)
|
||||
* and two members adding different tags at the same time both keep theirs.
|
||||
* Fields this version doesn't know are kept. Earlier shapes (`{ "list": [...] }`
|
||||
* or a bare array) are read, and converted once by the first save.
|
||||
*
|
||||
* Pure functions only.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -41,7 +44,7 @@ const BAD_ID_CHARS = /[\s(){\]%*"\\]/;
|
||||
|
||||
/** Whether a tag id from the server can be a keyword (no control characters either). */
|
||||
function isKeywordSafe(id: string): boolean {
|
||||
if (BAD_ID_CHARS.test(id)) return false;
|
||||
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;
|
||||
@@ -49,82 +52,134 @@ function isKeywordSafe(id: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface SharedTagsValue {
|
||||
list: KeywordDefinition[];
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** What the server holds, as read. */
|
||||
export interface StoredSharedTags {
|
||||
/** The raw entry of each tag by id, unknown fields included. */
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
/** Other values of the namespace next to `tags`, kept as they are. */
|
||||
extra: Record<string, unknown>;
|
||||
/** The map shape is on the server, so changes can patch one tag. */
|
||||
patchable: boolean;
|
||||
}
|
||||
|
||||
/** One definition from the server, or null when it can't be one. */
|
||||
function parseDefinition(value: unknown): KeywordDefinition | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
||||
if (!id || id.length > MAX_KEYWORD_ID_LENGTH || !isKeywordSafe(id)) return null;
|
||||
const label = typeof raw.label === 'string' ? raw.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
|
||||
const color = typeof raw.color === 'string' && KEYWORD_PALETTE[raw.color] ? raw.color : FALLBACK_KEYWORD_COLOR;
|
||||
export const EMPTY_SHARED_TAGS: StoredSharedTags = { entries: {}, extra: {}, patchable: false };
|
||||
|
||||
/**
|
||||
* Reads the namespace value: the map shape, or the earlier `{ list }` and
|
||||
* bare-array shapes (converted to entries; `patchable` stays false so the
|
||||
* first save writes the map shape).
|
||||
*/
|
||||
export function readSharedTags(value: unknown): StoredSharedTags {
|
||||
if (isObject(value) && isObject(value.tags)) {
|
||||
const entries: Record<string, Record<string, unknown>> = {};
|
||||
for (const [id, entry] of Object.entries(value.tags)) {
|
||||
if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry;
|
||||
}
|
||||
const { tags: _tags, ...extra } = value;
|
||||
void _tags;
|
||||
return { entries, extra, patchable: true };
|
||||
}
|
||||
const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : [];
|
||||
const entries: Record<string, Record<string, unknown>> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (!isObject(item) || typeof item.id !== 'string') continue;
|
||||
const id = item.id.trim();
|
||||
if (!isKeywordSafe(id) || seen.has(id.toLowerCase())) continue;
|
||||
seen.add(id.toLowerCase());
|
||||
const { id: _id, ...rest } = item;
|
||||
void _id;
|
||||
entries[id] = rest;
|
||||
}
|
||||
return { entries, extra: {}, patchable: false };
|
||||
}
|
||||
|
||||
/** One tag as the app shows it; an unknown color shows grey but stays stored. */
|
||||
function definitionOf(id: string, entry: Record<string, unknown>): KeywordDefinition {
|
||||
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
|
||||
const color = typeof entry.color === 'string' && KEYWORD_PALETTE[entry.color] ? entry.color : FALLBACK_KEYWORD_COLOR;
|
||||
const definition: KeywordDefinition = { id, label: label || id, color };
|
||||
if (typeof raw.visibility === 'string' && (VISIBILITIES as readonly string[]).includes(raw.visibility)) {
|
||||
definition.visibility = raw.visibility as KeywordVisibility;
|
||||
if (typeof entry.visibility === 'string' && (VISIBILITIES as readonly string[]).includes(entry.visibility)) {
|
||||
definition.visibility = entry.visibility as KeywordVisibility;
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared definitions in a metadata value: `{ list: [...] }`, or a bare
|
||||
* array from another client. Anything that isn't a definition is dropped,
|
||||
* and an id the list already has (in any case) keeps its first entry.
|
||||
*/
|
||||
/** 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[] {
|
||||
const items = Array.isArray(value)
|
||||
? value
|
||||
: value && typeof value === 'object' && Array.isArray((value as { list?: unknown }).list)
|
||||
? (value as { list: unknown[] }).list
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
const out: KeywordDefinition[] = [];
|
||||
for (const item of items) {
|
||||
const definition = parseDefinition(item);
|
||||
if (!definition) continue;
|
||||
const folded = definition.id.toLowerCase();
|
||||
if (seen.has(folded)) continue;
|
||||
seen.add(folded);
|
||||
out.push(definition);
|
||||
}
|
||||
return out;
|
||||
return sharedDefinitions(readSharedTags(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* The value written to the server: id, label, colour and visibility only (a
|
||||
* plugin's `addedBy` is about one member's settings). Throws
|
||||
* MetadataTooLargeError when it would not fit the 8 KB of one namespace.
|
||||
* 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 function sharedTagsValue(list: KeywordDefinition[]): SharedTagsValue {
|
||||
const value: SharedTagsValue = {
|
||||
list: parseSharedTags(list).map(({ id, label, color, visibility }) => (visibility ? { id, label, color, visibility } : { id, label, color })),
|
||||
};
|
||||
if (metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
|
||||
export interface SharedTagChange {
|
||||
id: string;
|
||||
value: { label: string; color?: string } | null;
|
||||
}
|
||||
|
||||
/** The id under which `id` is stored, in any case, or `id` itself. */
|
||||
function storedId(entries: Record<string, unknown>, id: string): string {
|
||||
const folded = id.toLowerCase();
|
||||
return Object.keys(entries).find((key) => key.toLowerCase() === folded) ?? id;
|
||||
}
|
||||
|
||||
/** The stored tags with the changes applied, nothing else touched. */
|
||||
export function applySharedTagChanges(stored: StoredSharedTags, changes: SharedTagChange[]): StoredSharedTags {
|
||||
const entries = { ...stored.entries };
|
||||
for (const change of changes) {
|
||||
const id = storedId(entries, change.id);
|
||||
if (change.value === null) {
|
||||
delete entries[id];
|
||||
continue;
|
||||
}
|
||||
const { label, color } = change.value;
|
||||
entries[id] = { ...(entries[id] ?? {}), label, ...(color !== undefined ? { color } : {}) };
|
||||
}
|
||||
return { ...stored, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Mailbox/set patches for the changes, and what the server holds after
|
||||
* them. On the map shape each changed tag is one patch and no other tag is
|
||||
* written; on an older shape (or none) the whole namespace is written once
|
||||
* in the map shape. Throws MetadataTooLargeError when the result would take
|
||||
* more than the 8 KB of one namespace (UTF-8 bytes of its JSON).
|
||||
*/
|
||||
export function planSharedTagsSave(
|
||||
stored: StoredSharedTags,
|
||||
changes: SharedTagChange[],
|
||||
): { patches: Array<{ path: string[]; value: unknown }>; result: StoredSharedTags } {
|
||||
const next = applySharedTagChanges(stored, changes);
|
||||
const namespace = { ...next.extra, tags: next.entries };
|
||||
if (metadataValueBytes(namespace) > MAX_METADATA_ENTRY_BYTES) {
|
||||
throw new MetadataTooLargeError(`The shared tags take more than ${MAX_METADATA_ENTRY_BYTES} bytes`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The list with `tag` added, or replacing the definition with its id. */
|
||||
export function upsertSharedTag(list: KeywordDefinition[], tag: KeywordDefinition): KeywordDefinition[] {
|
||||
const folded = tag.id.toLowerCase();
|
||||
const index = list.findIndex((item) => item.id.toLowerCase() === folded);
|
||||
if (index === -1) return [...list, tag];
|
||||
return list.map((item, i) => (i === index ? { ...tag, id: item.id } : item));
|
||||
}
|
||||
|
||||
/** The list without the definition of `id`. The keyword stays on its messages. */
|
||||
export function removeSharedTag(list: KeywordDefinition[], id: string): KeywordDefinition[] {
|
||||
const folded = id.toLowerCase();
|
||||
return list.filter((item) => item.id.toLowerCase() !== folded);
|
||||
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 colour keyword discovery proposes for it. A keyword that is on a
|
||||
* and color keyword discovery proposes for it. A keyword that is on a
|
||||
* message is never hidden because nobody defined it.
|
||||
*/
|
||||
export function groupAccountTags(
|
||||
|
||||
@@ -330,26 +330,29 @@
|
||||
"title": "Geteilte Labels",
|
||||
"description": "Labels Ihrer Gruppenpostfächer. Alle in der Gruppe sehen dieselben Namen und Farben an ihren Nachrichten.",
|
||||
"picker_heading": "Mit allen in diesem Postfach geteilt",
|
||||
"sidebar_heading": "Tags",
|
||||
"sidebar_heading": "Labels",
|
||||
"edit": "Geteilte Labels bearbeiten",
|
||||
"edit_tag": "Label bearbeiten",
|
||||
"edit_tag": "{name} bearbeiten",
|
||||
"delete_tag": "{name} löschen",
|
||||
"name_it": "{name} benennen",
|
||||
"add": "Geteiltes Label hinzufügen",
|
||||
"name": "Name",
|
||||
"colour": "Farbe",
|
||||
"color": "Farbe",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"delete_confirm": "Für alle löschen? Das Label bleibt an seinen Nachrichten.",
|
||||
"name_it": "Namen vergeben",
|
||||
"found_on_messages": "An Nachrichten, noch ohne Namen",
|
||||
"empty": "Noch keine geteilten Labels.",
|
||||
"exists": "Dieses Postfach hat dieses Label schon.",
|
||||
"too_long": "Dieser Name ist zu lang.",
|
||||
"loading": "Wird geladen…",
|
||||
"unsupported": "Dieser Mailserver kann keine geteilten Labels speichern, daher zeigt dieses Postfach Ihre eigenen Labels.",
|
||||
"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."
|
||||
"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": {
|
||||
|
||||
@@ -328,28 +328,31 @@
|
||||
},
|
||||
"shared_tags": {
|
||||
"title": "Shared tags",
|
||||
"description": "Tags of your group mailboxes. Everyone in the group sees the same names and colours on its mail.",
|
||||
"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 tag",
|
||||
"edit_tag": "Edit {name}",
|
||||
"delete_tag": "Delete {name}",
|
||||
"name_it": "Name {name}",
|
||||
"add": "Add shared tag",
|
||||
"name": "Name",
|
||||
"colour": "Colour",
|
||||
"color": "Color",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"delete_confirm": "Delete for everyone? The tag stays on its messages.",
|
||||
"name_it": "Give it a name",
|
||||
"found_on_messages": "On messages, no name yet",
|
||||
"empty": "No shared tags yet.",
|
||||
"exists": "This mailbox already has this tag.",
|
||||
"too_long": "This name is too long.",
|
||||
"loading": "Loading…",
|
||||
"unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags.",
|
||||
"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."
|
||||
"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": {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { MetadataTooLargeError, MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata';
|
||||
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,
|
||||
@@ -12,125 +17,276 @@ import {
|
||||
} from '../shared-tags-store';
|
||||
|
||||
const KEY = sharedTagsKey('login-1', 'team');
|
||||
const URGENT = { id: 'urgent', label: 'Urgent', color: 'red' };
|
||||
const URGENT = { label: 'Urgent', color: 'red' };
|
||||
|
||||
function fakeClient(options: {
|
||||
type?: string | null;
|
||||
metadata?: boolean;
|
||||
shared?: Record<string, unknown> | null;
|
||||
keywords?: Record<string, number>;
|
||||
} = {}) {
|
||||
let stored: Record<string, unknown> | null = options.shared === undefined ? { tags: { list: [URGENT] } } : options.shared;
|
||||
/** org-team's Inbox metadata on a v1.0.0 server, shared by every member's client. */
|
||||
class FakeServer {
|
||||
namespace: Record<string, unknown> | null = { tags: { urgent: { ...URGENT } } };
|
||||
state = 1;
|
||||
failReads = false;
|
||||
failWrites = false;
|
||||
|
||||
patch(patches: Array<{ path: string[]; value: unknown }>): { newState: string } {
|
||||
if (this.failWrites) throw new TypeError('Failed to fetch');
|
||||
let next = this.namespace === null ? null : structuredClone(this.namespace);
|
||||
for (const { path, value } of patches) {
|
||||
if (path.length === 0) { next = value as Record<string, unknown> | null; continue; }
|
||||
if (!next) throw new MetadataPatchError('The namespace does not exist.');
|
||||
let parent: Record<string, unknown> = next;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
const child = parent[segment];
|
||||
if (!child || typeof child !== 'object' || Array.isArray(child)) throw new MetadataPatchError('A parent of the patched value does not exist or is not an object.');
|
||||
parent = child as Record<string, unknown>;
|
||||
}
|
||||
const last = path[path.length - 1];
|
||||
if (value === null) delete parent[last];
|
||||
else parent[last] = value;
|
||||
}
|
||||
if (next && metadataValueBytes(next) > MAX_METADATA_ENTRY_BYTES) throw new MetadataTooLargeError('exceeds the maximum');
|
||||
this.namespace = next;
|
||||
this.state += 1;
|
||||
return { newState: `s${this.state}` };
|
||||
}
|
||||
}
|
||||
|
||||
function member(server: FakeServer, options: { type?: string | null; advertised?: boolean; keywords?: Record<string, number> } = {}) {
|
||||
let paused = false;
|
||||
const client = {
|
||||
getAccountInfo: vi.fn(() => ({ isPersonal: false })),
|
||||
getPrincipalTypes: vi.fn(async (ids: string[]) =>
|
||||
options.type === null ? null : Object.fromEntries(ids.map((id) => [id, options.type ?? 'group']))),
|
||||
supportsBulwarkMetadata: vi.fn(() => options.metadata !== false),
|
||||
getBulwarkMetadata: vi.fn(async () => stored),
|
||||
setBulwarkMetadata: vi.fn(async (_id: string | undefined, key: string, value: unknown) => {
|
||||
stored = { ...(stored ?? {}), [key]: value };
|
||||
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 })),
|
||||
setStored: (value: Record<string, unknown> | null) => { stored = value; },
|
||||
endPause: () => { paused = false; },
|
||||
};
|
||||
return client as typeof client & SharedTagsClient;
|
||||
}
|
||||
|
||||
const entry = () => useSharedTagsStore.getState().entries[KEY];
|
||||
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'));
|
||||
}
|
||||
|
||||
beforeEach(() => resetSharedTags());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
let server: FakeServer;
|
||||
beforeEach(() => { resetSharedTags(); server = new FakeServer(); });
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
|
||||
describe('loadSharedTags', () => {
|
||||
it('reads the shared definitions of a group account', async () => {
|
||||
const client = fakeClient({ keywords: { '$label:found': 3, $seen: 9 } });
|
||||
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: [URGENT] });
|
||||
// The recent messages are scanned in the group account, for tags only.
|
||||
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 = fakeClient({ type: 'individual' });
|
||||
const client = member(server, { type: 'individual' });
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry().status).toBe('personal');
|
||||
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts any non-personal account as a group when the server won\'t say', async () => {
|
||||
const client = fakeClient({ type: null });
|
||||
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('ready');
|
||||
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 to a server without metadata (0.16)', async () => {
|
||||
const client = fakeClient({ metadata: false });
|
||||
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('falls back when the metadata request fails (the 1.0 test build drops it)', async () => {
|
||||
const client = fakeClient({ shared: null });
|
||||
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('unsupported');
|
||||
});
|
||||
expect(entry().status).toBe('unavailable');
|
||||
|
||||
it('loads once per client and again for a new sign-in', async () => {
|
||||
const client = fakeClient();
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
|
||||
const next = fakeClient({ shared: { tags: { list: [] } } });
|
||||
await loadSharedTags(KEY, 'team', next);
|
||||
expect(entry().tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads them again when push reports a Mailbox change in the group account', async () => {
|
||||
const client = fakeClient();
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
const later = { id: 'later', label: 'Later', color: 'blue' };
|
||||
client.setStored({ tags: { list: [URGENT, later] } });
|
||||
|
||||
// A change in another account of the login doesn't concern it.
|
||||
emitPushStateChange({ '@type': 'StateChange', changed: { own: { Mailbox: 's2' } } }, client as never, 'active');
|
||||
// Still paused: coming back to the tab asks nothing.
|
||||
server.failReads = false;
|
||||
becomeVisible();
|
||||
await Promise.resolve();
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
|
||||
|
||||
emitPushStateChange({ '@type': 'StateChange', changed: { team: { Mailbox: 's2' } } }, client as never, 'active');
|
||||
await vi.waitFor(() => expect(entry().tags).toEqual([URGENT, later]));
|
||||
client.endPause();
|
||||
becomeVisible();
|
||||
await vi.waitFor(() => expect(entry().status).toBe('ready'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSharedTags', () => {
|
||||
it('applies the change to the newest definitions on the server and writes them back', async () => {
|
||||
const client = fakeClient();
|
||||
it('keeps the last good tags when a later read fails', async () => {
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
// Another member added a tag meanwhile.
|
||||
client.setStored({ tags: { list: [URGENT, { id: 'theirs', label: 'Theirs', color: 'green' }] } });
|
||||
await saveSharedTags(KEY, 'team', client, (list) => [...list, { id: 'mine', label: 'Mine', color: 'blue', addedBy: 'x' }]);
|
||||
expect(client.setBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', {
|
||||
list: [URGENT, { id: 'theirs', label: 'Theirs', color: 'green' }, { id: 'mine', label: 'Mine', color: 'blue' }],
|
||||
});
|
||||
expect(entry().tags.map((t) => t.id)).toEqual(['urgent', 'theirs', 'mine']);
|
||||
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('refuses a list that no longer fits and writes nothing', async () => {
|
||||
const client = fakeClient();
|
||||
const many = Array.from({ length: 200 }, (_, i) => ({ id: `tag-number-${i}`, label: `Tag number ${i}`, color: 'blue' }));
|
||||
await expect(saveSharedTags(KEY, 'team', client, () => many)).rejects.toBeInstanceOf(MetadataTooLargeError);
|
||||
expect(client.setBulwarkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses on a server without metadata', async () => {
|
||||
const client = fakeClient({ shared: null });
|
||||
await expect(saveSharedTags(KEY, 'team', client, (list) => list)).rejects.toBeInstanceOf(MetadataUnsupportedError);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
// The same, through the real JMAP client and recorded Stalwart answers.
|
||||
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': {
|
||||
@@ -175,36 +331,33 @@ describe('with the JMAP client', () => {
|
||||
if (method === 'Mailbox/query') {
|
||||
return json({ methodResponses: [['Mailbox/query', { ids: ['a'] }, 'q'], ['Mailbox/get', { list: [{ id: 'a', ...inbox }] }, 'g']] });
|
||||
}
|
||||
if (method === 'Mailbox/set') return json({ methodResponses: [['Mailbox/set', { updated: { a: null } }, '0']] });
|
||||
if (method === '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 writes the list as an object', async () => {
|
||||
const { client, requests } = await connected((body) => answers(body, { metadata: { 'org.bulwarkmail.tags': { list: [URGENT] }, 'org.bulwarkmail.template.x': {} } }));
|
||||
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: [URGENT] });
|
||||
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, (list) => [...list, { id: 'later', label: 'Later', color: 'blue' }]);
|
||||
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': { list: [URGENT, { id: 'later', label: 'Later', color: 'blue' }] } } },
|
||||
update: { a: { 'metadata/org.bulwarkmail.tags/tags/work~1clients': { label: 'Clients', color: 'blue' } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('turns a dropped metadata request into "not supported" without marking the login offline', async () => {
|
||||
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, {});
|
||||
});
|
||||
const connection = vi.fn();
|
||||
client.onConnectionChange(connection);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry().status).toBe('unsupported');
|
||||
expect(connection).not.toHaveBeenCalledWith(false);
|
||||
expect(entry().status).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
+222
-88
@@ -1,53 +1,85 @@
|
||||
/**
|
||||
* The shared tags of each group account a signed-in login reaches
|
||||
* (lib/shared-tags.ts): loaded the first time something on screen shows a
|
||||
* message or the sidebar of that account, kept per login and account, and
|
||||
* read again when the group's mailboxes change (push) or the tab comes back
|
||||
* after a while.
|
||||
* message or the sidebar of that account, kept per login and account.
|
||||
*
|
||||
* Why push and not polling: Stalwart bumps the Mailbox state of the group
|
||||
* account when its Inbox metadata changes, and every member's push channel
|
||||
* reports state changes of the accounts it can reach. So another member's
|
||||
* edit arrives as a Mailbox change for the group account, which costs
|
||||
* nothing extra to watch. Push can be off or broken (no EventSource, a
|
||||
* proxy that buffers it), so a tab that comes back to the foreground also
|
||||
* reads definitions older than two minutes again.
|
||||
* 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:
|
||||
* - `personal`: not a group principal (a mailbox a person shared); it keeps
|
||||
* the user's own tags.
|
||||
* - `unsupported`: a group, but the server keeps no metadata (0.16) or the
|
||||
* request failed (the 1.0 test build drops it); the group keeps the user's
|
||||
* own tags, as before, and the shared definitions can't be edited.
|
||||
* - `ready`: the shared definitions, plus the tags found on recent messages.
|
||||
* - `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 { MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata';
|
||||
import { MetadataPatchError, MetadataTooLargeError, MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata';
|
||||
import { tagIdFromKeyword } from '@/lib/keyword-discovery';
|
||||
import { SHARED_TAGS_KEY, parseSharedTags, sharedTagsValue } from '@/lib/shared-tags';
|
||||
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' | 'ready';
|
||||
export type SharedTagsStatus = 'loading' | 'personal' | 'unsupported' | 'unavailable' | 'ready';
|
||||
|
||||
export interface SharedTagsEntry {
|
||||
status: SharedTagsStatus;
|
||||
/** The shared definitions (status `ready` only). */
|
||||
/** The shared definitions with unsaved changes applied (status `ready`). */
|
||||
tags: KeywordDefinition[];
|
||||
/** What the server holds, as last read or written. */
|
||||
stored: StoredSharedTags | null;
|
||||
/** Changes a failed save left; sent again with the next good read. */
|
||||
pending: SharedTagChange[];
|
||||
/** Tag keywords found on the account's recent messages, with counts. */
|
||||
discovered: Record<string, number>;
|
||||
loadedAt: number;
|
||||
discoveredAt: number;
|
||||
/** When the last read failed; null after a good one. */
|
||||
readFailedAt: number | null;
|
||||
}
|
||||
|
||||
export type SharedTagsClient = Pick<IJMAPClient, 'getAccountInfo' | 'discoverKeywords'>
|
||||
& Partial<Pick<IJMAPClient, 'getPrincipalTypes' | 'supportsBulwarkMetadata' | 'getBulwarkMetadata' | 'setBulwarkMetadata'>>;
|
||||
export type SharedTagsClient = Pick<IJMAPClient, 'discoverKeywords'>
|
||||
& Partial<Pick<IJMAPClient, 'getPrincipalTypes' | 'supportsBulwarkMetadata' | 'advertisesBulwarkMetadata' | 'getBulwarkMetadata' | 'patchBulwarkMetadata'>>;
|
||||
|
||||
/** Messages scanned for tags nobody defined: one request's worth. */
|
||||
/** Messages scanned for tags nobody named: one request's worth. */
|
||||
export const DISCOVERY_SCAN_LIMIT = 500;
|
||||
/** A tab that comes back reads definitions older than this again. */
|
||||
export const STALE_AFTER_MS = 2 * 60_000;
|
||||
/** The scan for unnamed tags runs again after this, at the earliest. */
|
||||
export const DISCOVERY_INTERVAL_MS = 30 * 60_000;
|
||||
/** Push-triggered reloads of one account wait this long for quiet. */
|
||||
export const PUSH_DEBOUNCE_MS = 5_000;
|
||||
/** First retry when the server wouldn't say whether an account is a group. */
|
||||
export const GROUP_CHECK_RETRY_MS = 30_000;
|
||||
|
||||
/** A save that didn't reach the server; its changes are kept and sent again. */
|
||||
export class SharedTagsSaveDeferredError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : 'The shared tags could not be saved');
|
||||
this.name = 'SharedTagsSaveDeferredError';
|
||||
}
|
||||
}
|
||||
|
||||
interface SharedTagsState {
|
||||
entries: Record<string, SharedTagsEntry>;
|
||||
@@ -62,27 +94,46 @@ export function sharedTagsKey(loginId: string, accountId: string): string {
|
||||
const clients = new Map<string, SharedTagsClient>();
|
||||
const inflight = new Map<string, Promise<void>>();
|
||||
const accountIds = new Map<string, string>();
|
||||
const groupConfirmed = new Set<string>();
|
||||
const ownStates = new Map<string, Set<string>>();
|
||||
const pushTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const retryTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; delay: number }>();
|
||||
let unsubscribePush: (() => void) | null = null;
|
||||
let visibilityBound = false;
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
|
||||
const NEW_ENTRY: SharedTagsEntry = {
|
||||
status: 'loading', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 0, discoveredAt: 0, readFailedAt: null,
|
||||
};
|
||||
|
||||
const entryOf = (key: string): SharedTagsEntry | undefined => useSharedTagsStore.getState().entries[key];
|
||||
|
||||
function setEntry(key: string, patch: Partial<SharedTagsEntry>): void {
|
||||
useSharedTagsStore.setState((state) => {
|
||||
const previous = state.entries[key] ?? { status: 'loading' as const, tags: [], discovered: {}, loadedAt: 0 };
|
||||
return { entries: { ...state.entries, [key]: { ...previous, ...patch } } };
|
||||
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 account is a group principal (policySubjectForClient's rule). */
|
||||
async function isGroupAccount(client: SharedTagsClient, accountId: string): Promise<boolean> {
|
||||
const types = typeof client.getPrincipalTypes === 'function'
|
||||
? await client.getPrincipalTypes([accountId]).catch(() => null)
|
||||
: null;
|
||||
if (types) return types[accountId] === 'group';
|
||||
// The server won't say: every non-personal account counts.
|
||||
return client.getAccountInfo?.(accountId)?.isPersonal === false;
|
||||
/** Whether the client's metadata pause is over for the account. */
|
||||
const mayAskMetadata = (client: SharedTagsClient, accountId: string) =>
|
||||
typeof client.supportsBulwarkMetadata === 'function' && client.supportsBulwarkMetadata(accountId);
|
||||
|
||||
function scheduleGroupRetry(key: string): void {
|
||||
const previous = retryTimers.get(key);
|
||||
if (previous) clearTimeout(previous.timer);
|
||||
const delay = previous ? Math.min(previous.delay * 2, 10 * 60_000) : GROUP_CHECK_RETRY_MS;
|
||||
const timer = setTimeout(() => {
|
||||
const client = clients.get(key);
|
||||
const accountId = accountIds.get(key);
|
||||
if (client && accountId && entryOf(key)?.status === 'loading') void loadSharedTags(key, accountId, client, { force: true });
|
||||
}, delay);
|
||||
retryTimers.set(key, { timer, delay });
|
||||
}
|
||||
|
||||
async function discover(key: string, client: SharedTagsClient, accountId: string): Promise<void> {
|
||||
setEntry(key, { discoveredAt: Date.now() });
|
||||
try {
|
||||
const scan = await client.discoverKeywords({ accountId, limit: DISCOVERY_SCAN_LIMIT });
|
||||
if (clients.get(key) !== client) return;
|
||||
@@ -96,34 +147,55 @@ async function discover(key: string, client: SharedTagsClient, accountId: string
|
||||
}
|
||||
}
|
||||
|
||||
async function read(key: string, client: SharedTagsClient, accountId: string, withDiscovery: boolean): Promise<void> {
|
||||
const known = useSharedTagsStore.getState().entries[key];
|
||||
const isGroup = known && known.status !== 'loading' && known.status !== 'personal'
|
||||
? true
|
||||
: known?.status === 'personal'
|
||||
? false
|
||||
: await isGroupAccount(client, accountId);
|
||||
if (clients.get(key) !== client) return;
|
||||
if (!isGroup) {
|
||||
setEntry(key, { status: 'personal', tags: [], loadedAt: Date.now() });
|
||||
return;
|
||||
async function read(key: string, client: SharedTagsClient, accountId: string): Promise<void> {
|
||||
if (!groupConfirmed.has(key)) {
|
||||
if (typeof client.getPrincipalTypes !== 'function') {
|
||||
// No way to confirm a group: nothing shared is written for it.
|
||||
setEntry(key, { status: 'personal', loadedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
const types = await client.getPrincipalTypes([accountId]).catch(() => null);
|
||||
if (clients.get(key) !== client) return;
|
||||
if (!types) {
|
||||
// Never guess: stay on the user's own tags and ask again later.
|
||||
scheduleGroupRetry(key);
|
||||
return;
|
||||
}
|
||||
const retry = retryTimers.get(key);
|
||||
if (retry) clearTimeout(retry.timer);
|
||||
retryTimers.delete(key);
|
||||
if (types[accountId] !== 'group') {
|
||||
setEntry(key, { status: 'personal', loadedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
groupConfirmed.add(key);
|
||||
}
|
||||
// Checked first: 0.16 has no metadata and gets no request at all.
|
||||
if (typeof client.supportsBulwarkMetadata !== 'function' || !client.supportsBulwarkMetadata(accountId) || typeof client.getBulwarkMetadata !== 'function') {
|
||||
setEntry(key, { status: 'unsupported', tags: [], loadedAt: Date.now() });
|
||||
|
||||
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, so nothing here can mark the
|
||||
// login offline or take other calls down with it.
|
||||
const metadata = await client.getBulwarkMetadata(accountId);
|
||||
// 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) {
|
||||
setEntry(key, { status: 'unsupported', tags: [], loadedAt: Date.now() });
|
||||
// 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', tags: parseSharedTags(metadata[SHARED_TAGS_KEY]), loadedAt: Date.now() });
|
||||
if (withDiscovery) await discover(key, client, accountId);
|
||||
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 {
|
||||
@@ -132,43 +204,59 @@ function startWatching(): void {
|
||||
for (const [key, known] of clients) {
|
||||
if (known !== (client as unknown as SharedTagsClient)) continue;
|
||||
const accountId = accountIds.get(key);
|
||||
if (!accountId || !change.changed[accountId]?.Mailbox) continue;
|
||||
if (useSharedTagsStore.getState().entries[key]?.status !== 'ready') continue;
|
||||
void loadSharedTags(key, accountId, known, { force: true });
|
||||
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 (!visibilityBound && typeof document !== 'undefined') {
|
||||
visibilityBound = true;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
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 || entry.status !== 'ready' || now - entry.loadedAt < STALE_AFTER_MS) continue;
|
||||
void loadSharedTags(key, accountId, client, { force: true, discover: true });
|
||||
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 them again
|
||||
* (and `discover` scans the recent messages again too).
|
||||
* 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; discover?: boolean } = {},
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const running = inflight.get(key);
|
||||
if (running && clients.get(key) === client) return running;
|
||||
const sameClient = clients.get(key) === client;
|
||||
if (sameClient && !options.force && useSharedTagsStore.getState().entries[key]) return Promise.resolve();
|
||||
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];
|
||||
@@ -178,12 +266,10 @@ export function loadSharedTags(
|
||||
clients.set(key, client);
|
||||
accountIds.set(key, accountId);
|
||||
startWatching();
|
||||
if (!useSharedTagsStore.getState().entries[key]) setEntry(key, { status: 'loading' });
|
||||
const run = read(key, client, accountId, !sameClient || !!options.discover)
|
||||
if (!entryOf(key)) setEntry(key, {});
|
||||
const run = read(key, client, accountId)
|
||||
.catch(() => {
|
||||
if (clients.get(key) === client && useSharedTagsStore.getState().entries[key]?.status === 'loading') {
|
||||
setEntry(key, { status: 'unsupported', loadedAt: Date.now() });
|
||||
}
|
||||
if (clients.get(key) === client) setEntry(key, { readFailedAt: Date.now() });
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(key) === run) inflight.delete(key);
|
||||
@@ -192,30 +278,70 @@ export function loadSharedTags(
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Sends the pending changes plus `changes`; the stored tags follow. */
|
||||
async function writeChanges(
|
||||
key: string,
|
||||
accountId: string,
|
||||
client: SharedTagsClient,
|
||||
changes: SharedTagChange[],
|
||||
): Promise<void> {
|
||||
const entry = entryOf(key);
|
||||
const all = [...(entry?.pending ?? []), ...changes];
|
||||
let stored = entry?.stored ?? EMPTY_SHARED_TAGS;
|
||||
// Throws MetadataTooLargeError before anything is sent; nothing is kept then.
|
||||
planSharedTagsSave(stored, all);
|
||||
try {
|
||||
if (typeof client.patchBulwarkMetadata !== 'function' || typeof client.getBulwarkMetadata !== 'function') {
|
||||
throw new MetadataUnsupportedError();
|
||||
}
|
||||
// With nothing stored, patch first anyway: another member may have just
|
||||
// created the namespace, and writing it whole would drop their tag.
|
||||
const nothingStored = !stored.patchable && Object.keys(stored.entries).length === 0 && Object.keys(stored.extra).length === 0;
|
||||
if (nothingStored) stored = { ...stored, patchable: true };
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const plan = planSharedTagsSave(stored, all);
|
||||
try {
|
||||
const { newState } = await client.patchBulwarkMetadata(accountId, SHARED_TAGS_KEY, plan.patches);
|
||||
if (newState) {
|
||||
const own = ownStates.get(key) ?? new Set<string>();
|
||||
own.add(newState);
|
||||
ownStates.set(key, own);
|
||||
}
|
||||
setEntry(key, { status: 'ready', stored: plan.result, pending: [] });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(error instanceof MetadataPatchError) || attempt > 0) throw error;
|
||||
// Not (or no longer) the map shape: read it, convert it, write it whole.
|
||||
const metadata = await client.getBulwarkMetadata(accountId);
|
||||
if (!metadata) throw new MetadataUnsupportedError();
|
||||
stored = readSharedTags(metadata[SHARED_TAGS_KEY]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataTooLargeError) throw error;
|
||||
// Kept, shown, and sent again with the next good read.
|
||||
setEntry(key, { pending: all });
|
||||
throw new SharedTagsSaveDeferredError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the shared definitions of an account: reads the newest ones from
|
||||
* the server first, so two members editing at once don't undo each other's
|
||||
* other tags, applies `change`, and writes the result back. Throws
|
||||
* MetadataTooLargeError when the list no longer fits, MetadataUnsupportedError
|
||||
* when the account has no metadata.
|
||||
* 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,
|
||||
change: (current: KeywordDefinition[]) => KeywordDefinition[],
|
||||
): Promise<KeywordDefinition[]> {
|
||||
if (typeof client.getBulwarkMetadata !== 'function' || typeof client.setBulwarkMetadata !== 'function') {
|
||||
throw new MetadataUnsupportedError();
|
||||
}
|
||||
const metadata = await client.getBulwarkMetadata(accountId);
|
||||
if (!metadata) throw new MetadataUnsupportedError();
|
||||
const value = sharedTagsValue(change(parseSharedTags(metadata[SHARED_TAGS_KEY])));
|
||||
await client.setBulwarkMetadata(accountId, SHARED_TAGS_KEY, value);
|
||||
changes: SharedTagChange[],
|
||||
): Promise<void> {
|
||||
clients.set(key, client);
|
||||
accountIds.set(key, accountId);
|
||||
setEntry(key, { status: 'ready', tags: value.list, loadedAt: Date.now() });
|
||||
return value.list;
|
||||
startWatching();
|
||||
await writeChanges(key, accountId, client, changes);
|
||||
}
|
||||
|
||||
/** For tests: forget every account. */
|
||||
@@ -223,7 +349,15 @@ 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: {} });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user