feat: share a mail folder with other people from its menu

This commit is contained in:
Linus Rath
2026-10-31 15:29:35 +01:00
parent 585c70a5db
commit 417f1843a2
8 changed files with 437 additions and 3 deletions
@@ -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<void> {
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<string[]> {
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);
});
+86
View File
@@ -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<typeof vi.fn>;
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<string, boolean> = {}) => ({ ...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 });
});
});
+62
View File
@@ -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<Record<string, ShareMailboxRights> | 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<string, ShareMailboxRights> | 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<void> {
const res = await jmapClient.request(
[['Mailbox/set', {
accountId: accountId || jmapClient.accountId,
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
USING,
);
const result = requireMethodResult<{
updated?: Record<string, unknown> | null;
notUpdated?: Record<string, { description?: string; type?: string }>;
}>(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');
}
}
+16 -1
View File
@@ -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)}
/>
)}
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
</View>
);
}
@@ -1351,6 +1365,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onClose={() => setPrompt(null)}
/>
)}
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
</SafeAreaModal>
);
}
@@ -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(/<MailboxShareSheet /g)).toHaveLength(2);
});
it('reads and writes the share through the folder sharing calls, with the folder presets', () => {
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\)/);
});
});
@@ -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<ShareableCollection<ShareMailboxRights> | 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<SharePreset<ShareMailboxRights>[]>(() => [
{ 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 (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose}>
<Pressable
style={styles.backdrop}
onPress={onClose}
accessibilityLabel={t('sharing.loading_principals', 'Loading users…')}
>
<View style={[styles.spinner, { backgroundColor: c.background }]}>
<ActivityIndicator size="small" color={c.textMuted} />
</View>
</Pressable>
</Modal>
);
}
const { accountId } = mailboxShareRef(mailbox);
return (
<ShareCollectionSheet
collection={collection}
presets={presets}
addRights={MAILBOX_PRESETS.read}
// Stalwart 1.0 stores a read grant with the keyword right on.
detectPreset={(rights) => 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 },
});
@@ -55,13 +55,18 @@ interface ShareCollectionSheetProps<R extends object> {
addRights: R;
onShare: (collectionId: string, principalId: string, rights: R | null) => Promise<void>;
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<R extends object>({
collection, presets, addRights, onShare, onClose,
collection, presets, addRights, onShare, onClose, detectPreset,
}: ShareCollectionSheetProps<R>) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
@@ -224,7 +229,7 @@ export function ShareCollectionSheet<R extends object>({
<View style={styles.section}>
<Text style={styles.sectionLabel}>{t('calendar.share.shared_with', 'Shared with')}</Text>
{sharedEntries.map(([principalId, rights]) => {
const preset = detectSharePreset(rights, presets);
const preset = detectPreset ? detectPreset(rights) : detectSharePreset(rights, presets);
const busy = savingId === principalId;
return (
<View key={principalId} style={styles.shareRow}>
+31
View File
@@ -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<Mailbox, 'id' | 'originalId' | 'isShared' | 'accountId'>): {
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<Mailbox, 'isShared' | 'myRights'>,
serverSupportsSharing: boolean,
): boolean {
if (!serverSupportsSharing) return false;
return !mailbox.isShared || (mailbox.myRights as { mayShare?: boolean } | undefined)?.mayShare === true;
}