From 6e5aca60a98e0996968f198a04470bfdbc3e019b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:53:54 +0200 Subject: [PATCH] feat: share mail folders with other users via mail:share --- components/layout/mailbox-context-menu.tsx | 16 ++ components/layout/mailbox-share-dialog.tsx | 75 ++++++ components/layout/sidebar.tsx | 3 + components/mail/mail-app.tsx | 19 ++ .../settings/share-collection-dialog.tsx | 39 ++- lib/__tests__/jmap-client-extensions.test.ts | 225 ++++++++++++++++++ lib/jmap/client-interface.ts | 7 +- lib/jmap/client.ts | 69 +++++- lib/jmap/types.ts | 30 ++- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 36 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 components/layout/mailbox-share-dialog.tsx create mode 100644 lib/__tests__/jmap-client-extensions.test.ts diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 557aa83f1..2c9f30cf9 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -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 && ( + handleAction(() => onShareFolder(mailbox.id))} + disabled={!canShare} + testId="mailbox-share" + /> + )} diff --git a/components/layout/mailbox-share-dialog.tsx b/components/layout/mailbox-share-dialog.tsx new file mode 100644 index 000000000..0c7bf8a14 --- /dev/null +++ b/components/layout/mailbox-share-dialog.tsx @@ -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 | 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 ( +
+ + ); + } + + return ( + { + if (!client.setMailboxShare) return; + await client.setMailboxShare(jmapMailboxId, principalId, rights as MailboxRights | null, accountId); + await load(); + }} + onClose={onClose} + /> + ); +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 8ea6eef58..92668a88f 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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} /> diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 2784985ec..09ba53d46 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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(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 = {}) { )} + {client && sharingMailbox && ( + setSharingMailboxId(null)} + /> + )}
diff --git a/components/settings/share-collection-dialog.tsx b/components/settings/share-collection-dialog.tsx index c11e1c253..a756fb157 100644 --- a/components/settings/share-collection-dialog.tsx +++ b/components/settings/share-collection-dialog.tsx @@ -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, 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, 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, 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, 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); } diff --git a/lib/__tests__/jmap-client-extensions.test.ts b/lib/__tests__/jmap-client-extensions.test.ts new file mode 100644 index 000000000..89a7b1377 --- /dev/null +++ b/lib/__tests__/jmap-client-extensions.test.ts @@ -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 = {}) { + 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][] }; + +describe('JMAP client extensions', () => { + let fetchSpy: ReturnType; + let client: JMAPClient; + let requests: RequestBody[]; + + async function setup(accountCapabilities: Record = {}) { + 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: 'Invoice', preview: 'your invoice 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: 'Invoice', preview: 'your invoice 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'); + }); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index a17db45aa..e236c476d 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -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; updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise; deleteMailbox(mailboxId: string, accountId?: string): Promise; + // 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 | null>; + setMailboxShare?(mailboxId: string, principalId: string, rights: MailboxRights | null, accountId?: string): Promise; // ── Emails ──────────────────────────────────────────────────── // `pinnedFirst` sorts emails carrying the $pinned keyword to the top diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index e6c216a5f..9afcc5310 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 | 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 | 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 { + 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; notUpdated?: Record }; + 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, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index f9a944510..626c81318 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -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 | 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[]; diff --git a/locales/ar/common.json b/locales/ar/common.json index 876e19a30..dcdb0e387 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "مجلد فرعي جديد...", "new_folder": "مجلد جديد...", "rename": "إعادة تسمية...", + "share": "مشاركة...", "import_email": "استيراد ملف ‎.eml أو ‎.zip...", "empty_folder": "إفراغ المجلد", "empty_folder_generic": "إفراغ المجلد", diff --git a/locales/ca/common.json b/locales/ca/common.json index 42b8da91d..54639592c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index b473debb1..99fb0e926 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index 4126c75ce..cf82d6a3a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 931a0d555..d1876a53e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 72c55b54d..6ad3c39f1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index cd5f6a325..38c87abe9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 36b3698ec..148b21dc5 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "زیرپوشه جدید...", "new_folder": "پوشه جدید...", "rename": "تغییر نام...", + "share": "اشتراک‌گذاری...", "import_email": "وارد کردن .eml/.zip...", "empty_folder": "خالی کردن پوشه", "empty_folder_generic": "خالی کردن پوشه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 12d8ef9fc..e8ca34cda 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 211bdaa12..aebe7c064 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3418,6 +3418,7 @@ "new_subfolder": "תת־תיקייה חדשה…", "new_folder": "תיקייה חדשה…", "rename": "שנה שם…", + "share": "שיתוף...", "import_email": "ייבא .eml או .zip…", "empty_folder": "תיקייה ריקה", "empty_folder_generic": "תיקייה ריקה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 9d86a88f3..807190584 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 6cab47dee..a709c80db 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 25f2fb731..ca30f3a48 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "新しいサブフォルダー...", "new_folder": "新しいフォルダー...", "rename": "名前を変更...", + "share": "共有...", "import_email": ".eml または .zip をインポート...", "empty_folder": "フォルダーを空にする", "empty_folder_generic": "フォルダーを空にする", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9a1b96920..b923683d8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "새 하위 폴더...", "new_folder": "새 폴더...", "rename": "이름 바꾸기...", + "share": "공유...", "import_email": ".eml 또는 .zip 가져오기...", "empty_folder": "폴더 비우기", "empty_folder_generic": "폴더 비우기", diff --git a/locales/lv/common.json b/locales/lv/common.json index e4fba1672..deb99ebd2 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index bed694034..c85d54edf 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "Шинэ дэд хавтас...", "new_folder": "Шинэ хавтас...", "rename": "Нэр өөрчлөх...", + "share": "Хуваалцах...", "import_email": "Импорт .eml эсвэл .zip...", "empty_folder": "Хоосон хавтас", "empty_folder_generic": "Хоосон хавтас", diff --git a/locales/nb/common.json b/locales/nb/common.json index 20c89ee48..a6d0c9b2b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3b715a709..8a9cdb8a8 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 18fc48482..1574d5fc9 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index fe22f850a..afe25e022 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 84217d99e..e3dc0ba35 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 42ac6e153..6006e2e0c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "Новая вложенная папка...", "new_folder": "Новая папка...", "rename": "Переименовать...", + "share": "Поделиться...", "import_email": "Импортировать .eml или .zip...", "empty_folder": "Очистить папку", "empty_folder_generic": "Очистить папку", diff --git a/locales/sk/common.json b/locales/sk/common.json index 803160dcc..8a010ccf8 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index c7db30fe9..6a73258be 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 10633d9cb..43ad872d9 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "Нова вкладена папка...", "new_folder": "Нова папка...", "rename": "Перейменувати...", + "share": "Поділитися...", "import_email": "Імпортувати .eml або .zip...", "empty_folder": "Очистити папку", "empty_folder_generic": "Очистити папку", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index eaa223438..ece8e243d 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2200,6 +2200,7 @@ "new_subfolder": "新增子信件匣…", "new_folder": "新增信件匣…", "rename": "重新命名…", + "share": "共用...", "import_email": "匯入 .eml 或 .zip…", "empty_folder": "清空信件匣", "empty_folder_generic": "清空信件匣", diff --git a/locales/zh/common.json b/locales/zh/common.json index e8363bea9..5a08fcc1f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2207,6 +2207,7 @@ "new_subfolder": "新建子文件夹...", "new_folder": "新建文件夹...", "rename": "重命名...", + "share": "共享...", "import_email": "导入 .eml 或 .zip...", "empty_folder": "清空文件夹", "empty_folder_generic": "清空文件夹",