mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 16:15:53 +00:00
feat: share mail folders with other users via mail:share
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
FolderX,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
@@ -85,6 +86,8 @@ interface MailboxContextMenuProps {
|
||||
onCreateFolder?: (accountId?: string) => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
/** Share the folder with other principals (mail:share); omitted when the server lacks it. */
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
@@ -104,6 +107,7 @@ export function MailboxContextMenu({
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onShareFolder,
|
||||
onImportEmail,
|
||||
onRefresh,
|
||||
}: MailboxContextMenuProps) {
|
||||
@@ -158,6 +162,9 @@ export function MailboxContextMenu({
|
||||
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||
const canAddItems = mailbox.myRights?.mayAddItems !== false;
|
||||
// Sharing needs the mail:share extension (handler present) and, on a folder
|
||||
// someone else shared with us, their permission to re-share it.
|
||||
const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true);
|
||||
|
||||
const fullPath = getMailboxPath(mailbox, mailboxes);
|
||||
|
||||
@@ -198,6 +205,15 @@ export function MailboxContextMenu({
|
||||
disabled={!onRenameFolder || !canRename}
|
||||
testId="mailbox-rename"
|
||||
/>
|
||||
{onShareFolder && (
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("share")}
|
||||
onClick={() => handleAction(() => onShareFolder(mailbox.id))}
|
||||
disabled={!canShare}
|
||||
testId="mailbox-share"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Mailbox, MailboxRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface MailboxShareDialogProps {
|
||||
client: IJMAPClient;
|
||||
mailbox: Mailbox;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a mail folder with other principals (urn:ietf:params:jmap:mail:share).
|
||||
* The folder's current `shareWith` is loaded on open - it is not part of the
|
||||
* regular folder list - and re-read after every change so the dialog always
|
||||
* reflects what the server holds.
|
||||
*/
|
||||
export function MailboxShareDialog({ client, mailbox, onClose }: MailboxShareDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const [shareWith, setShareWith] = useState<Record<string, MailboxRights> | null | undefined>(undefined);
|
||||
|
||||
// A folder of a delegated account lives under its owner's accountId with
|
||||
// the owner's (un-namespaced) mailbox id.
|
||||
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
const jmapMailboxId = mailbox.originalId || mailbox.id;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!client.getMailboxShareWith) {
|
||||
setShareWith(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setShareWith(await client.getMailboxShareWith(jmapMailboxId, accountId));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||
onClose();
|
||||
}
|
||||
}, [client, jmapMailboxId, accountId, onClose, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (shareWith === undefined) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||
<div className="relative rounded-lg border border-border bg-background px-6 py-4 shadow-xl">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-label={t("loading_principals")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="mailbox"
|
||||
collectionName={mailbox.name}
|
||||
shareWith={shareWith}
|
||||
ownAccountId={mailbox.accountId || client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
if (!client.setMailboxShare) return;
|
||||
await client.setMailboxShare(jmapMailboxId, principalId, rights as MailboxRights | null, accountId);
|
||||
await load();
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,7 @@ interface SidebarProps {
|
||||
onCreateFolder?: (accountId?: string) => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
@@ -819,6 +820,7 @@ export function Sidebar({
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onShareFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
@@ -1506,6 +1508,7 @@ export function Sidebar({
|
||||
onCreateFolder={onCreateFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onShareFolder={onShareFolder}
|
||||
onImportEmail={onImportEmail}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -2719,6 +2720,16 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Folder sharing (mail:share): the dialog loads the folder's shareWith on
|
||||
// open, so only the id needs to be remembered here.
|
||||
const [sharingMailboxId, setSharingMailboxId] = useState<string | null>(null);
|
||||
const sharingMailbox = sharingMailboxId ? mailboxes.find(mb => mb.id === sharingMailboxId) ?? null : null;
|
||||
const supportsMailboxSharing = !!client?.supportsMailboxSharing?.();
|
||||
const handleShareFolderFromContextMenu = (mailboxId: string) => {
|
||||
if (!client) return;
|
||||
setSharingMailboxId(mailboxId);
|
||||
};
|
||||
|
||||
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
|
||||
if (!client) return;
|
||||
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||
@@ -3444,6 +3455,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||
onShareFolder={supportsMailboxSharing ? handleShareFolderFromContextMenu : undefined}
|
||||
onImportEmail={handleImportEmailFromContextMenu}
|
||||
onRefreshMailboxes={handleRefreshMailboxes}
|
||||
onCompose={() => {
|
||||
@@ -4182,6 +4194,13 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
)}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<PromptDialog {...promptDialogProps} />
|
||||
{client && sharingMailbox && (
|
||||
<MailboxShareDialog
|
||||
client={client}
|
||||
mailbox={sharingMailbox}
|
||||
onClose={() => setSharingMailboxId(null)}
|
||||
/>
|
||||
)}
|
||||
<TotpReauthDialog />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
@@ -6,11 +6,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal, CalendarRights, AddressBookRights, FileNodeRights } from "@/lib/jmap/types";
|
||||
import type { Principal, CalendarRights, AddressBookRights, FileNodeRights, MailboxRights } from "@/lib/jmap/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
type ShareKind = "calendar" | "addressBook" | "file";
|
||||
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights;
|
||||
type ShareKind = "calendar" | "addressBook" | "file" | "mailbox";
|
||||
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights | MailboxRights;
|
||||
|
||||
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
||||
|
||||
@@ -54,6 +54,37 @@ const FILE_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, FileNodeR
|
||||
},
|
||||
};
|
||||
|
||||
// Mail folder rights (RFC 8621 §2 + mail:share). "Read & write" lets the
|
||||
// grantee file, flag and remove mail; "Manager" additionally lets them
|
||||
// rename/delete the folder, create subfolders, send as it and re-share.
|
||||
const MAILBOX_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, MailboxRights> = {
|
||||
read: {
|
||||
mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySetSeen: true,
|
||||
maySetKeywords: false, mayCreateChild: false, mayRename: false, mayDelete: false,
|
||||
maySubmit: false, mayShare: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
|
||||
maySetKeywords: true, mayCreateChild: false, mayRename: false, mayDelete: false,
|
||||
maySubmit: false, mayShare: false,
|
||||
},
|
||||
manager: {
|
||||
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
|
||||
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true,
|
||||
maySubmit: true, mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
function detectMailboxPreset(r: MailboxRights): RolePreset {
|
||||
for (const [name, preset] of Object.entries(MAILBOX_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, MailboxRights][]) {
|
||||
const keys = Object.keys(preset) as (keyof MailboxRights)[];
|
||||
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectCalendarPreset(r: CalendarRights): RolePreset {
|
||||
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
||||
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
||||
@@ -87,12 +118,14 @@ function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefine
|
||||
if (preset === "custom") return undefined;
|
||||
if (kind === "calendar") return CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS];
|
||||
if (kind === "file") return FILE_PRESETS[preset as keyof typeof FILE_PRESETS];
|
||||
if (kind === "mailbox") return MAILBOX_PRESETS[preset as keyof typeof MAILBOX_PRESETS];
|
||||
return ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
|
||||
}
|
||||
|
||||
function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
|
||||
if (kind === "calendar") return detectCalendarPreset(rights as CalendarRights);
|
||||
if (kind === "file") return detectFilePreset(rights as FileNodeRights);
|
||||
if (kind === "mailbox") return detectMailboxPreset(rights as MailboxRights);
|
||||
return detectAddressBookPreset(rights as AddressBookRights);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
/**
|
||||
* Request shapes of the JMAP features layered on the core client: delta sync
|
||||
* (Email/changes, Mailbox/changes), search snippets (SearchSnippet/get) and
|
||||
* folder sharing (mail:share). Each asserts what goes over the wire and how
|
||||
* the response is mapped back.
|
||||
*/
|
||||
|
||||
function makeSession(accountCapabilities: Record<string, unknown> = {}) {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: {
|
||||
'acct-1': {
|
||||
name: 'test',
|
||||
isPersonal: true,
|
||||
accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...accountCapabilities },
|
||||
},
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
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: '',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
type RequestBody = { using: string[]; methodCalls: [string, Record<string, unknown>, string][] };
|
||||
|
||||
describe('JMAP client extensions', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let client: JMAPClient;
|
||||
let requests: RequestBody[];
|
||||
|
||||
async function setup(accountCapabilities: Record<string, unknown> = {}) {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(accountCapabilities)));
|
||||
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
requests = [];
|
||||
}
|
||||
|
||||
function answer(handler: (body: RequestBody) => unknown) {
|
||||
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as RequestBody;
|
||||
requests.push(body);
|
||||
return jsonResponse(handler(body));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client?.disconnect();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('delta sync', () => {
|
||||
it('maps Email/changes and passes sinceState / maxChanges', async () => {
|
||||
await setup();
|
||||
answer(() => ({
|
||||
methodResponses: [[
|
||||
'Email/changes',
|
||||
{ accountId: 'acct-1', oldState: 's1', newState: 's2', hasMoreChanges: false, created: ['a'], updated: ['b'], destroyed: ['c'] },
|
||||
'0',
|
||||
]],
|
||||
}));
|
||||
|
||||
const delta = await client.getEmailChanges('s1', undefined, 200);
|
||||
|
||||
expect(requests[0].methodCalls[0]).toEqual(['Email/changes', { accountId: 'acct-1', sinceState: 's1', maxChanges: 200 }, '0']);
|
||||
expect(delta).toEqual({
|
||||
oldState: 's1', newState: 's2', hasMoreChanges: false,
|
||||
created: ['a'], updated: ['b'], destroyed: ['c'], updatedProperties: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the server cannot calculate the delta', async () => {
|
||||
await setup();
|
||||
answer(() => ({ methodResponses: [['error', { type: 'cannotCalculateChanges' }, '0']] }));
|
||||
expect(await client.getMailboxChanges('old')).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces updatedProperties from Mailbox/changes', async () => {
|
||||
await setup();
|
||||
answer(() => ({
|
||||
methodResponses: [[
|
||||
'Mailbox/changes',
|
||||
{ oldState: 'm1', newState: 'm2', hasMoreChanges: false, created: [], updated: ['inbox'], destroyed: [], updatedProperties: ['totalEmails', 'unreadEmails'] },
|
||||
'0',
|
||||
]],
|
||||
}));
|
||||
const delta = await client.getMailboxChanges('m1');
|
||||
expect(delta?.updatedProperties).toEqual(['totalEmails', 'unreadEmails']);
|
||||
});
|
||||
|
||||
it('reports the Mailbox state per account from getAllMailboxesWithState', async () => {
|
||||
await setup();
|
||||
answer(() => ({
|
||||
methodResponses: [['Mailbox/get', { accountId: 'acct-1', state: 'm7', list: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }] }, '0']],
|
||||
}));
|
||||
const { mailboxes, states } = await client.getAllMailboxesWithState();
|
||||
expect(mailboxes.map((m) => m.id)).toEqual(['inbox']);
|
||||
expect(states).toEqual({ 'acct-1': 'm7' });
|
||||
});
|
||||
|
||||
it('reports the Email state a page was read at', async () => {
|
||||
await setup();
|
||||
answer(() => ({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: ['e1'], total: 1 }, '0'],
|
||||
['Email/get', { state: 'e9', list: [{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', mailboxIds: { inbox: true }, keywords: {} }] }, '1'],
|
||||
],
|
||||
}));
|
||||
const page = await client.getEmails('inbox');
|
||||
expect(page.state).toBe('e9');
|
||||
expect(page.emails).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search snippets', () => {
|
||||
it('chains SearchSnippet/get onto a text search and attaches the hits', async () => {
|
||||
await setup();
|
||||
answer(() => ({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'],
|
||||
['Email/get', { list: [
|
||||
{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', subject: 'Invoice', mailboxIds: {}, keywords: {} },
|
||||
{ id: 'e2', threadId: 't2', receivedAt: '2026-08-28T00:00:00Z', subject: 'Other', mailboxIds: {}, keywords: {} },
|
||||
] }, '1'],
|
||||
['SearchSnippet/get', { list: [
|
||||
{ emailId: 'e1', subject: '<mark>Invoice</mark>', preview: 'your <mark>invoice</mark> is attached' },
|
||||
{ emailId: 'e2', subject: null, preview: null },
|
||||
] }, 'snippets'],
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await client.searchEmails('invoice', 'inbox');
|
||||
|
||||
const snippetCall = requests[0].methodCalls[2];
|
||||
expect(snippetCall[0]).toBe('SearchSnippet/get');
|
||||
expect(snippetCall[1]).toEqual({
|
||||
accountId: 'acct-1',
|
||||
// The wildcard the search adds for prefix matching is stripped again.
|
||||
filter: { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'invoice' }] },
|
||||
'#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
});
|
||||
expect(result.emails[0].searchSnippet).toEqual({ subject: '<mark>Invoice</mark>', preview: 'your <mark>invoice</mark> is attached' });
|
||||
expect(result.emails[1].searchSnippet).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends a no-op instead of SearchSnippet/get for a structural filter', async () => {
|
||||
await setup();
|
||||
answer(() => ({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: [], total: 0 }, '0'],
|
||||
['Email/get', { list: [] }, '1'],
|
||||
['Core/echo', {}, 'snippets'],
|
||||
],
|
||||
}));
|
||||
await client.advancedSearchEmails({ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }] });
|
||||
expect(requests[0].methodCalls[2][0]).toBe('Core/echo');
|
||||
});
|
||||
|
||||
it('keeps the results when only the snippet call fails', async () => {
|
||||
await setup();
|
||||
answer(() => ({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: ['e1'], total: 1 }, '0'],
|
||||
['Email/get', { list: [{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', mailboxIds: {}, keywords: {} }] }, '1'],
|
||||
['error', { type: 'requestTooLarge' }, 'snippets'],
|
||||
],
|
||||
}));
|
||||
const result = await client.searchEmails('hello');
|
||||
expect(result.emails).toHaveLength(1);
|
||||
expect(result.emails[0].searchSnippet).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailbox sharing', () => {
|
||||
it('is advertised only with the mail:share capability', async () => {
|
||||
await setup();
|
||||
expect(client.supportsMailboxSharing()).toBe(false);
|
||||
client.disconnect();
|
||||
vi.restoreAllMocks();
|
||||
await setup({ 'urn:ietf:params:jmap:mail:share': {} });
|
||||
expect(client.supportsMailboxSharing()).toBe(true);
|
||||
});
|
||||
|
||||
it('reads shareWith with the extension in `using`', async () => {
|
||||
await setup({ 'urn:ietf:params:jmap:mail:share': {} });
|
||||
answer(() => ({
|
||||
methodResponses: [['Mailbox/get', { list: [{ id: 'f1', shareWith: { p1: { mayReadItems: true } } }] }, '0']],
|
||||
}));
|
||||
|
||||
const shareWith = await client.getMailboxShareWith('f1');
|
||||
|
||||
expect(requests[0].using).toContain('urn:ietf:params:jmap:mail:share');
|
||||
expect(requests[0].methodCalls[0]).toEqual(['Mailbox/get', { accountId: 'acct-1', ids: ['f1'], properties: ['id', 'shareWith'] }, '0']);
|
||||
expect(shareWith).toEqual({ p1: { mayReadItems: true } });
|
||||
});
|
||||
|
||||
it('patches shareWith/{principal} on the owner account and checks the outcome', async () => {
|
||||
await setup({ 'urn:ietf:params:jmap:mail:share': {} });
|
||||
answer(() => ({ methodResponses: [['Mailbox/set', { updated: { f1: null } }, '0']] }));
|
||||
|
||||
await client.setMailboxShare('f1', 'p1', null, 'owner');
|
||||
expect(requests[0].methodCalls[0]).toEqual(['Mailbox/set', { accountId: 'owner', update: { f1: { 'shareWith/p1': null } } }, '0']);
|
||||
|
||||
answer(() => ({ methodResponses: [['Mailbox/set', { notUpdated: { f1: { type: 'forbidden', description: 'no' } } }, '0']] }));
|
||||
await expect(client.setMailboxShare('f1', 'p1', null)).rejects.toThrow('no');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, CollectionChanges, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { SortLevel } from "@/lib/message-list-order";
|
||||
|
||||
@@ -126,6 +126,11 @@ export interface IJMAPClient {
|
||||
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
|
||||
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise<void>;
|
||||
deleteMailbox(mailboxId: string, accountId?: string): Promise<void>;
|
||||
// Mailbox sharing (urn:ietf:params:jmap:mail:share). Optional: the demo
|
||||
// client has no principals to share with.
|
||||
supportsMailboxSharing?(accountId?: string): boolean;
|
||||
getMailboxShareWith?(mailboxId: string, accountId?: string): Promise<Record<string, MailboxRights> | null>;
|
||||
setMailboxShare?(mailboxId: string, principalId: string, rights: MailboxRights | null, accountId?: string): Promise<void>;
|
||||
|
||||
// ── Emails ────────────────────────────────────────────────────
|
||||
// `pinnedFirst` sorts emails carrying the $pinned keyword to the top
|
||||
|
||||
+68
-1
@@ -1,5 +1,5 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, CollectionChanges, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -5127,6 +5127,73 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mailbox sharing (urn:ietf:params:jmap:mail:share) ─────────────
|
||||
|
||||
static readonly MAIL_SHARE_CAPABILITY = 'urn:ietf:params:jmap:mail:share';
|
||||
|
||||
/** Whether the account's server accepts `shareWith` on Mailbox objects. */
|
||||
supportsMailboxSharing(accountId?: string): boolean {
|
||||
return this.hasAccountCapability(JMAPClient.MAIL_SHARE_CAPABILITY, accountId || this.accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current `shareWith` map of one folder. Fetched on demand (the share
|
||||
* dialog) rather than with every folder list: Stalwart omits shareWith from
|
||||
* Mailbox/get unless it is requested, and it is only needed here.
|
||||
*/
|
||||
async getMailboxShareWith(mailboxId: string, accountId?: string): Promise<Record<string, MailboxRights> | null> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Mailbox/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: [mailboxId],
|
||||
properties: ["id", "shareWith"],
|
||||
}, "0"],
|
||||
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY]);
|
||||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||||
if (method !== "Mailbox/get") {
|
||||
throw new Error((result as { description?: string })?.description || "Failed to load folder sharing");
|
||||
}
|
||||
const mailbox = ((result as { list?: Array<{ id: string; shareWith?: Record<string, MailboxRights> | null }> }).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
|
||||
* via a `shareWith/{principalId}` patch, like the calendar / address book
|
||||
* variants.
|
||||
*/
|
||||
async setMailboxShare(
|
||||
mailboxId: string,
|
||||
principalId: string,
|
||||
rights: MailboxRights | null,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: targetAccountId,
|
||||
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY]);
|
||||
|
||||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||||
if (method !== "Mailbox/set") {
|
||||
throw new Error((result as { description?: string })?.description || "Failed to update folder share");
|
||||
}
|
||||
const setResult = result as { updated?: Record<string, unknown>; notUpdated?: Record<string, { description?: string }> };
|
||||
if (setResult.notUpdated?.[mailboxId]) {
|
||||
throw new Error(setResult.notUpdated[mailboxId].description || "Failed to update folder share");
|
||||
}
|
||||
if (!setResult.updated || !(mailboxId in setResult.updated)) {
|
||||
throw new Error("Server did not confirm the share update");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+19
-11
@@ -210,24 +210,32 @@ export interface Mailbox {
|
||||
unreadEmails: number;
|
||||
totalThreads: number;
|
||||
unreadThreads: number;
|
||||
myRights: {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
};
|
||||
myRights: MailboxRights;
|
||||
isSubscribed: boolean;
|
||||
// Sharing (urn:ietf:params:jmap:mail:share): principalId -> rights. Only
|
||||
// present when explicitly requested (see getMailboxShareWith).
|
||||
shareWith?: Record<string, MailboxRights> | null;
|
||||
// Shared folder support
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
// Mailbox rights (RFC 8621 §2 myRights; `mayShare` from the mail:share
|
||||
// extension). Also the value type of Mailbox.shareWith.
|
||||
export interface MailboxRights {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
mayShare?: boolean;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
emailIds: string[];
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "مجلد فرعي جديد...",
|
||||
"new_folder": "مجلد جديد...",
|
||||
"rename": "إعادة تسمية...",
|
||||
"share": "مشاركة...",
|
||||
"import_email": "استيراد ملف .eml أو .zip...",
|
||||
"empty_folder": "إفراغ المجلد",
|
||||
"empty_folder_generic": "إفراغ المجلد",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Subcarpeta nova...",
|
||||
"new_folder": "Carpeta nova...",
|
||||
"rename": "Canvia el nom...",
|
||||
"share": "Comparteix...",
|
||||
"import_email": "Importa .eml o .zip...",
|
||||
"empty_folder": "Buida la carpeta",
|
||||
"empty_folder_generic": "Buida la carpeta",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nová podsložka...",
|
||||
"new_folder": "Nová složka...",
|
||||
"rename": "Přejmenovat...",
|
||||
"share": "Sdílet...",
|
||||
"import_email": "Importovat .eml nebo .zip...",
|
||||
"empty_folder": "Vyprázdnit složku",
|
||||
"empty_folder_generic": "Vyprázdnit složku",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Ny undermappe...",
|
||||
"new_folder": "Ny mappe...",
|
||||
"rename": "Omdøb...",
|
||||
"share": "Del...",
|
||||
"import_email": "Importér .eml eller .zip...",
|
||||
"empty_folder": "Tøm mappe",
|
||||
"empty_folder_generic": "Tøm mappe",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Neuer Unterordner...",
|
||||
"new_folder": "Neuer Ordner...",
|
||||
"rename": "Umbenennen...",
|
||||
"share": "Freigeben...",
|
||||
"import_email": ".eml oder .zip importieren...",
|
||||
"empty_folder": "Ordner leeren",
|
||||
"empty_folder_generic": "Ordner leeren",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"share": "Share...",
|
||||
"import_email": "Import .eml or .zip...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nueva subcarpeta...",
|
||||
"new_folder": "Nueva carpeta...",
|
||||
"rename": "Renombrar...",
|
||||
"share": "Compartir...",
|
||||
"import_email": "Importar .eml o .zip...",
|
||||
"empty_folder": "Vaciar carpeta",
|
||||
"empty_folder_generic": "Vaciar carpeta",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "زیرپوشه جدید...",
|
||||
"new_folder": "پوشه جدید...",
|
||||
"rename": "تغییر نام...",
|
||||
"share": "اشتراکگذاری...",
|
||||
"import_email": "وارد کردن .eml/.zip...",
|
||||
"empty_folder": "خالی کردن پوشه",
|
||||
"empty_folder_generic": "خالی کردن پوشه",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nouveau sous-dossier...",
|
||||
"new_folder": "Nouveau dossier...",
|
||||
"rename": "Renommer...",
|
||||
"share": "Partager...",
|
||||
"import_email": "Importer .eml ou .zip...",
|
||||
"empty_folder": "Vider le dossier",
|
||||
"empty_folder_generic": "Vider le dossier",
|
||||
|
||||
@@ -3418,6 +3418,7 @@
|
||||
"new_subfolder": "תת־תיקייה חדשה…",
|
||||
"new_folder": "תיקייה חדשה…",
|
||||
"rename": "שנה שם…",
|
||||
"share": "שיתוף...",
|
||||
"import_email": "ייבא .eml או .zip…",
|
||||
"empty_folder": "תיקייה ריקה",
|
||||
"empty_folder_generic": "תיקייה ריקה",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Új almappa...",
|
||||
"new_folder": "Új mappa...",
|
||||
"rename": "Átnevezés...",
|
||||
"share": "Megosztás...",
|
||||
"import_email": "Importálás .eml vagy .zip fájlból...",
|
||||
"empty_folder": "Mappa ürítése",
|
||||
"empty_folder_generic": "Mappa ürítése",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nuova sottocartella...",
|
||||
"new_folder": "Nuova cartella...",
|
||||
"rename": "Rinomina...",
|
||||
"share": "Condividi...",
|
||||
"import_email": "Importa .eml o .zip...",
|
||||
"empty_folder": "Svuota cartella",
|
||||
"empty_folder_generic": "Svuota cartella",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "新しいサブフォルダー...",
|
||||
"new_folder": "新しいフォルダー...",
|
||||
"rename": "名前を変更...",
|
||||
"share": "共有...",
|
||||
"import_email": ".eml または .zip をインポート...",
|
||||
"empty_folder": "フォルダーを空にする",
|
||||
"empty_folder_generic": "フォルダーを空にする",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "새 하위 폴더...",
|
||||
"new_folder": "새 폴더...",
|
||||
"rename": "이름 바꾸기...",
|
||||
"share": "공유...",
|
||||
"import_email": ".eml 또는 .zip 가져오기...",
|
||||
"empty_folder": "폴더 비우기",
|
||||
"empty_folder_generic": "폴더 비우기",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Jauna apakšmape...",
|
||||
"new_folder": "Jauna mape...",
|
||||
"rename": "Pārsaukt...",
|
||||
"share": "Kopīgot...",
|
||||
"import_email": "Importēt .eml vai .zip...",
|
||||
"empty_folder": "Iztukšot mapi",
|
||||
"empty_folder_generic": "Iztukšot mapi",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Шинэ дэд хавтас...",
|
||||
"new_folder": "Шинэ хавтас...",
|
||||
"rename": "Нэр өөрчлөх...",
|
||||
"share": "Хуваалцах...",
|
||||
"import_email": "Импорт .eml эсвэл .zip...",
|
||||
"empty_folder": "Хоосон хавтас",
|
||||
"empty_folder_generic": "Хоосон хавтас",
|
||||
|
||||
@@ -2205,6 +2205,7 @@
|
||||
"new_subfolder": "Ny undermappe...",
|
||||
"new_folder": "Ny mappe...",
|
||||
"rename": "Gi nytt navn...",
|
||||
"share": "Del...",
|
||||
"import_email": "Importer .eml eller .zip...",
|
||||
"empty_folder": "Tøm mappe",
|
||||
"empty_folder_generic": "Tøm mappe",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nieuwe submap...",
|
||||
"new_folder": "Nieuwe map...",
|
||||
"rename": "Hernoemen...",
|
||||
"share": "Delen...",
|
||||
"import_email": ".eml of .zip importeren...",
|
||||
"empty_folder": "Map leegmaken",
|
||||
"empty_folder_generic": "Map leegmaken",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nowy podfolder...",
|
||||
"new_folder": "Nowy folder...",
|
||||
"rename": "Zmień nazwę...",
|
||||
"share": "Udostępnij...",
|
||||
"import_email": "Importuj .eml lub .zip...",
|
||||
"empty_folder": "Opróżnij folder",
|
||||
"empty_folder_generic": "Opróżnij folder",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nova subpasta...",
|
||||
"new_folder": "Nova pasta...",
|
||||
"rename": "Renomear...",
|
||||
"share": "Partilhar...",
|
||||
"import_email": "Importar .eml ou .zip...",
|
||||
"empty_folder": "Esvaziar pasta",
|
||||
"empty_folder_generic": "Esvaziar pasta",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Subfolder nou...",
|
||||
"new_folder": "Dosar nou...",
|
||||
"rename": "Redenumire...",
|
||||
"share": "Partajează...",
|
||||
"import_email": "Importați fișiere .eml sau .zip...",
|
||||
"empty_folder": "Folder gol",
|
||||
"empty_folder_generic": "Folder gol",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Новая вложенная папка...",
|
||||
"new_folder": "Новая папка...",
|
||||
"rename": "Переименовать...",
|
||||
"share": "Поделиться...",
|
||||
"import_email": "Импортировать .eml или .zip...",
|
||||
"empty_folder": "Очистить папку",
|
||||
"empty_folder_generic": "Очистить папку",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Nový podpriečinok...",
|
||||
"new_folder": "Nový priečinok...",
|
||||
"rename": "Premenovať...",
|
||||
"share": "Zdieľať...",
|
||||
"import_email": "Importovať .eml alebo .zip...",
|
||||
"empty_folder": "Vyprázdniť priečinok",
|
||||
"empty_folder_generic": "Vyprázdniť priečinok",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Yeni alt klasör...",
|
||||
"new_folder": "Yeni klasör...",
|
||||
"rename": "Yeniden adlandır...",
|
||||
"share": "Paylaş...",
|
||||
"import_email": ".eml veya .zip içe aktar...",
|
||||
"empty_folder": "Klasörü boşalt",
|
||||
"empty_folder_generic": "Klasörü boşalt",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "Нова вкладена папка...",
|
||||
"new_folder": "Нова папка...",
|
||||
"rename": "Перейменувати...",
|
||||
"share": "Поділитися...",
|
||||
"import_email": "Імпортувати .eml або .zip...",
|
||||
"empty_folder": "Очистити папку",
|
||||
"empty_folder_generic": "Очистити папку",
|
||||
|
||||
@@ -2200,6 +2200,7 @@
|
||||
"new_subfolder": "新增子信件匣…",
|
||||
"new_folder": "新增信件匣…",
|
||||
"rename": "重新命名…",
|
||||
"share": "共用...",
|
||||
"import_email": "匯入 .eml 或 .zip…",
|
||||
"empty_folder": "清空信件匣",
|
||||
"empty_folder_generic": "清空信件匣",
|
||||
|
||||
@@ -2207,6 +2207,7 @@
|
||||
"new_subfolder": "新建子文件夹...",
|
||||
"new_folder": "新建文件夹...",
|
||||
"rename": "重命名...",
|
||||
"share": "共享...",
|
||||
"import_email": "导入 .eml 或 .zip...",
|
||||
"empty_folder": "清空文件夹",
|
||||
"empty_folder_generic": "清空文件夹",
|
||||
|
||||
Reference in New Issue
Block a user