From b7b9c7e0767cabd79dab7bf4f9d623d826506533 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:43:27 +0100 Subject: [PATCH] feat: show the shared tags of group mailboxes, read from their metadata on Stalwart 1.0 --- locales/rn/en.json | 7 ++ src/api/__tests__/bulwark-metadata.test.ts | 99 +++++++++++++++++++ .../__tests__/jmap-metadata-requests.test.ts | 7 ++ src/api/__tests__/live-stalwart-mail.test.ts | 39 ++++++++ src/api/bulwark-metadata.ts | 91 +++++++++++++++++ src/components/email/MessageContent.tsx | 1 + src/components/email/MessageHeader.tsx | 8 +- src/components/settings/KeywordSettings.tsx | 70 ++++++++++++- src/lib/__tests__/shared-tags.test.ts | 64 ++++++++++++ src/lib/shared-tags.ts | 98 ++++++++++++++++++ src/screens/EmailListScreen.tsx | 33 +++++-- src/screens/EmailThreadScreen.tsx | 5 +- .../__tests__/keywords-store-shared.test.ts | 56 +++++++++++ src/stores/keywords-store.ts | 79 +++++++++++++++ 14 files changed, 644 insertions(+), 13 deletions(-) create mode 100644 src/api/__tests__/bulwark-metadata.test.ts create mode 100644 src/api/bulwark-metadata.ts create mode 100644 src/lib/__tests__/shared-tags.test.ts create mode 100644 src/lib/shared-tags.ts create mode 100644 src/stores/__tests__/keywords-store-shared.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index a1042d59..f1ead58c 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1316,6 +1316,13 @@ } } }, + "shared_tags": { + "description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.", + "empty": "No shared tags yet.", + "loading": "Loading…", + "title": "Shared tags", + "unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags." + }, "sharing": { "directory_failed": "Couldn't load the list of users and groups.", "directory_not_browsable": "Your server doesn't let you browse its user directory.", diff --git a/src/api/__tests__/bulwark-metadata.test.ts b/src/api/__tests__/bulwark-metadata.test.ts new file mode 100644 index 00000000..384c856f --- /dev/null +++ b/src/api/__tests__/bulwark-metadata.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const METADATA = 'urn:ietf:params:jmap:metadata'; + +const { state } = vi.hoisted(() => ({ + state: { + session: null as unknown, + requests: [] as Array<{ calls: Array<[string, Record, string]>; using?: string[] }>, + answer: null as null | ((calls: Array<[string, Record, string]>) => unknown), + }, +})); + +vi.mock('../jmap-client', () => ({ + jmapClient: { + serverUrl: 'https://mail.example.org', + get currentSession() { + return state.session; + }, + request: vi.fn(async (calls: Array<[string, Record, string]>, using?: string[]) => { + state.requests.push({ calls, using }); + return state.answer!(calls); + }), + }, +})); + +import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata'; + +const VENDOR = { supportsVendorNamespaces: true, supportsPrivate: true, namespaces: [], maxDepth: 8 }; + +// Stalwart 1.0.0 lists Metadata per account; 0.16 has none. +function session(version: '1.0' | '0.16') { + const caps = version === '1.0' ? { [METADATA]: { dataTypes: { Mailbox: VENDOR, Email: VENDOR } } } : {}; + return { + capabilities: version === '1.0' ? { [METADATA]: {} } : {}, + accounts: { + own: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } }, + team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } }, + }, + }; +} + +// As Stalwart 1.0.0 answers the query and the get (checked live 2026-10-29). +function inboxWith(metadata: unknown) { + return () => ({ + methodResponses: [ + ['Mailbox/query', { accountId: 'team', ids: ['a'] }, 'q'], + ['Mailbox/get', { accountId: 'team', list: [{ id: 'a', ...(metadata === undefined ? {} : { metadata }) }] }, 'g'], + ], + }); +} + +beforeEach(() => { + state.requests = []; + state.answer = null; + resetBulwarkMetadataPauses(); +}); + +describe('getBulwarkMetadata', () => { + it('sends nothing to an account that advertises no metadata (Stalwart 0.16)', async () => { + state.session = session('0.16'); + expect(supportsBulwarkMetadata('team')).toBe(false); + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(state.requests).toHaveLength(0); + }); + + it("reads the Inbox's Bulwark namespaces in a request of its own", async () => { + state.session = session('1.0'); + state.answer = inboxWith({ + 'org.bulwarkmail.tags': { tags: { urgent: { label: 'Urgent', color: 'red' } } }, + 'com.example.other': { x: 1 }, + }); + + expect(await getBulwarkMetadata('team')).toEqual({ tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } }); + expect(state.requests).toHaveLength(1); + const [{ calls, using }] = state.requests; + expect(using).toContain(METADATA); + expect(calls.map(([name]) => name)).toEqual(['Mailbox/query', 'Mailbox/get']); + expect(calls[0][1]).toEqual({ accountId: 'team', filter: { role: 'inbox' } }); + expect(calls[1][1].properties).toEqual(['id', 'metadata']); + }); + + it('takes an answer without the property for no metadata, and pauses the account', async () => { + state.session = session('1.0'); + state.answer = inboxWith(undefined); + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(supportsBulwarkMetadata('team')).toBe(false); + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(state.requests).toHaveLength(1); + // Another account is not paused with it. + expect(supportsBulwarkMetadata('own')).toBe(true); + }); + + it('pauses an account whose request fails (the pre-release build dropped it)', async () => { + state.session = session('1.0'); + state.answer = () => { throw new Error('socket hang up'); }; + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(supportsBulwarkMetadata('team')).toBe(false); + }); +}); diff --git a/src/api/__tests__/jmap-metadata-requests.test.ts b/src/api/__tests__/jmap-metadata-requests.test.ts index b8d119c7..216f01bd 100644 --- a/src/api/__tests__/jmap-metadata-requests.test.ts +++ b/src/api/__tests__/jmap-metadata-requests.test.ts @@ -239,11 +239,18 @@ describe('JMAP Metadata in the request-building sources', () => { /(['"`])metadata(\/[^'"`\n]*)?\1/, ]; + // Stalwart 1.0.0 serves Metadata. The one module that reads it (a group's + // shared tags) asks only accounts that advertise it, in a request of its + // own; its tests cover that. + const METADATA_READER = 'src/api/bulwark-metadata.ts'; + it('never names a JMAP Metadata property, argument or capability in code', () => { const files = [...sourceFiles('src'), 'App.tsx']; expect(files).toContain('src/api/email.ts'); + expect(files).toContain(METADATA_READER); const hits: string[] = []; for (const file of files) { + if (file === METADATA_READER) continue; const code = withoutComments(readFileSync(path.join(root, file), 'utf8')); for (const pattern of FORBIDDEN) { const match = code.match(pattern); diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index 8c1b7666..97f33d58 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -48,6 +48,8 @@ import { createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript, } from '../sieve'; import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media'; +import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata'; +import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags'; import { generateVCard, parseVCard } from '../../lib/vcard'; import type { ContactCard } from '../types'; @@ -352,6 +354,43 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => { expect(first).toHaveProperty('myRights'); }, 60_000); + // Shared tags (#1310) live in a group's Inbox metadata. The test login + // is in no group, so this checks the shape on its own Inbox: written the + // way the webmail writes it, read the way the app reads it. + it('reads shared tags from Inbox metadata on 1.0 and asks nothing of 0.16', async () => { + resetBulwarkMetadataPauses(); + const accountId = client.accountId; + if (!is10()) { + expect(supportsBulwarkMetadata(accountId)).toBe(false); + expect(await getBulwarkMetadata(accountId)).toBeNull(); + return; + } + const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata']; + const inbox = (await getMailboxes()).find((m) => m.role === 'inbox')!.id; + const before = await getBulwarkMetadata(accountId); + expect(before).not.toBeNull(); + const setTags = (value: unknown, path = '') => client.request([ + ['Mailbox/set', { accountId, update: { [inbox]: { [`metadata/org.bulwarkmail.tags${path}`]: value } } }, '0'], + ], using); + try { + const written = await setTags({ tags: { [`live-${RUN}`]: { label: 'Live check', color: 'teal-dark', note: 'kept' } } }); + expect(written.methodResponses[0][1].updated).toHaveProperty(inbox); + // One tag patched on its own, as the webmail saves a change. + await setTags({ label: 'Second', color: 'blue' }, `/tags/second-${RUN}`); + + resetBulwarkMetadataPauses(); + const entries = await getBulwarkMetadata(accountId); + expect(parseSharedTags(entries?.[SHARED_TAGS_KEY])).toEqual([ + { id: `live-${RUN}`, label: 'Live check', color: 'teal' }, + { id: `second-${RUN}`, label: 'Second', color: 'blue' }, + ]); + } finally { + await setTags(before?.[SHARED_TAGS_KEY] ?? null); + } + const after = await getBulwarkMetadata(accountId); + expect(after?.[SHARED_TAGS_KEY]).toEqual(before?.[SHARED_TAGS_KEY]); + }, 60_000); + it('creates, reads, validates and deletes a Sieve script', async () => { const content = `require ["fileinto"];\r\n# ${RUN} */\r\nif header :contains "subject" "${RUN}" { fileinto "INBOX"; }\r\n`; expect((await validateSieveScript(content)).isValid).toBe(true); diff --git a/src/api/bulwark-metadata.ts b/src/api/bulwark-metadata.ts new file mode 100644 index 00000000..dedb8bf2 --- /dev/null +++ b/src/api/bulwark-metadata.ts @@ -0,0 +1,91 @@ +// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata) +// of an account's Inbox: the shared tag definitions of a group account +// (namespace `org.bulwarkmail.tags`). Port of the reading half of the +// webmail's lib/jmap/bulwark-metadata.ts and client.getBulwarkMetadata. +// +// Stalwart 1.0.0 serves Metadata; 0.16 advertises none, and a pre-release +// 1.0 build advertised it but dropped the connection on a Mailbox/get that +// selects it (42751a6b). So this is the only module that asks for it: only +// for an account whose capabilities offer vendor namespaces on mailboxes, +// in a request of its own, and any failure pauses that account for ten +// minutes. Every other Mailbox/get keeps its explicit property list. + +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; + +export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata'; +export const BULWARK_NAMESPACE = 'org.bulwarkmail'; + +const PAUSE_AFTER_FAILURE_MS = 10 * 60_000; + +/** + * Whether the account's capabilities offer vendor-namespaced metadata on + * mailboxes. Says nothing about whether the server really implements it. + */ +export function advertisesMailboxMetadata(accountCapabilities: Record | undefined): boolean { + const cap = accountCapabilities?.[METADATA_CAPABILITY] as + | { dataTypes?: Record } + | undefined; + return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true; +} + +/** Bulwark's entries of a `metadata` object, by key (`tags`, `template.`). */ +export function bulwarkEntries(metadata: unknown): Record { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {}; + const prefix = `${BULWARK_NAMESPACE}.`; + const out: Record = {}; + for (const [namespace, value] of Object.entries(metadata as Record)) { + if (namespace.startsWith(prefix)) out[namespace.slice(prefix.length)] = value; + } + return out; +} + +// Accounts whose last read failed, by server and account, with when. +const failedAt = new Map(); +const pauseKey = (accountId: string) => `${jmapClient.serverUrl ?? ''}|${accountId}`; + +/** Whether the app may ask this account for its Inbox metadata now. */ +export function supportsBulwarkMetadata(accountId: string): boolean { + const failed = failedAt.get(pauseKey(accountId)); + if (failed !== undefined && Date.now() - failed < PAUSE_AFTER_FAILURE_MS) return false; + const account = jmapClient.currentSession?.accounts?.[accountId] as + | { accountCapabilities?: Record } + | undefined; + return advertisesMailboxMetadata(account?.accountCapabilities); +} + +/** + * Bulwark's shared metadata entries of the account's Inbox, by key. Null + * when the account has none to offer (0.16) or the read failed; no request + * goes out for an account that doesn't advertise Metadata. + */ +export async function getBulwarkMetadata(accountId: string): Promise | null> { + if (!supportsBulwarkMetadata(accountId)) return null; + const property = 'metadata'; + try { + const res = await jmapClient.request([ + ['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'], + ['Mailbox/get', { + accountId, + '#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' }, + properties: ['id', property], + }, 'g'], + ], [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY]); + const get = res.methodResponses?.find((r) => r[0] === 'Mailbox/get'); + const inbox = (get?.[1] as { list?: Array> } | undefined)?.list?.[0]; + // An answer without the property is a server that doesn't keep it, + // never "every entry was removed". + if (!inbox || !(property in inbox)) throw new Error('No Inbox metadata in the answer'); + failedAt.delete(pauseKey(accountId)); + return bulwarkEntries(inbox[property]); + } catch (err) { + console.warn('[bulwark-metadata] unavailable for account', accountId, err); + failedAt.set(pauseKey(accountId), Date.now()); + return null; + } +} + +/** Forget the failure pauses (sign-out, tests). */ +export function resetBulwarkMetadataPauses(): void { + failedAt.clear(); +} diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx index e49c2f15..4febfb19 100644 --- a/src/components/email/MessageContent.tsx +++ b/src/components/email/MessageContent.tsx @@ -84,6 +84,7 @@ export function MessageContent({ onToggleStar={onToggleStar} onAddressPress={onAddressPress} compact={compact} + jmapAccountId={jmapAccountId} /> {attachmentPosition === 'beside-sender' && chips} diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index d4ca4759..4fdcbcca 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -9,7 +9,7 @@ import { useColors } from '../../theme/colors'; import SenderAvatar from '../SenderAvatar'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; -import { useKeywordsStore } from '../../stores/keywords-store'; +import { useAccountKeywordDefs } from '../../stores/keywords-store'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date'; import { deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, @@ -30,6 +30,8 @@ interface Props { onAddressPress: (address: EmailAddress) => void; /** Compact variant for collapsed thread cards. */ compact?: boolean; + /** JMAP account the message lives in (a group's shared tags name its tags). */ + jmapAccountId?: string; } function addressLabel(a: EmailAddress): string { @@ -94,13 +96,13 @@ function DetailRow({ label, value, styles, mono }: { label: string; value?: stri * "Show details" panel (recipients & routing, authentication, identifiers, * mailing list, properties) the webmail viewer shows. */ -export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact }: Props) { +export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const locale = useLocaleStore((s) => s.locale); const timeFormat = useSettingsStore((s) => s.timeFormat); - const keywordDefs = useKeywordsStore((s) => s.keywords); + const keywordDefs = useAccountKeywordDefs(jmapAccountId ?? email.jmapAccountId); const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes); const [showDetails, setShowDetails] = React.useState(false); React.useEffect(() => { setShowDetails(false); }, [email.id]); diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index ce83d72e..0456a936 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -4,7 +4,9 @@ import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons' import { SettingsSection } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store'; +import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../../stores/keywords-store'; +import { useEmailStore } from '../../stores/email-store'; +import { supportsBulwarkMetadata } from '../../api/bulwark-metadata'; import { DARK_COLORS } from '../../theme/tokens'; import { useLocaleStore } from '../../stores/locale-store'; import { discoverKeywords } from '../../api/keyword-discovery'; @@ -203,10 +205,72 @@ export function KeywordSettings() { )} + ); } +/** + * The shared tags of each group mailbox in the sidebar (#1310): what every + * member sees on its mail, read from the group's Inbox metadata. Shown, not + * edited here; Bulwark on the web edits them. + */ +function SharedTagsSection() { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const mailboxes = useEmailStore((s) => s.mailboxes); + const sharedTags = useKeywordsStore((s) => s.sharedTags); + const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags); + const accounts = React.useMemo(() => { + const seen = new Map(); + for (const m of mailboxes) { + if (m.isShared && m.accountId && !seen.has(m.accountId)) seen.set(m.accountId, m.accountName ?? m.accountId); + } + return [...seen].map(([id, name]) => ({ id, name })); + }, [mailboxes]); + useEffect(() => { + for (const account of accounts) void loadSharedTags(account.id); + }, [accounts, loadSharedTags]); + + if (accounts.length === 0) return null; + const login = currentTagsLogin(); + return ( + + {t('shared_tags.title', 'Shared tags')} + + {t('shared_tags.description', 'Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.')} + + {accounts.map((account) => { + const entry = sharedTags[account.id]; + const tags = entry && entry.login === login ? entry.tags : null; + return ( + + {account.name} + {tags === null ? ( + + {supportsBulwarkMetadata(account.id) + ? t('shared_tags.loading', 'Loading…') + : t('shared_tags.unsupported', "This mail server can't keep shared tags, so this mailbox shows your own tags.")} + + ) : tags.length === 0 ? ( + {t('shared_tags.empty', 'No shared tags yet.')} + ) : ( + tags.map((kw) => ( + + + {kw.label} + $label:{kw.id} + + )) + )} + + ); + })} + + ); +} + interface KeywordFormProps { initial?: Keyword; existingIds: string[]; @@ -335,6 +399,10 @@ function makeStyles(c: ThemePalette) { outlineBtnText: { ...typography.caption, color: c.mutedForeground }, scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs }, scanResults: { gap: spacing.sm }, + sharedBlock: { gap: spacing.sm, marginTop: spacing.lg }, + sharedTitle: { ...typography.bodySemibold, color: c.text }, + sharedAccount: { gap: spacing.sm }, + sharedAccountName: { ...typography.caption, color: c.textSecondary, fontWeight: '600' }, form: { gap: spacing.md, padding: spacing.md, diff --git a/src/lib/__tests__/shared-tags.test.ts b/src/lib/__tests__/shared-tags.test.ts new file mode 100644 index 00000000..e45b5598 --- /dev/null +++ b/src/lib/__tests__/shared-tags.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { keywordColorFromPalette, mergeSharedTags, parseSharedTags } from '../shared-tags'; + +describe('parseSharedTags', () => { + // The map shape the webmail writes (lib/shared-tags.ts). + it('reads the map by tag id, sorted by name, keeping unknown fields out', () => { + expect(parseSharedTags({ + tags: { + urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' }, + billing: { label: ' Billing ', color: 'teal' }, + unnamed: { color: 'blue' }, + }, + version: 2, + })).toEqual([ + { id: 'billing', label: 'Billing', color: 'teal' }, + { id: 'unnamed', label: 'unnamed', color: 'blue' }, + { id: 'urgent', label: 'Urgent', color: 'red' }, + ]); + }); + + it('reads the earlier list and bare-array shapes, once per id', () => { + const list = [{ id: 'a', label: 'A', color: 'green' }, { id: 'A', label: 'dup' }, { label: 'no id' }]; + expect(parseSharedTags({ list })).toEqual([{ id: 'a', label: 'A', color: 'green' }]); + expect(parseSharedTags(list)).toEqual([{ id: 'a', label: 'A', color: 'green' }]); + }); + + it('drops ids no keyword can hold and caps long names', () => { + const tags = parseSharedTags({ + tags: { 'has space': { label: 'x' }, 'a*b': { label: 'y' }, ok: { label: 'z'.repeat(50) } }, + }); + expect(tags.map((t) => t.id)).toEqual(['ok']); + expect(tags[0].label).toHaveLength(30); + }); + + it('reads nothing from junk', () => { + expect(parseSharedTags(undefined)).toEqual([]); + expect(parseSharedTags('tags')).toEqual([]); + expect(parseSharedTags({ tags: [1, 2] })).toEqual([]); + }); +}); + +describe('keywordColorFromPalette', () => { + it("maps the webmail's shades onto the app's colours and unknown ones to grey", () => { + expect(keywordColorFromPalette('blue-light')).toBe('blue'); + expect(keywordColorFromPalette('amber')).toBe('amber'); + expect(keywordColorFromPalette('magenta')).toBe('gray'); + expect(keywordColorFromPalette(7)).toBe('gray'); + }); +}); + +describe('mergeSharedTags', () => { + it('lets the shared definition name a tag and keeps the own ones nobody shared', () => { + const shared = [{ id: 'Red', label: 'Escalated', color: 'red' as const }]; + const personal = [ + { id: 'red', label: 'Red', color: 'red' as const }, + { id: 'blue', label: 'Blue', color: 'blue' as const }, + ]; + expect(mergeSharedTags(shared, personal)).toEqual([ + { id: 'Red', label: 'Escalated', color: 'red' }, + { id: 'blue', label: 'Blue', color: 'blue' }, + ]); + expect(mergeSharedTags([], personal)).toBe(personal); + }); +}); diff --git a/src/lib/shared-tags.ts b/src/lib/shared-tags.ts new file mode 100644 index 00000000..6fcd4d6f --- /dev/null +++ b/src/lib/shared-tags.ts @@ -0,0 +1,98 @@ +// Shared tags of a group account: the tag names and colours every member of +// a shared mailbox sees, kept in the metadata of the group's Inbox +// (namespace `org.bulwarkmail.tags`, src/api/bulwark-metadata.ts). Port of +// the reading half of the webmail's lib/shared-tags.ts (#1310, #470). +// +// Keywords in a group account are the same for every member, so +// `$label:urgent` on a team message is the team's tag; the shared +// definitions name it for everyone. Stored as a map by tag id, +// `{ "tags": { "": { "label", "color", ... } } }`; the earlier +// `{ "list": [...] }` and bare-array shapes are read too. The app only +// reads them: editing them is the webmail's (Settings > Tags). + +import { colors } from '../theme/tokens'; +import type { KeywordColor, KeywordDef } from '../stores/keywords-store'; + +// keywords-store's FALLBACK_KEYWORD_COLOR; not imported, that store imports this module. +const FALLBACK_KEYWORD_COLOR: KeywordColor = 'gray'; + +/** The metadata key (namespace `org.bulwarkmail.tags`). */ +export const SHARED_TAGS_KEY = 'tags'; + +/** Longest name a shared tag keeps, as in the webmail's tag editor. */ +export const MAX_SHARED_TAG_LABEL = 30; + +// RFC 8621 keywords are at most 128 bytes, `$label:` included. +const MAX_KEYWORD_ID_LENGTH = 128 - '$label:'.length; + +/** + * Characters RFC 8621 keywords can't hold (and whitespace), so a tag id + * from the server that could never be on a message is dropped. + */ +const BAD_ID_CHARS = /[\s(){\]%*"\\]/; + +function isKeywordSafe(id: string): boolean { + if (!id || id.length > MAX_KEYWORD_ID_LENGTH || BAD_ID_CHARS.test(id)) return false; + for (let i = 0; i < id.length; i++) { + const code = id.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** + * The app's colour for a webmail palette key. The webmail has a light and a + * dark shade of each colour (`blue-light`, `blue-dark`); the app has one. + * A colour neither knows shows grey. + */ +export function keywordColorFromPalette(color: unknown): KeywordColor { + if (typeof color !== 'string') return FALLBACK_KEYWORD_COLOR; + const base = color.replace(/-(?:light|dark)$/, ''); + return Object.prototype.hasOwnProperty.call(colors.tags, base) ? base as KeywordColor : FALLBACK_KEYWORD_COLOR; +} + +/** The raw entry of each tag by id, from any of the stored shapes. */ +function entriesOf(value: unknown): Record> { + const entries: Record> = {}; + if (isObject(value) && isObject(value.tags)) { + for (const [id, entry] of Object.entries(value.tags)) { + if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry; + } + return entries; + } + const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : []; + const seen = new Set(); + for (const item of items) { + if (!isObject(item) || typeof item.id !== 'string') continue; + const id = item.id.trim(); + if (!isKeywordSafe(id) || seen.has(id.toLowerCase())) continue; + seen.add(id.toLowerCase()); + entries[id] = item; + } + return entries; +} + +/** The shared definitions in the namespace's value, by name. */ +export function parseSharedTags(value: unknown): KeywordDef[] { + return Object.entries(entriesOf(value)) + .map(([id, entry]) => { + const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : ''; + return { id, label: label || id, color: keywordColorFromPalette(entry.color) }; + }) + .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); +} + +/** + * What a message of a group account is tagged with: the shared definitions + * first, then the member's own for ids nobody shared, so a tag on a team + * message never loses its name just because the team didn't define it. + * Ids compare without case, as keywords do. + */ +export function mergeSharedTags(shared: readonly KeywordDef[], personal: readonly KeywordDef[]): KeywordDef[] { + if (shared.length === 0) return personal as KeywordDef[]; + const named = new Set(shared.map((d) => d.id.toLowerCase())); + return [...shared, ...personal.filter((d) => !named.has(d.id.toLowerCase()))]; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 454642d3..a9dc294f 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -28,11 +28,12 @@ import type { LoadListAttachments } from '../lib/list-attachments'; import { useNetworkStore } from '../stores/network-store'; import { useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts, - searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK, + searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK, spannedAccounts, type EmailFilters, } from '../stores/email-store'; import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store'; -import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; +import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store'; +import { mergeSharedTags } from '../lib/shared-tags'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { listBackStep } from '../lib/list-back'; import { reportFailure } from '../lib/report-failure'; @@ -476,6 +477,24 @@ export default function EmailListScreen({ const currentRole = currentMailbox?.role ?? null; // The JMAP account behind the open folder (undefined = the user's own). const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; + + // A group account's messages carry its shared tags (#1310): read them for + // every shared account in the sidebar, and name a row's tags by the + // account it lives in. + const sharedTags = useKeywordsStore((s) => s.sharedTags); + const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags); + React.useEffect(() => { + for (const accountId of spannedAccounts(mailboxes)) if (accountId) void loadSharedTags(accountId); + }, [mailboxes, loadSharedTags]); + const keywordDefsByAccount = React.useMemo(() => { + const login = currentTagsLogin(); + const out = new Map(); + for (const [accountId, entry] of Object.entries(sharedTags)) { + if (entry.login === login) out.set(accountId, mergeSharedTags(entry.tags, keywordDefs)); + } + return out; + }, [sharedTags, keywordDefs]); + const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs; const inJunk = currentRole === 'junk' || currentRole === 'spam'; const showRecipient = currentRole === 'sent' || currentRole === 'drafts'; @@ -788,7 +807,7 @@ export default function EmailListScreen({ showPreview={showPreview} showRecipient={showRecipient} tagIds={rowTagIds.get(key) ?? ''} - keywordDefs={keywordDefs} + keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs} disableAvatarImages={inJunk && !showAvatarsInJunk} answered={flags?.answered ?? false} forwarded={flags?.forwarded ?? false} @@ -805,7 +824,7 @@ export default function EmailListScreen({ [ selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor, - showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk, + showPreview, showRecipient, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk, loadAttachments, openAttachment, ], ); @@ -1020,8 +1039,8 @@ export default function EmailListScreen({ const keywordFilterLabel = React.useMemo(() => { if (!filters.keyword) return null; const id = filters.keyword.replace(/^\$label:/, '').replace(/^\$color:/, ''); - return keywordDefs.find((k) => k.id === id)?.label ?? id; - }, [filters.keyword, keywordDefs]); + return folderKeywordDefs.find((k) => k.id === id)?.label ?? id; + }, [filters.keyword, folderKeywordDefs]); const setFolderScope = (scope: string) => { const updated: EmailFilters = { ...filters }; // The scope a search gets by default stays unset, so it keeps following @@ -1909,7 +1928,7 @@ export default function EmailListScreen({ setTagSheetOpen(false)} - keywords={keywordDefs} + keywords={folderKeywordDefs} selectedEmails={selectedEmails} onToggle={handleBatchTagToggle} /> diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index e9f9d752..e13d7059 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -33,7 +33,7 @@ import { type FlagsHint, } from '../lib/email-detail-cache'; import { shareEmailEml } from '../lib/email-export'; -import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; +import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store'; import { useSheetDrag } from '../lib/use-sheet-drag'; import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; import { useLayout } from '../lib/use-layout'; @@ -336,7 +336,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr [bottomActions], ); - const keywordDefs = useKeywordsStore((s) => s.keywords); + // A group account's messages are tagged with its shared tags (#1310). + const keywordDefs = useAccountKeywordDefs(ownerAccountId); const hydrateKeywords = useKeywordsStore((s) => s.hydrate); const keywordsHydrated = useKeywordsStore((s) => s.hydrated); React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]); diff --git a/src/stores/__tests__/keywords-store-shared.test.ts b/src/stores/__tests__/keywords-store-shared.test.ts new file mode 100644 index 00000000..74e521f4 --- /dev/null +++ b/src/stores/__tests__/keywords-store-shared.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { client, metadata } = vi.hoisted(() => ({ + client: { username: 'me@example.org' as string | null, serverUrl: 'https://mail.example.org', connectedAccountId: 'own' }, + metadata: vi.fn(), +})); + +vi.mock('../../api/jmap-client', () => ({ jmapClient: client })); +vi.mock('../../api/bulwark-metadata', () => ({ getBulwarkMetadata: metadata })); + +import { useKeywordsStore, currentTagsLogin } from '../keywords-store'; + +const TEAM_TAGS = { tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } }; + +beforeEach(() => { + metadata.mockReset(); + client.username = 'me@example.org'; + useKeywordsStore.setState({ sharedTags: {} }); +}); + +describe('loadSharedTags', () => { + it("reads a group account's shared tags and stamps them with the login", async () => { + metadata.mockResolvedValue(TEAM_TAGS); + await useKeywordsStore.getState().loadSharedTags('team'); + expect(metadata).toHaveBeenCalledWith('team'); + const entry = useKeywordsStore.getState().sharedTags.team; + expect(entry.tags).toEqual([{ id: 'urgent', label: 'Urgent', color: 'red' }]); + expect(entry.login).toBe(currentTagsLogin()); + }); + + it("never asks for the user's own account, and doesn't ask again while fresh", async () => { + metadata.mockResolvedValue(TEAM_TAGS); + await useKeywordsStore.getState().loadSharedTags('own'); + expect(metadata).not.toHaveBeenCalled(); + await useKeywordsStore.getState().loadSharedTags('team'); + await useKeywordsStore.getState().loadSharedTags('team'); + expect(metadata).toHaveBeenCalledTimes(1); + }); + + it('keeps the last good tags when a read fails', async () => { + metadata.mockResolvedValueOnce(TEAM_TAGS).mockResolvedValueOnce(null); + await useKeywordsStore.getState().loadSharedTags('team'); + await useKeywordsStore.getState().loadSharedTags('team', { force: true }); + expect(useKeywordsStore.getState().sharedTags.team.tags).toHaveLength(1); + }); + + it('drops an answer that arrives after the user switched to another login', async () => { + let resolve!: (v: unknown) => void; + metadata.mockReturnValue(new Promise((r) => { resolve = r; })); + const loading = useKeywordsStore.getState().loadSharedTags('team'); + client.username = 'other@example.org'; + resolve(TEAM_TAGS); + await loading; + expect(useKeywordsStore.getState().sharedTags.team).toBeUndefined(); + }); +}); diff --git a/src/stores/keywords-store.ts b/src/stores/keywords-store.ts index 984624fd..7626d8a1 100644 --- a/src/stores/keywords-store.ts +++ b/src/stores/keywords-store.ts @@ -1,6 +1,10 @@ +import React from 'react'; import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { colors } from '../theme/tokens'; +import { jmapClient } from '../api/jmap-client'; +import { getBulwarkMetadata } from '../api/bulwark-metadata'; +import { SHARED_TAGS_KEY, mergeSharedTags, parseSharedTags } from '../lib/shared-tags'; const STORAGE_KEY = 'webmail:keywords:v1'; @@ -37,9 +41,28 @@ export function keywordToken(id: string): string { return `$label:${id}`; } +/** A group account's shared tags (lib/shared-tags.ts), as last read. */ +export interface SharedTagsEntry { + /** Server and user that read them: account ids repeat across servers. */ + login: string; + tags: KeywordDef[]; + loadedAt: number; +} + +/** Shared tags older than this are read again when a view asks for them. */ +export const SHARED_TAGS_STALE_MS = 2 * 60_000; + interface KeywordsState { keywords: KeywordDef[]; hydrated: boolean; + /** Shared tag definitions of the group accounts, by JMAP account id. */ + sharedTags: Record; + /** + * Read a group account's shared tags from its Inbox metadata, unless they + * were read for this login in the last two minutes. Nothing goes out for + * the user's own account or one that keeps no metadata (Stalwart 0.16). + */ + loadSharedTags: (accountId: string, options?: { force?: boolean }) => Promise; hydrate: () => Promise; add: (kw: KeywordDef) => void; update: (id: string, patch: Partial>) => void; @@ -47,6 +70,15 @@ interface KeywordsState { resetDefaults: () => void; } +/** The login the JMAP client is connected as, '' before a session. */ +export function currentTagsLogin(): string { + const user = jmapClient.username; + const server = jmapClient.serverUrl; + return user && server ? `${server}|${user}` : ''; +} + +const sharedInflight = new Map>(); + function persist(keywords: KeywordDef[]): void { void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => { console.warn('[keywords-store] persist failed', err); @@ -56,6 +88,34 @@ function persist(keywords: KeywordDef[]): void { export const useKeywordsStore = create((set, get) => ({ keywords: DEFAULT_KEYWORDS, hydrated: false, + sharedTags: {}, + + loadSharedTags: (accountId, options) => { + const login = currentTagsLogin(); + if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve(); + const current = get().sharedTags[accountId]; + if (!options?.force && current?.login === login && Date.now() - current.loadedAt < SHARED_TAGS_STALE_MS) { + return Promise.resolve(); + } + const key = `${login}|${accountId}`; + const running = sharedInflight.get(key); + if (running) return running; + const run = (async () => { + const entries = await getBulwarkMetadata(accountId); + // The user switched accounts meanwhile: these tags are another login's. + if (currentTagsLogin() !== login) return; + // A failed read (or none to make) keeps the last good tags. + if (!entries) return; + set((s) => ({ + sharedTags: { + ...s.sharedTags, + [accountId]: { login, tags: parseSharedTags(entries[SHARED_TAGS_KEY]), loadedAt: Date.now() }, + }, + })); + })().finally(() => sharedInflight.delete(key)); + sharedInflight.set(key, run); + return run; + }, hydrate: async () => { if (get().hydrated) return; @@ -97,3 +157,22 @@ export const useKeywordsStore = create((set, get) => ({ persist(DEFAULT_KEYWORDS); }, })); + +/** + * The tag definitions for messages of a JMAP account: a group account's + * shared tags first (read from its Inbox metadata, Stalwart 1.0), then the + * user's own; just the user's own for their own account or before the + * shared ones are known. `accountId` undefined means the user's own. + */ +export function useAccountKeywordDefs(accountId: string | undefined): KeywordDef[] { + const personal = useKeywordsStore((s) => s.keywords); + const entry = useKeywordsStore((s) => (accountId ? s.sharedTags[accountId] : undefined)); + const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags); + React.useEffect(() => { + if (accountId) void loadSharedTags(accountId); + }, [accountId, loadSharedTags]); + return React.useMemo( + () => (entry && entry.login === currentTagsLogin() ? mergeSharedTags(entry.tags, personal) : personal), + [entry, personal], + ); +}