feat: share mail folders with other users via mail:share

This commit is contained in:
Linus Rath
2026-08-29 17:53:54 +02:00
parent b408b2597a
commit 6e5aca60a9
36 changed files with 494 additions and 16 deletions
@@ -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}
/>
);
}
+3
View File
@@ -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}
/>
+19
View File
@@ -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');
});
});
});
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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[];
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "مجلد فرعي جديد...",
"new_folder": "مجلد جديد...",
"rename": "إعادة تسمية...",
"share": "مشاركة...",
"import_email": "استيراد ملف ‎.eml أو ‎.zip...",
"empty_folder": "إفراغ المجلد",
"empty_folder_generic": "إفراغ المجلد",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "زیرپوشه جدید...",
"new_folder": "پوشه جدید...",
"rename": "تغییر نام...",
"share": "اشتراک‌گذاری...",
"import_email": "وارد کردن .eml/.zip...",
"empty_folder": "خالی کردن پوشه",
"empty_folder_generic": "خالی کردن پوشه",
+1
View File
@@ -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",
+1
View File
@@ -3418,6 +3418,7 @@
"new_subfolder": "תת־תיקייה חדשה…",
"new_folder": "תיקייה חדשה…",
"rename": "שנה שם…",
"share": "שיתוף...",
"import_email": "ייבא .eml או .zip…",
"empty_folder": "תיקייה ריקה",
"empty_folder_generic": "תיקייה ריקה",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "新しいサブフォルダー...",
"new_folder": "新しいフォルダー...",
"rename": "名前を変更...",
"share": "共有...",
"import_email": ".eml または .zip をインポート...",
"empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする",
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "새 하위 폴더...",
"new_folder": "새 폴더...",
"rename": "이름 바꾸기...",
"share": "공유...",
"import_email": ".eml 또는 .zip 가져오기...",
"empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기",
+1
View File
@@ -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",
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "Шинэ дэд хавтас...",
"new_folder": "Шинэ хавтас...",
"rename": "Нэр өөрчлөх...",
"share": "Хуваалцах...",
"import_email": "Импорт .eml эсвэл .zip...",
"empty_folder": "Хоосон хавтас",
"empty_folder_generic": "Хоосон хавтас",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "Новая вложенная папка...",
"new_folder": "Новая папка...",
"rename": "Переименовать...",
"share": "Поделиться...",
"import_email": "Импортировать .eml или .zip...",
"empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "Нова вкладена папка...",
"new_folder": "Нова папка...",
"rename": "Перейменувати...",
"share": "Поділитися...",
"import_email": "Імпортувати .eml або .zip...",
"empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку",
+1
View File
@@ -2200,6 +2200,7 @@
"new_subfolder": "新增子信件匣…",
"new_folder": "新增信件匣…",
"rename": "重新命名…",
"share": "共用...",
"import_email": "匯入 .eml 或 .zip…",
"empty_folder": "清空信件匣",
"empty_folder_generic": "清空信件匣",
+1
View File
@@ -2207,6 +2207,7 @@
"new_subfolder": "新建子文件夹...",
"new_folder": "新建文件夹...",
"rename": "重命名...",
"share": "共享...",
"import_email": "导入 .eml 或 .zip...",
"empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹",