diff --git a/lib/__tests__/jmap-bulwark-metadata.test.ts b/lib/__tests__/jmap-bulwark-metadata.test.ts new file mode 100644 index 000000000..2e1db844a --- /dev/null +++ b/lib/__tests__/jmap-bulwark-metadata.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../jmap/client'; +import { MetadataTooLargeError, MetadataUnsupportedError, bulwarkEntries, isValidMetadataKey } from '../jmap/bulwark-metadata'; + +// Bulwark keeps shared tags and templates in the Inbox metadata of an +// account, one namespace per item (each namespace holds at most 8 KB on +// Stalwart v1.0.0). Servers without metadata, and the pre-release 1.0 build +// that drops the connection on these calls, count as "not supported". + +const METADATA_CAP = { + 'urn:ietf:params:jmap:metadata': { + dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } }, + }, +}; + +function session() { + return { + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {} }, + accounts: { + me: { name: 'me@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } }, + team: { name: 'team@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } }, + old: { name: 'old@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'me' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: '', + }; +} + +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +let fetchSpy: ReturnType; +let client: JMAPClient; +let requests: Array<{ using: string[]; methodCalls: Array<[string, Record, string]> }>; +let answer: (body: (typeof requests)[number]) => Response | Promise; + +beforeEach(async () => { + vi.useRealTimers(); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(json(session())); + client = new JMAPClient('https://mail.example.com', 'me@example.org', 'pw'); + await client.connect(); + fetchSpy.mockReset(); + requests = []; + fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')); + requests.push(body); + return answer(body); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => vi.restoreAllMocks()); + +const inboxAnswer = (metadata: unknown, property = 'metadata') => json({ + methodResponses: [ + ['Mailbox/query', { ids: ['inbox-1'] }, 'q'], + ['Mailbox/get', { list: [{ id: 'inbox-1', [property]: metadata }] }, 'g'], + ], +}); + +describe('getBulwarkMetadata', () => { + it('reads Bulwark\'s namespaces of the Inbox in a request of its own', async () => { + answer = () => inboxAnswer({ 'org.bulwarkmail.tags': [{ id: 'urgent' }], 'org.bulwarkmail.template.a': { name: 'A' }, 'other.example': 1 }); + expect(await client.getBulwarkMetadata('team')).toEqual({ tags: [{ id: 'urgent' }], 'template.a': { name: 'A' } }); + expect(requests).toHaveLength(1); + expect(requests[0].using).toContain('urn:ietf:params:jmap:metadata'); + expect(requests[0].methodCalls.map((c) => c[0])).toEqual(['Mailbox/query', 'Mailbox/get']); + expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'metadata']); + }); + + it('reads private metadata when asked', async () => { + answer = () => inboxAnswer({ 'org.bulwarkmail.templates': { x: 1 } }, 'privateMetadata'); + expect(await client.getBulwarkMetadata('me', { private: true })).toEqual({ templates: { x: 1 } }); + expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'privateMetadata']); + }); + + it('asks nothing of an account without the capability', async () => { + answer = () => { throw new Error('should not be asked'); }; + expect(client.supportsBulwarkMetadata('old')).toBe(false); + expect(await client.getBulwarkMetadata('old')).toBeNull(); + expect(requests).toHaveLength(0); + }); + + it('treats a dropped connection as unsupported and leaves the account alone afterwards', async () => { + answer = () => { throw new TypeError('Failed to fetch'); }; + expect(await client.getBulwarkMetadata('team')).toBeNull(); + const asked = requests.length; + expect(client.supportsBulwarkMetadata('team')).toBe(false); + expect(await client.getBulwarkMetadata('team')).toBeNull(); + expect(requests).toHaveLength(asked); + // Other accounts are not affected. + expect(client.supportsBulwarkMetadata('me')).toBe(true); + }); +}); + +describe('setBulwarkMetadata', () => { + it('patches one namespace of the Inbox and nothing else', async () => { + answer = (body) => body.methodCalls[0][0] === 'Mailbox/set' + ? json({ methodResponses: [['Mailbox/set', { updated: { 'inbox-1': null } }, '0']] }) + : inboxAnswer({}); + await client.setBulwarkMetadata('team', 'tags', [{ id: 'urgent', color: '#f00' }]); + const set = requests.find((r) => r.methodCalls[0][0] === 'Mailbox/set')!; + expect(set.methodCalls[0][1]).toEqual({ + accountId: 'team', + update: { 'inbox-1': { 'metadata/org.bulwarkmail.tags': [{ id: 'urgent', color: '#f00' }] } }, + }); + }); + + it('refuses an entry over 8 KB before asking, and passes on the server\'s size refusal', async () => { + answer = (body) => body.methodCalls[0][0] === 'Mailbox/set' + ? json({ methodResponses: [['Mailbox/set', { notUpdated: { 'inbox-1': { type: 'invalidProperties', description: 'Metadata size 70413 exceeds the maximum of 65536 bytes.' } } }, '0']] }) + : inboxAnswer({}); + await expect(client.setBulwarkMetadata('team', 'template.big', { body: 'x'.repeat(9000) })).rejects.toBeInstanceOf(MetadataTooLargeError); + expect(requests.some((r) => r.methodCalls[0][0] === 'Mailbox/set')).toBe(false); + await expect(client.setBulwarkMetadata('team', 'template.ok', { body: 'small' })).rejects.toBeInstanceOf(MetadataTooLargeError); + }); + + it('says unsupported for an account without metadata', async () => { + await expect(client.setBulwarkMetadata('old', 'tags', [])).rejects.toBeInstanceOf(MetadataUnsupportedError); + }); +}); + +describe('helpers', () => { + it('accepts lowercase dotted keys only', () => { + expect(isValidMetadataKey('template.abc-1')).toBe(true); + expect(isValidMetadataKey('Tags')).toBe(false); + expect(isValidMetadataKey('a/b')).toBe(false); + expect(isValidMetadataKey('.x')).toBe(false); + }); + + it('ignores other vendors and non-objects', () => { + expect(bulwarkEntries(null)).toEqual({}); + expect(bulwarkEntries({ 'org.bulwarkmail': 1, 'org.bulwarkmailx.a': 2, 'org.bulwarkmail.a': 3 })).toEqual({ a: 3 }); + }); +}); diff --git a/lib/jmap/bulwark-metadata.ts b/lib/jmap/bulwark-metadata.ts new file mode 100644 index 000000000..57f39f7ee --- /dev/null +++ b/lib/jmap/bulwark-metadata.ts @@ -0,0 +1,76 @@ +/** + * Bulwark's own data in JMAP metadata (urn:ietf:params:jmap:metadata) of an + * account's Inbox: shared tag definitions and team templates in a group + * account, personal templates in the user's own account. + * + * What Stalwart v1.0.0 does (local-data/org-features-facts.md): + * - vendor namespaces need a dot ("org.bulwarkmail" works, "bulwark" is + * refused); each namespace holds at most 8 KB, one mailbox at most 64 KB + * of shared and 16 KB of private metadata; + * - `metadata/` patches one namespace and leaves the others; + * - every member of a group account sees its shared metadata; private + * metadata is per user. + * + * So every item gets a namespace of its own, `org.bulwarkmail.`. 0.16 + * has no metadata, and a pre-release 1.0 build advertises it but drops the + * connection on Mailbox/get with `metadata`; the calls go in a request of + * their own and any failure means "not supported" for that account. + */ + +export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata'; +export const BULWARK_NAMESPACE = 'org.bulwarkmail'; +/** Largest value Stalwart's default config takes in one namespace. */ +export const MAX_METADATA_ENTRY_BYTES = 8192; + +const KEY_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/; + +export class MetadataTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = 'MetadataTooLargeError'; + } +} + +export class MetadataUnsupportedError extends Error { + constructor(message = 'This server keeps no metadata for Bulwark') { + super(message); + this.name = 'MetadataUnsupportedError'; + } +} + +/** `tags`, `template.`: lowercase, digits, dashes, dot-separated. */ +export function isValidMetadataKey(key: string): boolean { + return key.length <= 120 && KEY_RE.test(key); +} + +export function namespaceFor(key: string): string { + if (!isValidMetadataKey(key)) throw new Error(`Invalid metadata key: ${key}`); + return `${BULWARK_NAMESPACE}.${key}`; +} + +/** + * 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` or `privateMetadata` object, by key. */ +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; +} + +/** The size Stalwart counts for a value: its JSON in UTF-8. */ +export function metadataValueBytes(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).length; +} diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index b5fbc4e52..32c3fff43 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -610,6 +610,10 @@ export interface IJMAPClient { listPrincipals?(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise; /** Principal type by id ("individual", "group"); null when unknown. Optional (demo client). */ getPrincipalTypes?(ids: string[]): Promise | null>; + // Bulwark data in Inbox metadata (lib/jmap/bulwark-metadata.ts). Optional (demo client). + supportsBulwarkMetadata?(accountId?: string): boolean; + getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise | null>; + setBulwarkMetadata?(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise; setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise; setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0fd9efa67..3032b4477 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,3 +1,4 @@ +import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata"; import { IncompleteDeleteError } from "./delete-error"; import { ArchiveEmailsError } from "./archive-error"; import { generateUUID } from '@/lib/utils'; @@ -7076,6 +7077,84 @@ export class JMAPClient implements IJMAPClient { } } + /** + * Accounts whose metadata request failed, with when: not asked again for + * ten minutes. A dropped connection (the pre-release 1.0 build) looks the + * same as a network blip, so the pause is not for the whole session. + */ + private metadataUnsupported = new Map(); + + /** Whether Bulwark may keep data in this account's Inbox metadata (lib/jmap/bulwark-metadata.ts). */ + supportsBulwarkMetadata(accountId?: string): boolean { + const id = accountId || this.accountId; + const failedAt = this.metadataUnsupported.get(id); + if (failedAt !== undefined && Date.now() - failedAt < 10 * 60_000) return false; + return advertisesMailboxMetadata(this.accounts[id]?.accountCapabilities as Record | undefined); + } + + /** + * Bulwark's metadata entries of the account's Inbox, by key, shared or the + * user's private ones. Null when the server has no usable metadata. + */ + async getBulwarkMetadata(accountId?: string, options?: { private?: boolean }): Promise | null> { + const id = accountId || this.accountId; + if (!this.supportsBulwarkMetadata(id)) return null; + const property = options?.private ? "privateMetadata" : "metadata"; + try { + // A request of its own: a server that can't answer it must not take + // other calls down with it. + const response = await this.request([ + ["Mailbox/query", { accountId: id, filter: { role: "inbox" } }, "q"], + ["Mailbox/get", { accountId: id, "#ids": { resultOf: "q", name: "Mailbox/query", path: "/ids" }, properties: ["id", property] }, "g"], + ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]); + const get = response.methodResponses?.find((r) => r[0] === "Mailbox/get"); + const inbox = (get?.[1] as { list?: Array> } | undefined)?.list?.[0]; + if (!inbox) throw new MetadataUnsupportedError("No Inbox metadata in the answer"); + this.metadataInboxIds.set(id, inbox.id as string); + this.metadataUnsupported.delete(id); + return bulwarkEntries(inbox[property]); + } catch (error) { + debug.warn('jmap', `Metadata unavailable for account ${id}:`, error); + this.metadataUnsupported.set(id, Date.now()); + return null; + } + } + + private metadataInboxIds = new Map(); + + /** + * Writes one Bulwark metadata entry of the account's Inbox (null removes + * it), leaving every other entry as it is. Throws MetadataTooLargeError + * when the server refuses the size, MetadataUnsupportedError when the + * account has no metadata. + */ + async setBulwarkMetadata(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise { + const id = accountId || this.accountId; + if (!this.supportsBulwarkMetadata(id)) throw new MetadataUnsupportedError(); + const property = options?.private ? "privateMetadata" : "metadata"; + if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) { + throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`); + } + let inboxId = this.metadataInboxIds.get(id); + if (!inboxId) { + await this.getBulwarkMetadata(id, options); + inboxId = this.metadataInboxIds.get(id); + if (!inboxId) throw new MetadataUnsupportedError(); + } + const response = await this.request([ + ["Mailbox/set", { accountId: id, update: { [inboxId]: { [`${property}/${namespaceFor(key)}`]: value } } }, "0"], + ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== "Mailbox/set") { + throw jmapError((result as JmapErrorInfo | undefined)?.description || "Failed to save metadata", result as JmapErrorInfo | undefined); + } + const notUpdated = (result as { notUpdated?: Record }).notUpdated?.[inboxId]; + if (notUpdated) { + if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!); + throw new Error(notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`); + } + } + /** * The busy periods of a principal between `utcStart` and `utcEnd` * (Principal/getAvailability). Event details are never requested: this