diff --git a/lib/jmap/__tests__/share-rights.test.ts b/lib/jmap/__tests__/share-rights.test.ts new file mode 100644 index 000000000..a782143e9 --- /dev/null +++ b/lib/jmap/__tests__/share-rights.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { detectPreset, MAILBOX_PRESETS } from '../share-rights'; +import type { MailboxRights } from '../types'; + +// What Stalwart 1.0 and v1.0.0 return for a folder shared with the "read" +// preset (checked on the test servers, 2026-10-28): the keyword right comes +// back true with the seen right, and v1.0.0 adds maySetMetadata. +const READ_AS_STORED_BY_1_0 = { + mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySetSeen: true, + maySetKeywords: true, mayCreateChild: false, mayRename: false, maySubmit: false, + mayDelete: false, mayShare: false, maySetMetadata: false, +} as MailboxRights; + +describe('detectPreset', () => { + it('reads a folder shared read-only on Stalwart 1.0 back as read only', () => { + expect(detectPreset('mailbox', READ_AS_STORED_BY_1_0)).toBe('read'); + }); + + it('still tells read and write apart', () => { + expect(detectPreset('mailbox', { ...READ_AS_STORED_BY_1_0, mayAddItems: true, mayRemoveItems: true })).toBe('readWrite'); + expect(detectPreset('mailbox', MAILBOX_PRESETS.read)).toBe('read'); + }); + + it('does not excuse a keyword right without the seen right', () => { + expect(detectPreset('mailbox', { ...MAILBOX_PRESETS.read, maySetSeen: false, maySetKeywords: true })).toBe('custom'); + }); + + it('ignores rights a newer server adds', () => { + expect(detectPreset('addressBook', { mayRead: true, mayWrite: true, mayDelete: false, mayShare: false, maySetMetadata: false } as never)).toBe('readWrite'); + }); +}); diff --git a/lib/jmap/share-rights.ts b/lib/jmap/share-rights.ts index d7d52c581..623ca7710 100644 --- a/lib/jmap/share-rights.ts +++ b/lib/jmap/share-rights.ts @@ -1,7 +1,17 @@ /** * The rights Bulwark grants when it shares a folder, calendar, address book - * or file (`shareWith/`), as named presets, and the preset a - * stored grant amounts to. + * or file (`shareWith/`), as named presets. The share dialog of + * a single item and the Delegates page both build their patches from here. + * + * What Stalwart stores differs from what it was sent in places: + * - 1.0 keeps `maySetSeen` and `maySetKeywords` of a mailbox as one right, so + * a "read" grant (seen yes, keywords no) reads back with both true. + * - v1.0.0 adds `maySetMetadata` to every type. Bulwark never sends it (0.16 + * would refuse the property), and the server stores false. + * - Stalwart before 0.16.6 names file rights mayRead / mayWrite / mayShare; + * JMAPClient.setFileNodeShare converts on the way out. + * So presets are compared on the rights they define, and the mailbox + * keyword right is allowed to follow the seen right. */ import type { AddressBookRights, CalendarRights, FileNodeRights, MailboxRights } from './types'; @@ -79,11 +89,13 @@ function presetsFor(kind: ShareKind): Partial> { return ADDRESS_BOOK_PRESETS; } -function matches(preset: AnyRights, rights: AnyRights): boolean { +function matches(kind: ShareKind, preset: AnyRights, rights: AnyRights): boolean { const have = rights as unknown as Record; return Object.entries(preset).every(([key, want]) => { const got = have[key] ?? false; - return got === want; + if (got === want) return true; + // Stalwart 1.0 stores the two as one right (see the top of this file). + return kind === 'mailbox' && key === 'maySetKeywords' && got === true && have.maySetSeen === true; }); } @@ -96,7 +108,7 @@ export function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | u /** The preset a stored grant amounts to, or "custom". */ export function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset { for (const [name, preset] of Object.entries(presetsFor(kind)) as [RolePreset, AnyRights][]) { - if (matches(preset, rights)) return name; + if (matches(kind, preset, rights)) return name; } return 'custom'; }