From 417f1843a2cf3336343f7df74a02030c7f3e7b23 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:29:35 +0100 Subject: [PATCH] feat: share a mail folder with other people from its menu --- .../live-stalwart-mailbox-share.test.ts | 107 ++++++++++++++++++ src/api/__tests__/mailbox-sharing.test.ts | 86 ++++++++++++++ src/api/mailbox-sharing.ts | 62 ++++++++++ src/components/SidebarDrawer.tsx | 17 ++- .../__tests__/mailbox-share-wiring.test.ts | 30 +++++ src/components/sharing/MailboxShareSheet.tsx | 98 ++++++++++++++++ .../sharing/ShareCollectionSheet.tsx | 9 +- src/lib/mailbox-share.ts | 31 +++++ 8 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 src/api/__tests__/live-stalwart-mailbox-share.test.ts create mode 100644 src/api/__tests__/mailbox-sharing.test.ts create mode 100644 src/api/mailbox-sharing.ts create mode 100644 src/components/__tests__/mailbox-share-wiring.test.ts create mode 100644 src/components/sharing/MailboxShareSheet.tsx create mode 100644 src/lib/mailbox-share.ts diff --git a/src/api/__tests__/live-stalwart-mailbox-share.test.ts b/src/api/__tests__/live-stalwart-mailbox-share.test.ts new file mode 100644 index 00000000..0d1c9c29 --- /dev/null +++ b/src/api/__tests__/live-stalwart-mailbox-share.test.ts @@ -0,0 +1,107 @@ +/** + * Live checks of folder sharing (the folder menu's "Share…") against a real + * Stalwart: the first login makes a folder, shares it with the second one, + * changes the role, and takes the access away again. + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`, + * `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2` + * defaults to the first password). + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * npx vitest run src/api/__tests__/live-stalwart-mailbox-share.test.ts + * + * The folder and its share are removed afterwards, also after a failed check. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { supportsMailboxSharing } from '../delegation'; +import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing'; +import { createMailbox, deleteMailbox } from '../email'; +import { listPrincipals } from '../files'; +import { directoryNotice } from '../../lib/principal-directory'; +import { detectPreset, MAILBOX_PRESETS } from '../../lib/delegation/share-rights'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS; +const live = SERVER && PASS && USER2 ? describe : describe.skip; + +let owner: JMAPClient; +let delegate: JMAPClient; +let folderId: string | null = null; + +async function useOwner(): Promise { + const mod = await import('../jmap-client'); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot()); +} + +/** The owner's folders the delegate can see. */ +async function delegateSees(): Promise { + const session = await delegate.refreshSession(); + if (!session.accounts?.[owner.accountId]) return []; + const res = await delegate.request( + [['Mailbox/get', { accountId: owner.accountId, ids: null, properties: ['id'] }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.MAIL], + ); + const [method, body] = (res.methodResponses ?? [])[0] as [string, { list?: { id: string }[] }, string]; + return method === 'Mailbox/get' ? (body.list ?? []).map((m) => m.id) : []; +} + +live('live Stalwart: folder sharing', () => { + beforeAll(async () => { + owner = new JMAPClient(); + await owner.connect(SERVER!, USER, PASS); + delegate = new JMAPClient(); + await delegate.connect(SERVER!, USER2!, PASS2); + await useOwner(); + }, 60_000); + + afterAll(async () => { + if (!owner || !folderId) return; + await useOwner(); + await setMailboxShare(folderId, delegate.accountId, null).catch(() => undefined); + await deleteMailbox(folderId, undefined, { onDestroyRemoveEmails: true }).catch(() => undefined); + }, 60_000); + + it('shares a folder read-only, upgrades it, and takes it back', async () => { + expect(supportsMailboxSharing()).toBe(true); + folderId = await createMailbox({ name: `Bulwark share check ${Date.now()}` }); + const principalId = delegate.accountId; + + // Nobody yet: Stalwart answers {} (or null). + expect(Object.keys((await getMailboxShareWith(folderId)) ?? {})).toEqual([]); + + await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.read); + const afterRead = await getMailboxShareWith(folderId); + expect(afterRead?.[principalId]).toBeTruthy(); + // 1.0 stores the keyword right with the seen right; still "Read only". + expect(detectPreset('mailbox', afterRead![principalId])).toBe('read'); + expect(await delegateSees()).toContain(folderId); + + await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.readWrite); + const afterWrite = await getMailboxShareWith(folderId); + expect(detectPreset('mailbox', afterWrite![principalId])).toBe('readWrite'); + + await setMailboxShare(folderId, principalId, null); + const afterRemove = await getMailboxShareWith(folderId); + expect(afterRemove?.[principalId]).toBeFalsy(); + expect(await delegateSees()).not.toContain(folderId); + }, 60_000); + + // Stalwart 1.0 hides the directory unless the admin turns directory + // queries on: the sheet then says why the list is short and names a + // sharee it can't resolve "Unknown user". 0.16 lists everyone. + it('either lists the person shared with or explains why the list is short', async () => { + const listing = await listPrincipals(); + const listed = listing.principals.some((p) => p.id === delegate.accountId); + const notice = directoryNotice(listing); + expect(listed || notice !== null).toBe(true); + if (!listed) expect(notice).toBe('restricted'); + }, 60_000); +}); diff --git a/src/api/__tests__/mailbox-sharing.test.ts b/src/api/__tests__/mailbox-sharing.test.ts new file mode 100644 index 00000000..022b84a5 --- /dev/null +++ b/src/api/__tests__/mailbox-sharing.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'own', + request: vi.fn(), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing'; +import { MAILBOX_PRESETS } from '../../lib/delegation/share-rights'; +import { canShareMailbox, mailboxShareRef } from '../../lib/mailbox-share'; +import type { Mailbox, MailboxRights } from '../types'; + +const mockRequest = jmapClient.request as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getMailboxShareWith', () => { + it("asks for the folder's shareWith with the mail:share capability", async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Mailbox/get', { list: [{ id: 'mb1', shareWith: { p2: MAILBOX_PRESETS.read } }] }, '0']], + }); + const shareWith = await getMailboxShareWith('mb1'); + expect(shareWith).toEqual({ p2: MAILBOX_PRESETS.read }); + const [[call], using] = mockRequest.mock.calls[0]; + expect(call).toEqual(['Mailbox/get', { accountId: 'own', ids: ['mb1'], properties: ['id', 'shareWith'] }, '0']); + expect(using).toContain('urn:ietf:params:jmap:mail:share'); + }); + + it('reads a folder of a shared account under its owner', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [{ id: 'x', shareWith: null }] }, '0']] }); + expect(await getMailboxShareWith('x', 'team')).toBeNull(); + expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('team'); + }); + + it('fails when the folder is gone', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [], notFound: ['mb1'] }, '0']] }); + await expect(getMailboxShareWith('mb1')).rejects.toThrow('Folder not found'); + }); +}); + +describe('setMailboxShare', () => { + it('patches one principal into shareWith, and null takes the access away', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: { mb1: null } }, '0']] }); + await setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read); + await setMailboxShare('mb1', 'p2', null, 'team'); + expect(mockRequest.mock.calls[0][0][0][1]).toEqual({ accountId: 'own', update: { mb1: { 'shareWith/p2': MAILBOX_PRESETS.read } } }); + expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ accountId: 'team', update: { mb1: { 'shareWith/p2': null } } }); + }); + + it("reports the server's refusal", async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Mailbox/set', { notUpdated: { mb1: { type: 'forbidden', description: 'Not allowed' } } }, '0']], + }); + await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('Not allowed'); + }); + + it('fails when the server does not confirm the change', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: {} }, '0']] }); + await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('did not confirm'); + }); +}); + +describe('which folders offer Share', () => { + const rights = (extra: Record = {}) => ({ ...MAILBOX_PRESETS.read, ...extra }) as unknown as MailboxRights; + + it("offers it on the user's own folders where the server shares folders", () => { + expect(canShareMailbox({ isShared: false, myRights: rights() }, true)).toBe(true); + expect(canShareMailbox({ isShared: false, myRights: rights() }, false)).toBe(false); + }); + + it("offers it on someone else's folder only with their permission to pass it on", () => { + expect(canShareMailbox({ isShared: true, myRights: rights() }, true)).toBe(false); + expect(canShareMailbox({ isShared: true, myRights: rights({ mayShare: true }) }, true)).toBe(true); + }); + + it("shares a shared account's folder under its owner, with the owner's id", () => { + const shared = { id: 'team:5', originalId: '5', isShared: true, accountId: 'team' } as Mailbox; + expect(mailboxShareRef(shared)).toEqual({ mailboxId: '5', accountId: 'team' }); + expect(mailboxShareRef({ id: 'a', isShared: false, accountId: 'own' } as Mailbox)).toEqual({ mailboxId: 'a', accountId: undefined }); + }); +}); diff --git a/src/api/mailbox-sharing.ts b/src/api/mailbox-sharing.ts new file mode 100644 index 00000000..f53ec327 --- /dev/null +++ b/src/api/mailbox-sharing.ts @@ -0,0 +1,62 @@ +// Sharing a mail folder with other principals +// (urn:ietf:params:jmap:mail:share), as the webmail's +// JMAPClient.getMailboxShareWith / setMailboxShare do (lib/jmap/client.ts). + +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import { requireMethodResult } from './jmap-result'; +import { MAIL_SHARE_CAPABILITY } from './delegation'; +import type { ShareMailboxRights } from '../lib/delegation/share-rights'; + +const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, MAIL_SHARE_CAPABILITY]; + +/** + * The current `shareWith` of one folder. Read on demand (the share sheet): + * Stalwart leaves shareWith out of Mailbox/get unless it is asked for. + * `accountId` is the owner's account for a folder someone shared with the + * user, with the owner's mailbox id. + */ +export async function getMailboxShareWith( + mailboxId: string, + accountId?: string, +): Promise | null> { + const res = await jmapClient.request( + [['Mailbox/get', { accountId: accountId || jmapClient.accountId, ids: [mailboxId], properties: ['id', 'shareWith'] }, '0']], + USING, + ); + const result = requireMethodResult<{ list?: Array<{ id: string; shareWith?: Record | null }> }>( + res, '0', 'Mailbox/get', + ); + const mailbox = (result.list ?? []).find((mb) => mb.id === mailboxId); + if (!mailbox) throw new Error('Folder not found'); + return mailbox.shareWith ?? null; +} + +/** + * Grants (`rights`) or revokes (`null`) a principal's access to a folder with + * a `shareWith/{principalId}` patch, like the calendar and address book + * shares. + */ +export async function setMailboxShare( + mailboxId: string, + principalId: string, + rights: ShareMailboxRights | null, + accountId?: string, +): Promise { + const res = await jmapClient.request( + [['Mailbox/set', { + accountId: accountId || jmapClient.accountId, + update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } }, + }, '0']], + USING, + ); + const result = requireMethodResult<{ + updated?: Record | null; + notUpdated?: Record; + }>(res, '0', 'Mailbox/set'); + const err = result.notUpdated?.[mailboxId]; + if (err) throw new Error(err.description || err.type || 'Failed to update folder share'); + if (!result.updated || !(mailboxId in result.updated)) { + throw new Error('Server did not confirm the share update'); + } +} diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 16b4cd2e..984092f6 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star, Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus, - Clock, Layers, Users, Tag, Mails, MoreHorizontal, MailOpen, StickyNote, AlarmClock, Flag, + Clock, Layers, Users, Tag, Mails, MoreHorizontal, Share2, MailOpen, StickyNote, AlarmClock, Flag, CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw, type AppIcon, } from './icons'; @@ -35,6 +35,9 @@ import { } from '../api/email'; import { useTagCountsStore } from '../stores/tag-counts-store'; import { sidebarTagRows } from '../lib/keyword-nesting'; +import { supportsMailboxSharing } from '../api/delegation'; +import { MailboxShareSheet } from './sharing/MailboxShareSheet'; +import { canShareMailbox } from '../lib/mailbox-share'; import { tagIdFromKeyword } from '../lib/thread-utils'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -381,6 +384,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void; } | null>(null); const [busy, setBusy] = React.useState(false); + // The folder whose "Share" sheet is open (webmail MailboxShareDialog). + const [sharing, setSharing] = React.useState<{ mailbox: Mailbox; name: string } | null>(null); const tagCounts = useTagCountsStore((s) => s.counts); const tagCountsGeneration = useTagCountsStore((s) => s.generation); const ensureTagCounts = useTagCountsStore((s) => s.ensure); @@ -664,6 +669,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side }), }); } + if (canShareMailbox(mb, supportsMailboxSharing())) { + actions.push({ + key: 'share', + label: t('mailbox_context_menu.share', 'Share…'), + icon: Share2, + onPress: () => setSharing({ mailbox: mb, name }), + }); + } if (!mb.role && mb.myRights?.mayRename !== false) { actions.push({ key: 'rename', @@ -1316,6 +1329,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side onClose={() => setPrompt(null)} /> )} + setSharing(null)} /> ); } @@ -1351,6 +1365,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side onClose={() => setPrompt(null)} /> )} + setSharing(null)} /> ); } diff --git a/src/components/__tests__/mailbox-share-wiring.test.ts b/src/components/__tests__/mailbox-share-wiring.test.ts new file mode 100644 index 00000000..805c533f --- /dev/null +++ b/src/components/__tests__/mailbox-share-wiring.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Checked on the source: the suite has no React Native renderer. +const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8'); + +describe('folder sharing in the sidebar', () => { + it('offers Share in the folder menu where the folder can be shared, and opens the share sheet', () => { + const source = read('components/SidebarDrawer.tsx'); + expect(source).toMatch(/canShareMailbox\(mb, supportsMailboxSharing\(\)\)/); + expect(source).toMatch(/key: 'share'/); + expect(source).toMatch(/onPress: \(\) => setSharing\(\{ mailbox: mb, name \}\)/); + // Both the docked sidebar and the drawer render the sheet. + expect(source.match(/ { + const source = read('components/sharing/MailboxShareSheet.tsx'); + expect(source).toMatch(/getMailboxShareWith\(mailboxId, accountId\)/); + expect(source).toMatch(/setMailboxShare\(mailboxId, principalId, rights, accountId\)/); + expect(source).toMatch(/detectPreset\('mailbox', rights\)/); + expect(source).not.toMatch(/freeBusy/); + }); + + it('lets the share sheet name a preset the server stored differently', () => { + const source = read('components/sharing/ShareCollectionSheet.tsx'); + expect(source).toMatch(/detectPreset \? detectPreset\(rights\) : detectSharePreset\(rights, presets\)/); + }); +}); diff --git a/src/components/sharing/MailboxShareSheet.tsx b/src/components/sharing/MailboxShareSheet.tsx new file mode 100644 index 00000000..d9c381a9 --- /dev/null +++ b/src/components/sharing/MailboxShareSheet.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { ActivityIndicator, Alert, Modal, Pressable, StyleSheet, View } from 'react-native'; +import type { Mailbox } from '../../api/types'; +import { getMailboxShareWith, setMailboxShare } from '../../api/mailbox-sharing'; +import { detectPreset, MAILBOX_PRESETS, type ShareMailboxRights } from '../../lib/delegation/share-rights'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useColors } from '../../theme/colors'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { ShareCollectionSheet, type ShareableCollection, type SharePreset } from './ShareCollectionSheet'; +import { mailboxShareRef } from '../../lib/mailbox-share'; + +interface MailboxShareSheetProps { + /** Null hides the sheet. */ + mailbox: Mailbox | null; + /** The folder's name as the sidebar shows it (Inbox, Sent… localised). */ + name?: string; + onClose: () => void; +} + +/** + * Share a mail folder with other people (webmail mailbox-share-dialog.tsx): + * the folder's shareWith is read when the sheet opens, since it is not part + * of the folder list, and then the same sheet as for calendars and address + * books picks people and roles. + */ +export function MailboxShareSheet({ mailbox, name, onClose }: MailboxShareSheetProps) { + const t = useLocaleStore((s) => s.t); + const c = useColors(); + const [collection, setCollection] = React.useState | null>(null); + + React.useEffect(() => { + setCollection(null); + if (!mailbox) return undefined; + let cancelled = false; + const { mailboxId, accountId } = mailboxShareRef(mailbox); + getMailboxShareWith(mailboxId, accountId).then( + (shareWith) => { + if (!cancelled) setCollection({ id: mailboxId, name: name || mailbox.name, shareWith }); + }, + (err: unknown) => { + if (cancelled) return; + Alert.alert( + t('sharing.share_failed', 'Failed to update sharing'), + err instanceof Error ? err.message : String(err), + ); + onClose(); + }, + ); + return () => { + cancelled = true; + }; + // `name` and `t` only label the sheet; a new folder starts a new read. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mailbox]); + + // The webmail's folder presets: no free/busy for mail. + const presets = React.useMemo[]>(() => [ + { key: 'read', label: t('sharing.preset.read', 'Read only'), rights: MAILBOX_PRESETS.read }, + { key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: MAILBOX_PRESETS.readWrite }, + { key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: MAILBOX_PRESETS.manager }, + ], [t]); + + if (!mailbox) return null; + + if (!collection) { + return ( + + + + + + + + ); + } + + const { accountId } = mailboxShareRef(mailbox); + return ( + detectPreset('mailbox', rights)} + onShare={(mailboxId, principalId, rights) => setMailboxShare(mailboxId, principalId, rights, accountId)} + onClose={onClose} + /> + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', alignItems: 'center', justifyContent: 'center' }, + spinner: { padding: 20, borderRadius: 12 }, +}); diff --git a/src/components/sharing/ShareCollectionSheet.tsx b/src/components/sharing/ShareCollectionSheet.tsx index 102bf117..5d2b3d69 100644 --- a/src/components/sharing/ShareCollectionSheet.tsx +++ b/src/components/sharing/ShareCollectionSheet.tsx @@ -55,13 +55,18 @@ interface ShareCollectionSheetProps { addRights: R; onShare: (collectionId: string, principalId: string, rights: R | null) => Promise; onClose: () => void; + /** + * Names the preset a stored grant amounts to, when the server stores + * something other than what was sent (a folder on Stalwart 1.0). + */ + detectPreset?: (rights: R) => string; } // JMAP sharing for an owned collection: pick a principal, choose a role // (webmail ShareCollectionDialog). The principal list comes from the same // Principal/query the Files share sheet uses. export function ShareCollectionSheet({ - collection, presets, addRights, onShare, onClose, + collection, presets, addRights, onShare, onClose, detectPreset, }: ShareCollectionSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); @@ -224,7 +229,7 @@ export function ShareCollectionSheet({ {t('calendar.share.shared_with', 'Shared with')} {sharedEntries.map(([principalId, rights]) => { - const preset = detectSharePreset(rights, presets); + const preset = detectPreset ? detectPreset(rights) : detectSharePreset(rights, presets); const busy = savingId === principalId; return ( diff --git a/src/lib/mailbox-share.ts b/src/lib/mailbox-share.ts new file mode 100644 index 00000000..0d44b82c --- /dev/null +++ b/src/lib/mailbox-share.ts @@ -0,0 +1,31 @@ +import type { Mailbox } from '../api/types'; + +// Folder sharing (webmail mailbox-share-dialog.tsx and the Share entry of +// mailbox-context-menu.tsx): which folders offer it, and where it is kept. + +/** + * Where a folder's sharing is read and written: a folder of a shared or + * group account lives under its owner's account with the owner's id. + */ +export function mailboxShareRef(mailbox: Pick): { + mailboxId: string; + accountId: string | undefined; +} { + return { + mailboxId: mailbox.originalId || mailbox.id, + accountId: mailbox.isShared ? mailbox.accountId : undefined, + }; +} + +/** + * Whether the folder's menu offers "Share": the server takes shareWith on + * folders (mail:share), and a folder someone shared with the user may be + * passed on only with their permission (webmail mailbox-context-menu.tsx). + */ +export function canShareMailbox( + mailbox: Pick, + serverSupportsSharing: boolean, +): boolean { + if (!serverSupportsSharing) return false; + return !mailbox.isShared || (mailbox.myRights as { mayShare?: boolean } | undefined)?.mayShare === true; +}