From 113e96bdec10100dd982779fa7a9f561121f1041 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:23:28 +0100 Subject: [PATCH 1/3] feat: recover mail deleted for good from the server's archive (bulwarkmail/webmail#1319) --- src/api/__tests__/archived-items.test.ts | 142 ++++++++ src/api/__tests__/live-stalwart-mail.test.ts | 80 +++++ src/api/archived-items.ts | 155 ++++++++ .../settings/RecoverDeletedSettings.tsx | 337 ++++++++++++++++++ .../settings-search-recovery.test.ts | 19 + .../recovery/__tests__/archived-items.test.ts | 212 +++++++++++ src/lib/recovery/__tests__/fixtures.ts | 133 +++++++ .../recovery/__tests__/recovery-store.test.ts | 121 +++++++ src/lib/recovery/archived-items.ts | 308 ++++++++++++++++ src/lib/recovery/policy.ts | 17 + src/lib/recovery/recovery-store.ts | 131 +++++++ .../recovery/use-deleted-items-recovery.ts | 62 ++++ src/lib/settings-search.ts | 5 +- src/screens/SettingsScreen.tsx | 29 +- 14 files changed, 1745 insertions(+), 6 deletions(-) create mode 100644 src/api/__tests__/archived-items.test.ts create mode 100644 src/api/archived-items.ts create mode 100644 src/components/settings/RecoverDeletedSettings.tsx create mode 100644 src/lib/__tests__/settings-search-recovery.test.ts create mode 100644 src/lib/recovery/__tests__/archived-items.test.ts create mode 100644 src/lib/recovery/__tests__/fixtures.ts create mode 100644 src/lib/recovery/__tests__/recovery-store.test.ts create mode 100644 src/lib/recovery/archived-items.ts create mode 100644 src/lib/recovery/policy.ts create mode 100644 src/lib/recovery/recovery-store.ts create mode 100644 src/lib/recovery/use-deleted-items-recovery.ts diff --git a/src/api/__tests__/archived-items.test.ts b/src/api/__tests__/archived-items.test.ts new file mode 100644 index 00000000..67368a6b --- /dev/null +++ b/src/api/__tests__/archived-items.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'ci', + request: vi.fn(), + currentSession: null as unknown, + getMaxObjectsInGet: () => 500, + getMaxObjectsInSet: () => 500, + getMaxCallsInRequest: () => 16, + }, +})); + +import { jmapClient } from '../jmap-client'; +import { jmapErrorType } from '../jmap-result'; +import { + LOGIN_SWITCHED, + advertisesArchivedItems, + hasArchivedEmail, + listArchivedEmails, + queryArchivedItemIds, + resetArchiveFilterMemory, + restoreArchivedItems, +} from '../archived-items'; +import { ACCOUNT, LICENSE_REFUSAL, archivedEmail, bigArchive, fakeArchiveServer } from '../../lib/recovery/__tests__/fixtures'; + +// Ported from the webmail's lib/__tests__/jmap-archived-items.test.ts. + +type Call = [string, Record, string]; +const mockRequest = jmapClient.request as ReturnType; + +function setSession(stalwart: boolean) { + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} }, + accounts: { + [ACCOUNT]: { + name: 'org-alice@example.org', isPersonal: true, isReadOnly: false, + accountCapabilities: stalwart + ? { 'urn:ietf:params:jmap:mail': {}, 'urn:stalwart:jmap': {} } + : { 'urn:ietf:params:jmap:mail': {} }, + }, + }, + }; +} + +function serve(answer: (body: { methodCalls: Call[] }) => unknown) { + mockRequest.mockImplementation(async (methodCalls: Call[]) => answer({ methodCalls })); +} + +const sent = () => mockRequest.mock.calls.map(([calls, using]) => ({ calls: calls as Call[], using: using as string[] })); +const queries = () => sent().flatMap((b) => b.calls).filter(([m]) => m === 'x:ArchivedItem/query'); + +beforeEach(() => { + vi.clearAllMocks(); + resetArchiveFilterMemory(); + setSession(true); +}); + +describe('archived items API', () => { + it('advertises them only where the account lists urn:stalwart:jmap', () => { + expect(advertisesArchivedItems()).toBe(true); + setSession(false); + expect(advertisesArchivedItems()).toBe(false); + // Session-level only (never how Stalwart sends it) doesn't count either. + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:stalwart:jmap': {} }, + accounts: { [ACCOUNT]: { name: 'a', isPersonal: true, isReadOnly: false, accountCapabilities: {} } }, + }; + expect(advertisesArchivedItems()).toBe(false); + }); + + it('asks the account filter first and, once refused, never again for that account', async () => { + serve(fakeArchiveServer([archivedEmail('a')]).answer); + await expect(queryArchivedItemIds(ACCOUNT, { limit: 1 })).resolves.toEqual({ ids: ['a'], total: null }); + await queryArchivedItemIds(ACCOUNT, { limit: 1 }); + expect(queries().map(([, args]) => args)).toEqual([ + { accountId: ACCOUNT, limit: 1, filter: { accountId: ACCOUNT } }, + { accountId: ACCOUNT, limit: 1 }, + { accountId: ACCOUNT, limit: 1 }, + ]); + expect(sent()[0].using).toEqual(['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']); + }); + + it("keeps an admin's answer to the account through the filter, and drops other accounts' items anyway", async () => { + serve(fakeArchiveServer([ + archivedEmail('i3'), + archivedEmail('i2', { accountId: 'cz' }), + archivedEmail('i1', { accountId: undefined }), + ], { impersonate: true }).answer); + const result = await listArchivedEmails(ACCOUNT, { days: 0 }); + expect(result.items.map((i) => i.id)).toEqual(['i3']); + expect(queries().every(([, args]) => (args.filter as { accountId?: string })?.accountId === ACCOUNT)).toBe(true); + }); + + it('throws the refusal with its type when the server has no license', async () => { + serve(() => ({ methodResponses: [['error', LICENSE_REFUSAL, 'q']] })); + const error = await queryArchivedItemIds(ACCOUNT).catch((e: unknown) => e); + expect(jmapErrorType(error)).toBe('forbidden'); + }); + + it('pages with calculateTotal, so the newest of more than 5000 items comes first', async () => { + serve(fakeArchiveServer(bigArchive(6000, Date.now())).answer); + const first = await listArchivedEmails(ACCOUNT, { days: 0 }); + expect(first.items[0].id).toBe('i006000'); + expect(first.nextPosition).toBe(2000); + const pages = queries().filter(([, args]) => args.calculateTotal && !args.filter); + expect(pages.map(([, args]) => [args.position ?? 0, args.limit])).toEqual([[0, 500], [500, 500], [1000, 500], [1500, 500]]); + const gets = sent().flatMap((b) => b.calls).filter(([m]) => m === 'x:ArchivedItem/get'); + expect(gets.every(([, args]) => (args.ids as string[]).length <= 500 && args.accountId === ACCOUNT)).toBe(true); + const older = await listArchivedEmails(ACCOUNT, { days: 0, startPosition: 2000 }); + expect(older.items[0].id).toBe('i004000'); + }); + + it('checks for the Trash link with one-item pages and stops at the first message', async () => { + serve(fakeArchiveServer(bigArchive(30, Date.now())).answer); + await expect(hasArchivedEmail(ACCOUNT, { days: 7 })).resolves.toBe(true); + expect(queries().filter(([, args]) => args.calculateTotal && !args.filter).map(([, args]) => args.limit)).toEqual([1]); + }); + + it('restores with requestRestore in the account it was asked for and reports notFound per item', async () => { + const server = fakeArchiveServer([archivedEmail('a')]); + serve(server.answer); + const results = await restoreArchivedItems(['a', 'b'], ACCOUNT); + expect(results).toEqual([{ id: 'a', ok: true }, { id: 'b', ok: false, error: 'notFound', type: 'notFound' }]); + expect(sent()[0].calls).toEqual([ + ['x:ArchivedItem/set', { accountId: ACCOUNT, update: { a: { status: 'requestRestore' }, b: { status: 'requestRestore' } } }, 'r0'], + ]); + expect(server.remaining()).toEqual([]); + }); + + it('fails every item of a request that fails instead of throwing', async () => { + mockRequest.mockRejectedValue(new Error('offline')); + await expect(restoreArchivedItems(['a'], ACCOUNT)).resolves.toEqual([{ id: 'a', ok: false, error: 'offline' }]); + }); + + it('sends nothing once the login it was opened in is no longer active', async () => { + serve(fakeArchiveServer([archivedEmail('a')]).answer); + const results = await restoreArchivedItems(['a'], ACCOUNT, () => false); + expect(results).toEqual([{ id: 'a', ok: false, error: LOGIN_SWITCHED }]); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index 2c7c805d..80e8f9af 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -461,3 +461,83 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => { }, 60_000); }); }); + +// Recover deleted items (#1319): x:ArchivedItem. Stalwart keeps mail deleted +// for good only with an Enterprise license and `archiveDeletedItemsFor` set +// for the account; without them the case says why and skips. +live('live Stalwart: recover deleted items (x:ArchivedItem)', () => { + const tag = `bulwark-archive-${RUN}`; + let scratch = ''; + + beforeAll(async () => { + const mod = await import('../jmap-client'); + singleton = mod.jmapClient as unknown as JMAPClient; + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + await useLogin(client); + scratch = await createMailbox({ name: `live-archive-${RUN}` }); + }, 60_000); + + afterAll(async () => { + await useLogin(client); + const leftovers = await messagesWith(tag).catch(() => [] as string[]); + if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined); + if (scratch) { + await emptyMailbox(scratch).catch(() => undefined); + await deleteMailbox(scratch).catch(() => undefined); + } + }, 60_000); + + it('lists mail deleted for good and puts it back in the Inbox, or reports the feature unsupported', async (ctx) => { + const { advertisesArchivedItems, listArchivedEmails, queryArchivedItemIds, restoreArchivedItems } = await import('../archived-items'); + const { classifyArchiveError } = await import('../../lib/recovery/archived-items'); + const accountId = client.accountId; + + // What the detection store would decide. + let probe: 'supported' | 'unsupported' | 'error' = 'unsupported'; + let refusal = ''; + if (advertisesArchivedItems(accountId)) { + try { + await queryArchivedItemIds(accountId, { limit: 1 }); + probe = 'supported'; + } catch (err) { + probe = classifyArchiveError(err); + refusal = err instanceof Error ? err.message : String(err); + } + } + if (!is10()) { + // 0.16: Settings hides the page. + expect(probe).toBe('unsupported'); + return; + } + expect(advertisesArchivedItems(accountId)).toBe(true); + expect(probe).not.toBe('error'); + if (probe === 'unsupported') { + ctx.skip(`x:ArchivedItem refused on this 1.0 server (${refusal}): it needs an Enterprise license`); + return; + } + + const emailId = await importMessage(scratch, `${tag} restore me`, 'Deleted for good, then restored.'); + await destroyEmails([emailId]); + + const findArchived = async () => (await listArchivedEmails(accountId, { days: 1 })).items.find((i) => i.subject.includes(tag)); + const archived = await eventually(findArchived, (item) => !!item, 20_000); + if (!archived) { + ctx.skip('Nothing was archived: the account needs "archiveDeletedItemsFor" (Data retention > Archive deleted items) set on the server'); + return; + } + expect(archived.accountId).toBe(accountId); + expect(archived.fromEmail).toBe('partner@example.net'); + + const results = await restoreArchivedItems([archived.id], accountId); + expect(results).toEqual([{ id: archived.id, ok: true }]); + + // The server restores in the background, into the Inbox. + const inbox = (await getMailboxes()).find((b) => b.role === 'inbox')!.id; + const back = await eventually(() => messagesWith(tag), (ids) => ids.length > 0, 30_000); + expect(back).toHaveLength(1); + const [restored] = await getFullEmails([back[0]]); + expect(Object.keys(restored.mailboxIds ?? {})).toContain(inbox); + expect(await findArchived()).toBeUndefined(); + }, 120_000); +}); diff --git a/src/api/archived-items.ts b/src/api/archived-items.ts new file mode 100644 index 00000000..4267727c --- /dev/null +++ b/src/api/archived-items.ts @@ -0,0 +1,155 @@ +// "Recover deleted items" (webmail JMAPClient.queryArchivedItemIds and +// friends, #1319): Stalwart's x:ArchivedItem methods. The pure parts are in +// lib/recovery/archived-items. +// +// Every call names the account it is for. The page passes `stillSameLogin`, +// which is asked right before each write: a switch of logins while a restore +// runs must not send the rest of it to the other login's server. + +import { jmapClient } from './jmap-client'; +import { JMAPMethodError, batched } from './jmap-result'; +import type { JMAPMethodCall } from './types'; +import { + ARCHIVED_ITEM_USING, + STALWART_CAPABILITY, + buildRestoreCalls, + hasArchivedEmail as peekArchive, + listArchivedEmailsPaged, + readRestoreResults, + type ArchiveCall, + type ArchiveIo, + type ArchiveListResult, + type ArchiveQueryPage, + type RestoreResult, +} from '../lib/recovery/archived-items'; + +function methodError(result: unknown, fallback: string): JMAPMethodError { + const error = result as { type?: string; description?: string } | undefined; + return new JMAPMethodError(error?.type ?? 'serverFail', error?.description || fallback); +} + +/** + * Whether the account lists Stalwart's own capability. Stalwart puts it in + * the account's accountCapabilities only, never at session level, so this + * reads the account (webmail hasAccountCapability). + */ +export function advertisesArchivedItems(accountId: string = jmapClient.accountId): boolean { + const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities; + return !!caps && STALWART_CAPABILITY in caps; +} + +/** Accounts whose server refused the `accountId` filter (every user without Impersonate). */ +const filterRefused = new Set(); + +/** For tests: forget which accounts refused the filter. */ +export function resetArchiveFilterMemory(): void { + filterRefused.clear(); +} + +/** + * One page of the account's archived item ids, newest first. A request of + * its own: without an Enterprise license Stalwart refuses it (`forbidden`), + * and that must not take other calls down with it. The `accountId` filter + * keeps an admin's answer to this account; a normal user is refused it + * (`unsupportedFilter`) and asked again without it, once per account. + */ +export async function queryArchivedItemIds( + accountId: string = jmapClient.accountId, + options: { position?: number; limit?: number; calculateTotal?: boolean } = {}, +): Promise { + const args: Record = { accountId }; + if (options.position) args.position = options.position; + if (options.limit) args.limit = options.limit; + if (options.calculateTotal) args.calculateTotal = true; + const ask = async (filtered: boolean) => { + const res = await jmapClient.request( + [['x:ArchivedItem/query', filtered ? { ...args, filter: { accountId } } : args, 'q']], + ARCHIVED_ITEM_USING, + ); + return res.methodResponses?.[0] ?? []; + }; + let [method, result] = await ask(!filterRefused.has(accountId)); + if (method === 'error' && (result as { type?: string } | undefined)?.type === 'unsupportedFilter' && !filterRefused.has(accountId)) { + filterRefused.add(accountId); + [method, result] = await ask(false); + } + if (method !== 'x:ArchivedItem/query') throw methodError(result, 'Failed to list deleted items'); + const { ids, total } = result as { ids?: unknown; total?: unknown }; + return { + ids: Array.isArray(ids) ? ids.filter((v): v is string => typeof v === 'string') : [], + total: typeof total === 'number' ? total : null, + }; +} + +function archiveIo(accountId: string): ArchiveIo { + return { + query: (position, limit) => queryArchivedItemIds(accountId, { position, limit, calculateTotal: true }), + get: async (ids) => { + const calls: JMAPMethodCall[] = batched(ids, jmapClient.getMaxObjectsInGet()) + .map((batch, i): JMAPMethodCall => ['x:ArchivedItem/get', { accountId, ids: batch }, `g${i}`]); + const list: unknown[] = []; + for (const group of batched(calls, jmapClient.getMaxCallsInRequest())) { + const res = await jmapClient.request(group, ARCHIVED_ITEM_USING); + for (const [method, result] of res.methodResponses ?? []) { + if (method !== 'x:ArchivedItem/get') throw methodError(result, 'Failed to read deleted items'); + const items = (result as { list?: unknown }).list; + if (Array.isArray(items)) list.push(...items); + } + } + return list; + }, + }; +} + +/** The account's deleted messages inside the days window, newest first, in pages. */ +export async function listArchivedEmails( + accountId: string = jmapClient.accountId, + options: { days?: number; startPosition?: number } = {}, +): Promise { + return listArchivedEmailsPaged(archiveIo(accountId), accountId, { + days: options.days ?? 0, + startPosition: options.startPosition, + }); +} + +/** Whether at least one deleted message of the account is inside the days window. */ +export async function hasArchivedEmail( + accountId: string = jmapClient.accountId, + options: { days?: number } = {}, +): Promise { + return peekArchive(archiveIo(accountId), accountId, { days: options.days ?? 0 }); +} + +/** The reason a restore was not sent: another login is active now. */ +export const LOGIN_SWITCHED = 'The account was switched'; + +/** + * Asks the server to put `ids` back. Never throws: a request that fails, or + * one not sent because the login changed, marks its items failed. + */ +export async function restoreArchivedItems( + ids: string[], + accountId: string = jmapClient.accountId, + stillSameLogin: () => boolean = () => true, +): Promise { + const calls = buildRestoreCalls(ids, accountId, jmapClient.getMaxObjectsInSet()); + const results: RestoreResult[] = []; + const failGroup = (group: ArchiveCall[], reason: string) => { + for (const [, args] of group) { + results.push(...Object.keys(args.update as Record).map((itemId) => ({ id: itemId, ok: false, error: reason }))); + } + }; + for (const group of batched(calls, jmapClient.getMaxCallsInRequest())) { + if (!stillSameLogin()) { + failGroup(group, LOGIN_SWITCHED); + continue; + } + try { + const res = await jmapClient.request(group, ARCHIVED_ITEM_USING); + results.push(...readRestoreResults(group, (res.methodResponses ?? []) as ArchiveCall[])); + } catch (error) { + failGroup(group, error instanceof Error ? error.message : String(error)); + } + } + return results; +} diff --git a/src/components/settings/RecoverDeletedSettings.tsx b/src/components/settings/RecoverDeletedSettings.tsx new file mode 100644 index 00000000..62a12d4b --- /dev/null +++ b/src/components/settings/RecoverDeletedSettings.tsx @@ -0,0 +1,337 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import { CheckSquare, RefreshCw, Square, Undo2 } from '../icons'; +import Button from '../Button'; +import { SettingsSection } from './settings-section'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useSettingsStore } from '../../stores/settings-store'; +import { toast } from '../../stores/toast-store'; +import { jmapClient } from '../../api/jmap-client'; +import { listArchivedEmails, restoreArchivedItems } from '../../api/archived-items'; +import { servedLoginOf } from '../../lib/draft-login'; +import { loginMovedFrom } from '../../lib/compose-login'; +import { formatListDate } from '../../lib/date-format'; +import { + classifyArchiveError, + restoreOutcome, + visibleArchivedItems, + type ArchivedEmail, +} from '../../lib/recovery/archived-items'; +import { recoveryPolicy } from '../../lib/recovery/policy'; +import { useRecoveryStore } from '../../lib/recovery/recovery-store'; +import { useDeletedItemsRecovery } from '../../lib/recovery/use-deleted-items-recovery'; + +// Ported from the webmail's components/settings/recover-deleted-settings.tsx +// (#1319): the messages the user deleted for good that Stalwart still keeps +// (Enterprise, `archiveDeletedItemsFor`), newest first, read in pages with +// "Show older" past 2000. Restoring asks the server to put them back in the +// Inbox; it does that in the background, and the mail list picks them up +// from the push that follows. The admin's day limit comes from +// lib/recovery/policy (no limit until the app has the org policy). + +type ListState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'ready'; items: ArchivedEmail[]; nextPosition: number | null; loadingOlder: boolean }; + +interface Outcome { + restored: number; + failed: number; + /** No longer in the archive: restored elsewhere, or the server stopped keeping it. */ + gone: number; +} + +const NO_OUTCOME: Outcome = { restored: 0, failed: 0, gone: 0 }; + +export function RecoverDeletedSettings() { + const c = useColors(); + const styles = useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const locale = useLocaleStore((s) => s.locale); + const timeFormat = useSettingsStore((s) => s.timeFormat); + const { available, accountId, key } = useDeletedItemsRecovery(); + const { days } = recoveryPolicy(); + const record = useRecoveryStore((s) => s.record); + const forgetRecent = useRecoveryStore((s) => s.forgetRecent); + + const [list, setList] = useState({ status: 'loading' }); + const [selected, setSelected] = useState>(() => new Set()); + const [restoring, setRestoring] = useState(false); + const [outcome, setOutcome] = useState(NO_OUTCOME); + + // The account the page shows now: answers for another one are dropped. + const keyRef = useRef(key); + useEffect(() => { + keyRef.current = key; + }, [key]); + // The login the list was read in: a restore is sent only while it is active. + const ownerRef = useRef(null); + // An answer that arrives after a newer read started is dropped too. + const readSeq = useRef(0); + + const refused = useCallback((error: unknown, forKey: string) => { + // A refusal now (the license ran out) hides the page like detection would. + if (classifyArchiveError(error) === 'unsupported') record(forKey, 'unsupported'); + }, [record]); + + const load = useCallback(async () => { + if (!accountId || !key) return; + const seq = ++readSeq.current; + ownerRef.current = servedLoginOf(jmapClient); + setList({ status: 'loading' }); + setSelected(new Set()); + setOutcome(NO_OUTCOME); + try { + const result = await listArchivedEmails(accountId, { days }); + if (seq !== readSeq.current) return; + setList({ status: 'ready', items: result.items, nextPosition: result.nextPosition, loadingOlder: false }); + } catch (error) { + if (seq !== readSeq.current) return; + refused(error, key); + setList({ status: 'error' }); + } + }, [accountId, key, days, refused]); + + useEffect(() => { + if (!available) return undefined; + void load(); + const reads = readSeq; + return () => { + reads.current++; + }; + }, [available, load]); + + const items = useMemo(() => (list.status === 'ready' ? visibleArchivedItems(list.items, days) : []), [list, days]); + + if (!available || !accountId || !key) return null; + + const showOlder = async () => { + if (list.status !== 'ready' || list.nextPosition === null || list.loadingOlder) return; + const seq = readSeq.current; + const start = list.nextPosition; + setList({ ...list, loadingOlder: true }); + try { + const result = await listArchivedEmails(accountId, { days, startPosition: start }); + if (seq !== readSeq.current) return; + setList((prev) => { + if (prev.status !== 'ready') return prev; + const known = new Set(prev.items.map((item) => item.id)); + return { + status: 'ready', + items: [...prev.items, ...result.items.filter((item) => !known.has(item.id))], + nextPosition: result.nextPosition, + loadingOlder: false, + }; + }); + } catch (error) { + if (seq !== readSeq.current) return; + refused(error, key); + setList((prev) => (prev.status === 'ready' ? { ...prev, loadingOlder: false } : prev)); + toast.error(t('recover_deleted.load_failed', "Deleted mail couldn't be loaded.")); + } + }; + + const allSelected = items.length > 0 && items.every((item) => selected.has(item.id)); + const toggle = (id: string) => setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const toggleAll = () => setSelected(allSelected ? new Set() : new Set(items.map((item) => item.id))); + const nothingSelected = !items.some((item) => selected.has(item.id)); + + const restore = async () => { + const ids = items.filter((item) => selected.has(item.id)).map((item) => item.id); + if (restoring || ids.length === 0) return; + const forKey = key; + const seq = readSeq.current; + const owner = ownerRef.current; + setRestoring(true); + try { + const results = await restoreArchivedItems(ids, accountId, () => !loginMovedFrom(owner)); + forgetRecent(forKey); + const { done, gone, failed, refused: unlicensed } = restoreOutcome(ids, results); + if (unlicensed) record(forKey, 'unsupported'); + // Another account (or a reload of the list) since: none of this applies to what is shown now. + if (keyRef.current !== forKey || seq !== readSeq.current) return; + setOutcome({ restored: done.size, failed: failed.length, gone: gone.size }); + // Restored and vanished items leave the list; the ones that failed stay selected to try again. + setList((prev) => (prev.status === 'ready' + ? { ...prev, items: prev.items.filter((item) => !done.has(item.id) && !gone.has(item.id)) } + : prev)); + setSelected(new Set(failed)); + if (done.size > 0) { + toast.success(t('recover_deleted.restored', '{count, plural, one {# message is} other {# messages are}} on the way back to your Inbox.', { count: done.size })); + } + if (failed.length > 0) { + toast.error(t('recover_deleted.restore_partial', "{count} messages couldn't be restored. They stay selected, so you can try again.", { count: failed.length })); + } + } finally { + setRestoring(false); + } + }; + + const dateOf = (iso: string | null) => (iso ? formatListDate(iso, { dateFormat: 'full', timeFormat, locale, t }) : ''); + const outcomeLines = [ + outcome.restored > 0 + ? t('recover_deleted.restored', '{count, plural, one {# message is} other {# messages are}} on the way back to your Inbox.', { count: outcome.restored }) + : null, + outcome.gone > 0 + ? t('recover_deleted.restore_gone', '{count} messages were no longer in the archive: they were restored elsewhere or the server stopped keeping them.', { count: outcome.gone }) + : null, + ].filter((line): line is string => !!line); + + return ( + + + {days > 0 + ? t('recover_deleted.range_days', 'Showing mail deleted in the last {days} days.', { days }) + : t('recover_deleted.range_all', 'Showing all deleted mail the server still keeps.')} + + + {list.status === 'loading' && ( + + + {t('recover_deleted.loading', 'Loading deleted mail…')} + + )} + + {list.status === 'error' && ( + + {t('recover_deleted.load_failed', "Deleted mail couldn't be loaded.")} + + + )} + + {list.status === 'ready' && ( + + {/* Always there, so what a restore did is read out. */} + + {outcomeLines.map((line) => {line})} + {outcome.failed > 0 && ( + + {t('recover_deleted.restore_partial', "{count} messages couldn't be restored. They stay selected, so you can try again.", { count: outcome.failed })} + + )} + + + {items.length === 0 ? ( + + {t('recover_deleted.empty', 'There is no deleted mail to recover.')} + + {t('recover_deleted.empty_hint', 'Deleted mail only shows up here if your administrator turned on archiving of deleted items in Stalwart.')} + + + ) : ( + <> + + + {allSelected ? : } + + {selected.size > 0 + ? t('recover_deleted.selected', '{count} selected', { count: selected.size }) + : t('recover_deleted.select_all', 'Select all')} + + + + + + + + + {items.map((item, index) => { + const sender = item.fromName || item.fromEmail || t('recover_deleted.unknown_sender', 'Unknown sender'); + const checked = selected.has(item.id); + const subject = item.subject || t('recover_deleted.no_subject', '(no subject)'); + const deleted = t('recover_deleted.deleted_on', 'Deleted {date}', { date: dateOf(item.archivedAt) }); + return ( + toggle(item.id)} + disabled={restoring} + accessibilityRole="checkbox" + accessibilityState={{ checked, disabled: restoring }} + accessibilityLabel={[sender, subject, dateOf(item.receivedAt), deleted].filter(Boolean).join(', ')} + style={({ pressed }) => [styles.row, index > 0 && styles.rowBorder, pressed && styles.rowPressed]} + > + {checked ? : } + + + {sender} + {item.receivedAt ? {dateOf(item.receivedAt)} : null} + + {subject} + {deleted} + + + ); + })} + + + {t('recover_deleted.restore_hint', "Restored mail goes back to your Inbox as unread, sorted by its original date, so it may be far down the list. The server restores it in the background. If that fails there, the message is lost and Bulwark can't tell.")} + + + )} + + {list.nextPosition !== null && ( + + )} + + )} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + block: { gap: spacing.sm, marginTop: spacing.sm }, + inline: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm }, + muted: { ...typography.body, color: c.textMuted }, + small: { fontSize: 12, lineHeight: 16, color: c.textMuted }, + error: { ...typography.body, color: c.error }, + body: { ...typography.body, color: c.text }, + toolbar: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm }, + selectAll: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minHeight: 44, paddingRight: spacing.sm }, + toolbarButtons: { flexDirection: 'row', gap: spacing.sm, marginLeft: 'auto' }, + list: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, paddingHorizontal: spacing.md, paddingVertical: spacing.md }, + rowBorder: { borderTopWidth: 1, borderTopColor: c.border }, + rowPressed: { backgroundColor: c.muted }, + rowText: { flex: 1, minWidth: 0, gap: 2 }, + rowTop: { flexDirection: 'row', alignItems: 'baseline', gap: spacing.sm }, + sender: { ...typography.bodyMedium, color: c.text, flex: 1 }, + olderButton: { alignSelf: 'flex-start' }, + }); +} diff --git a/src/lib/__tests__/settings-search-recovery.test.ts b/src/lib/__tests__/settings-search-recovery.test.ts new file mode 100644 index 00000000..398acf29 --- /dev/null +++ b/src/lib/__tests__/settings-search-recovery.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { getDictionary, translate } from '../../i18n'; +import { buildSettingsSearchIndex, normalizeSettingsQuery, tabMatchesQuery } from '../settings-search'; + +// Settings search finds the Stalwart 1.0 pages (webmail lib/settings-search) +// by the words people use for them. + +const en = getDictionary('en'); +const tEn = (key: string) => translate('en', key); +const index = buildSettingsSearchIndex(en, tEn); +const finds = (tab: 'recovery', label: string, q: string) => tabMatchesQuery(index, tab, label, normalizeSettingsQuery(q)); + +describe('settings search for Recover deleted items', () => { + it('finds Recover deleted items', () => { + expect(finds('recovery', 'Recover deleted items', 'undelete')).toBe(true); + expect(finds('recovery', 'Recover deleted items', 'emptied')).toBe(true); + expect(finds('recovery', 'Recover deleted items', 'colleague')).toBe(false); + }); +}); diff --git a/src/lib/recovery/__tests__/archived-items.test.ts b/src/lib/recovery/__tests__/archived-items.test.ts new file mode 100644 index 00000000..769be4cb --- /dev/null +++ b/src/lib/recovery/__tests__/archived-items.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; +import { + ARCHIVE_LIST_CAP, + buildRestoreCalls, + classifyArchiveError, + hasArchivedEmail, + listArchivedEmailsPaged, + parseArchivedFrom, + parseArchivedItems, + readRestoreResults, + restoreOutcome, + visibleArchivedItems, + type ArchiveIo, +} from '../archived-items'; +import { JMAPMethodError } from '../../../api/jmap-result'; +import { ACCOUNT, ARCHIVED_FILE, LICENSE_REFUSAL, archivedEmail, bigArchive, fakeArchiveServer } from './fixtures'; + +const NOW = Date.parse('2026-10-28T12:00:00Z'); + +/** An ArchiveIo over the derived server, counting what it is asked. */ +function ioFor(items: Array>) { + const server = fakeArchiveServer(items); + const asked = { queries: 0, gets: 0 }; + const io: ArchiveIo = { + query: async (position, limit) => { + asked.queries++; + const [[, result]] = server.answer({ methodCalls: [['x:ArchivedItem/query', { accountId: ACCOUNT, position, limit, calculateTotal: true }, 'q']] }).methodResponses; + return { ids: result.ids as string[], total: result.total as number }; + }, + get: async (ids) => { + asked.gets++; + const [[, result]] = server.answer({ methodCalls: [['x:ArchivedItem/get', { accountId: ACCOUNT, ids }, 'g']] }).methodResponses; + return result.list as unknown[]; + }, + }; + return { io, asked }; +} + +describe('parseArchivedFrom', () => { + it('reads the three forms the server stores', () => { + expect(parseArchivedFrom('Org Bob ')).toEqual({ name: 'Org Bob', email: 'org-bob@example.org' }); + expect(parseArchivedFrom('org-bob@example.org')).toEqual({ name: '', email: 'org-bob@example.org' }); + expect(parseArchivedFrom('Org Bob')).toEqual({ name: 'Org Bob', email: '' }); + expect(parseArchivedFrom('')).toEqual({ name: '', email: '' }); + }); +}); + +describe('parseArchivedItems', () => { + it('keeps deleted messages with sender, subject and dates, and leaves other kinds out', () => { + const items = parseArchivedItems([archivedEmail('a'), ARCHIVED_FILE, archivedEmail('b', { from: 'carol@example.org', subject: '' })], ACCOUNT); + expect(items).toEqual([ + { + id: 'a', fromName: 'Org Bob', fromEmail: 'org-bob@example.org', subject: 'Message a', + receivedAt: '2026-10-01T09:00:00Z', archivedAt: '2026-10-20T08:00:00Z', archivedUntil: '2026-11-19T08:00:00Z', + size: 2048, accountId: ACCOUNT, + }, + expect.objectContaining({ id: 'b', fromName: '', fromEmail: 'carol@example.org', subject: '' }), + ]); + }); + + it('drops malformed entries instead of failing', () => { + expect(parseArchivedItems([null, 'x', { '@type': 'Email' }, archivedEmail('c', { archivedAt: 'yesterday' })], ACCOUNT)).toEqual([]); + expect(parseArchivedItems(undefined, ACCOUNT)).toEqual([]); + }); + + it("keeps only the account's own items from a mixed answer, never one without an account", () => { + const items = parseArchivedItems([ + archivedEmail('a'), + archivedEmail('z', { accountId: 'cz' }), + archivedEmail('n', { accountId: undefined }), + archivedEmail('e', { accountId: '' }), + ], ACCOUNT); + expect(items.map((i) => i.id)).toEqual(['a']); + }); +}); + +describe('visibleArchivedItems', () => { + const items = parseArchivedItems([ + archivedEmail('old', { archivedAt: '2026-10-01T08:00:00Z' }), + archivedEmail('new', { archivedAt: '2026-10-27T08:00:00Z' }), + archivedEmail('mid', { archivedAt: '2026-10-20T08:00:00Z' }), + archivedEmail('gone', { archivedAt: '2026-10-26T08:00:00Z', archivedUntil: '2026-10-27T08:00:00Z' }), + ], ACCOUNT); + + it('lists everything the server still keeps, newest deletion first, with days 0', () => { + expect(visibleArchivedItems(items, 0, NOW).map((i) => i.id)).toEqual(['new', 'mid', 'old']); + }); + + it('limits the list to the last N days', () => { + expect(visibleArchivedItems(items, 10, NOW).map((i) => i.id)).toEqual(['new', 'mid']); + expect(visibleArchivedItems(items, 2, NOW).map((i) => i.id)).toEqual(['new']); + expect(visibleArchivedItems(items, 1, NOW)).toEqual([]); + }); +}); + +describe('listArchivedEmailsPaged', () => { + it('reads newest first in pages of 500 and stops after 2000 with where Show older goes on', async () => { + const { io, asked } = ioFor(bigArchive(6000, NOW)); + const first = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW }); + expect(asked.queries).toBe(ARCHIVE_LIST_CAP / 500); + expect(first.nextPosition).toBe(2000); + // The newest item (past the 5000 an unpaged query would stop at) comes first; files are left out. + expect(first.items[0].id).toBe('i006000'); + expect(first.items).toHaveLength(1600); + + const second = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW, startPosition: first.nextPosition! }); + expect(second.nextPosition).toBe(4000); + const third = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW, startPosition: second.nextPosition! }); + expect(third.nextPosition).toBeNull(); + const all = [...first.items, ...second.items, ...third.items]; + expect(all).toHaveLength(4800); + expect(all[all.length - 1].id).toBe('i000002'); + }); + + it('stops once a whole page is older than the days window', async () => { + // Item n was deleted n minutes ago: one day is items 0-1440, 1153 of them messages. + const { io, asked } = ioFor(bigArchive(6000, NOW)); + const result = await listArchivedEmailsPaged(io, ACCOUNT, { days: 1, now: NOW }); + expect(result.nextPosition).toBeNull(); + // Pages 1-3 hold items inside the day, page 4 (1500-1999) is the first wholly older one. + expect(asked.queries).toBe(4); + expect(result.items.every((i) => Date.parse(i.archivedAt) >= NOW - 86_400_000)).toBe(true); + expect(result.items).toHaveLength(1153); + }); + + it('ends at the last page', async () => { + const { io, asked } = ioFor(bigArchive(30, NOW)); + const result = await listArchivedEmailsPaged(io, ACCOUNT, { days: 0, now: NOW }); + expect(result).toMatchObject({ nextPosition: null }); + expect(result.items).toHaveLength(24); + expect(asked.queries).toBe(1); + }); +}); + +describe('hasArchivedEmail', () => { + it('looks one item at a time and stops at the first message inside the window', async () => { + const { io, asked } = ioFor([ + { ...ARCHIVED_FILE, id: 'i3', archivedAt: '2026-10-28T11:00:00Z' }, + archivedEmail('i2', { archivedAt: '2026-10-28T10:00:00Z', archivedUntil: '2026-11-28T10:00:00Z' }), + archivedEmail('i1', { archivedAt: '2026-10-28T09:00:00Z', archivedUntil: '2026-11-28T10:00:00Z' }), + ]); + await expect(hasArchivedEmail(io, ACCOUNT, { days: 7, now: NOW })).resolves.toBe(true); + expect(asked).toEqual({ queries: 2, gets: 2 }); + }); + + it('says no at the first item older than the window', async () => { + const { io, asked } = ioFor([archivedEmail('i1', { archivedAt: '2026-09-01T09:00:00Z', archivedUntil: '2026-11-28T10:00:00Z' })]); + await expect(hasArchivedEmail(io, ACCOUNT, { days: 7, now: NOW })).resolves.toBe(false); + expect(asked.queries).toBe(1); + }); + + it('says no for an archive of files only, after its limit', async () => { + const files = Array.from({ length: 50 }, (_, n) => ({ ...ARCHIVED_FILE, id: `i${String(n).padStart(3, '0')}`, archivedAt: '2026-10-28T11:00:00Z' })); + const { io, asked } = ioFor(files); + await expect(hasArchivedEmail(io, ACCOUNT, { days: 0, now: NOW, limit: 5 })).resolves.toBe(false); + expect(asked.queries).toBe(5); + }); +}); + +describe('restore calls', () => { + it('asks for requestRestore per item, split at maxObjectsInSet', () => { + const calls = buildRestoreCalls(['a', 'b', 'c'], ACCOUNT, 2); + expect(calls).toEqual([ + ['x:ArchivedItem/set', { accountId: ACCOUNT, update: { a: { status: 'requestRestore' }, b: { status: 'requestRestore' } } }, 'r0'], + ['x:ArchivedItem/set', { accountId: ACCOUNT, update: { c: { status: 'requestRestore' } } }, 'r1'], + ]); + }); + + it('reports every item: restored, refused with the reason and type, or failed with its call', () => { + const calls = buildRestoreCalls(['a', 'b', 'c'], ACCOUNT, 2); + const results = readRestoreResults(calls, [ + ['x:ArchivedItem/set', { updated: { a: null }, notUpdated: { b: { type: 'notFound' } } }, 'r0'], + ['error', LICENSE_REFUSAL, 'r1'], + ]); + expect(results).toEqual([ + { id: 'a', ok: true }, + { id: 'b', ok: false, error: 'notFound', type: 'notFound' }, + { id: 'c', ok: false, error: LICENSE_REFUSAL.description, type: 'forbidden' }, + ]); + }); + + it('fails the items of a call the answer leaves out', () => { + const calls = buildRestoreCalls(['a'], ACCOUNT, 10); + expect(readRestoreResults(calls, [])).toEqual([{ id: 'a', ok: false, error: 'No answer' }]); + }); +}); + +describe('classifyArchiveError', () => { + it('counts a refusal by the server as unsupported and anything else as an error', () => { + expect(classifyArchiveError(new JMAPMethodError('forbidden', LICENSE_REFUSAL.description))).toBe('unsupported'); + expect(classifyArchiveError(new JMAPMethodError('unknownMethod', 'x'))).toBe('unsupported'); + expect(classifyArchiveError(new Error('JMAP request failed: 400 - {"type":"urn:ietf:params:jmap:error:unknownCapability"}'))).toBe('unsupported'); + expect(classifyArchiveError(new Error('Failed to fetch'))).toBe('error'); + expect(classifyArchiveError(new JMAPMethodError('serverFail', 'x'))).toBe('error'); + }); +}); + +describe('restoreOutcome', () => { + it('takes restored and vanished items off the list, keeps failures selected and notices a license refusal', () => { + const outcome = restoreOutcome(['a', 'b', 'c', 'd'], [ + { id: 'a', ok: true }, + { id: 'b', ok: false, error: 'notFound', type: 'notFound' }, + { id: 'c', ok: false, error: 'boom' }, + ]); + expect([...outcome.done]).toEqual(['a']); + expect([...outcome.gone]).toEqual(['b']); + // d got no result at all: it failed too. + expect(outcome.failed).toEqual(['c', 'd']); + expect(outcome.refused).toBe(false); + expect(restoreOutcome(['a'], [{ id: 'a', ok: false, error: 'x', type: 'forbidden' }])).toMatchObject({ failed: ['a'], refused: true }); + }); +}); diff --git a/src/lib/recovery/__tests__/fixtures.ts b/src/lib/recovery/__tests__/fixtures.ts new file mode 100644 index 00000000..123c61f2 --- /dev/null +++ b/src/lib/recovery/__tests__/fixtures.ts @@ -0,0 +1,133 @@ +/** + * x:ArchivedItem answers for the tests (copied from the webmail's + * lib/recovery/__tests__/fixtures.ts). DERIVED, NOT RECORDED: no test server + * has an Enterprise license, so these are built from the Stalwart source + * (0.16.23 and the 1.0 checkout, identical here): + * - the object: crates/registry/src/schema/structs_impl.rs, `impl IntoValue + * for ArchivedEmail` (from, subject, receivedAt, size, accountId, + * archivedAt, archivedUntil, blobId) inside `ArchivedItem`, tagged `@type`; + * files, events, contacts and scripts are archived too. Dates are + * UTCDateTime's Display ("2026-10-20T08:00:00Z"); + * - `from` as the archiving code builds it (1.0 + * crates/services/src/index_queue/index.rs, 0.16 + * crates/services/src/task_manager/index.rs): "Name
", the + * address, or the name. `receivedAt` is the Date header, or the deletion + * time without one, on 1.0, and the received time on 0.16; + * - the query (fakeArchiveServer below): crates/jmap/src/registry/query.rs + * `extract_parameters` and crates/jmap/src/api/query.rs + * `QueryResponseBuilder`. Descending by id by default. Without + * `calculateTotal` the store scans ascending, cut at `limit` or 5000 from + * `position`, then the ids are reversed and `position` is applied again; + * with it everything is scanned and sorted, and position and limit page + * it. A user without Impersonate is refused an `accountId` filter + * (`unsupportedFilter`); + * - the /set answer: crates/jmap/src/registry/mapping/archived_item.rs + * (`updated` with null, `notUpdated` with `notFound`). + * The `forbidden` refusal is the one the unlicensed test servers really + * send (local-data/org-features-facts.md §3). + */ + +export const ACCOUNT = 'ci'; + +export const LICENSE_REFUSAL = { + type: 'forbidden', + description: 'This feature is only available in the Enterprise edition. Obtain your trial license at https://license.stalw.art/trial.', +}; + +export function archivedEmail(id: string, fields: Partial> = {}): Record { + return { + id, + '@type': 'Email', + from: 'Org Bob ', + subject: `Message ${id}`, + receivedAt: '2026-10-01T09:00:00Z', + size: 2048, + accountId: ACCOUNT, + archivedAt: '2026-10-20T08:00:00Z', + archivedUntil: '2026-11-19T08:00:00Z', + blobId: `blob-${id}`, + ...fields, + }; +} + +export const ARCHIVED_FILE = { + id: 'f1', + '@type': 'FileNode', + name: 'plan.pdf', + createdAt: '2026-10-01T09:00:00Z', + accountId: ACCOUNT, + archivedAt: '2026-10-21T08:00:00Z', + archivedUntil: '2026-11-20T08:00:00Z', + blobId: 'blob-f1', +}; + +const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + +/** + * `count` archived items, item n deleted n minutes before `now` (so a + * higher n is older); every fifth is a file, the rest messages. Ids sort + * like Stalwart's registry ids: the newest has the highest id. + */ +export function bigArchive(count: number, now: number, accountId = ACCOUNT): Array> { + const out: Array> = []; + for (let n = 0; n < count; n++) { + const id = `i${String(count - n).padStart(6, '0')}`; + const archivedAt = iso(now - n * 60_000); + out.push(n % 5 === 4 + ? { ...ARCHIVED_FILE, id, accountId, archivedAt, archivedUntil: iso(now + 86_400_000) } + : archivedEmail(id, { accountId, archivedAt, archivedUntil: iso(now + 86_400_000), subject: `Message ${n}` })); + } + return out; +} + +type Call = [string, Record, string]; + +/** + * A stand-in for Stalwart's x:ArchivedItem methods over `items` (any + * accounts), answering one JMAP request body. `impersonate` gives the + * admin's view: every account, and the `accountId` filter accepted. + */ +export function fakeArchiveServer(items: Array>, options: { impersonate?: boolean; maxResults?: number } = {}) { + const maxResults = options.maxResults ?? 5000; + const byId = new Map(items.map((item) => [item.id as string, item])); + const calls: Call[] = []; + const answer = (body: { methodCalls: Call[] }) => ({ + methodResponses: body.methodCalls.map(([method, args, callId]): Call => { + calls.push([method, args, callId]); + const account = args.accountId as string; + if (method === 'x:ArchivedItem/query') { + const filter = args.filter as { accountId?: string } | undefined; + if (filter?.accountId && !options.impersonate) return ['error', { type: 'unsupportedFilter', description: 'Filter on property accountId is not supported or invalid' }, callId]; + const owner = filter?.accountId ?? (options.impersonate ? null : account); + const ascending = [...byId.values()].filter((i) => owner === null || i.accountId === owner).map((i) => i.id as string).sort(); + const position = (args.position as number | undefined) ?? 0; + const limit = Math.min((args.limit as number | undefined) ?? maxResults, maxResults); + let ids: string[]; + if (args.calculateTotal) { + ids = [...ascending].reverse().slice(position, position + limit); + } else { + // The store cuts the ascending scan, then the ids are reversed and position applied again. + const scanned = ascending.slice(position, position + limit).reverse(); + ids = scanned.slice(position, position + limit); + } + return [method, { accountId: account, ids, position, ...(args.calculateTotal ? { total: ascending.length } : {}), queryState: 's0', canCalculateChanges: true }, callId]; + } + if (method === 'x:ArchivedItem/get') { + const ids = args.ids as string[]; + const visible = ids.map((i) => byId.get(i)).filter((i): i is Record => !!i && (!!options.impersonate || i.accountId === account)); + return [method, { accountId: account, list: visible, notFound: ids.filter((i) => !visible.some((v) => v.id === i)) }, callId]; + } + if (method === 'x:ArchivedItem/set') { + const ids = Object.keys(args.update as object); + const updated: Record = {}; + const notUpdated: Record = {}; + for (const i of ids) { + if (byId.has(i)) { updated[i] = null; byId.delete(i); } else notUpdated[i] = { type: 'notFound' }; + } + return [method, { accountId: account, updated, notUpdated }, callId]; + } + return ['error', { type: 'unknownMethod' }, callId]; + }), + }); + return { answer, calls, remaining: () => [...byId.keys()] }; +} diff --git a/src/lib/recovery/__tests__/recovery-store.test.ts b/src/lib/recovery/__tests__/recovery-store.test.ts new file mode 100644 index 00000000..035041a5 --- /dev/null +++ b/src/lib/recovery/__tests__/recovery-store.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ERROR_RETRY_MS, RECENT_TTL_MS, UNSUPPORTED_RETRY_MS, recoveryKey, useRecoveryStore } from '../recovery-store'; +import { JMAPMethodError } from '../../../api/jmap-result'; +import type { ArchiveQueryPage } from '../archived-items'; +import { LICENSE_REFUSAL } from './fixtures'; + +// Ported from the webmail's stores/__tests__/recovery-store.test.ts (the +// app keeps answers for the app run, so the tab-reload and sessionStorage +// cases have no counterpart). + +const page = (ids: string[]): ArchiveQueryPage => ({ ids, total: null }); + +function makeClient(query: () => Promise, stalwart = true, has: () => Promise = async () => true) { + return { + advertisesArchivedItems: () => stalwart, + queryArchivedItemIds: vi.fn(query), + hasArchivedEmail: vi.fn(has), + }; +} + +const refused = () => Promise.reject(new JMAPMethodError('forbidden', LICENSE_REFUSAL.description)); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-28T12:00:00Z')); +}); + +afterEach(() => { + useRecoveryStore.getState().reset(); + vi.useRealTimers(); +}); + +describe('recovery detection', () => { + it('asks a one-item query, hides the feature when the server refuses and asks again only after the retry time', async () => { + const client = makeClient(refused); + const key = recoveryKey('acc-alice', 'ci'); + await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(false); + expect(client.queryArchivedItemIds).toHaveBeenCalledWith({ limit: 1 }); + expect(useRecoveryStore.getState().checks[key]).toEqual({ supported: false, retryAt: Date.now() + UNSUPPORTED_RETRY_MS }); + + await useRecoveryStore.getState().detect(client, key); + expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1); + + // The license arrives later: the next check after the retry time shows it. + client.queryArchivedItemIds.mockImplementation(async () => page([])); + vi.setSystemTime(Date.now() + UNSUPPORTED_RETRY_MS + 1); + await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(true); + expect(useRecoveryStore.getState().checks[key]?.supported).toBe(true); + }); + + it('asks again before the retry time when forced', async () => { + const client = makeClient(refused); + const key = recoveryKey('acc-alice', 'ci'); + await useRecoveryStore.getState().detect(client, key); + await useRecoveryStore.getState().detect(client, key, { force: true }); + expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(2); + }); + + it('hides it on any other error too, and asks again sooner', async () => { + const client = makeClient(() => Promise.reject(new Error('Network request failed'))); + const key = recoveryKey('acc-alice', 'ci'); + await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(false); + expect(useRecoveryStore.getState().checks[key]?.retryAt).toBe(Date.now() + ERROR_RETRY_MS); + }); + + it('shows it when the query answers, and keeps that', async () => { + const client = makeClient(async () => page(['a'])); + const key = recoveryKey('acc-alice', 'ci'); + await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(true); + vi.setSystemTime(Date.now() + 24 * 60 * 60_000); + await expect(useRecoveryStore.getState().detect(client, key)).resolves.toBe(true); + expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1); + }); + + it('asks once for two callers at the same time', async () => { + const client = makeClient(async () => page([])); + const key = recoveryKey('acc-alice', 'ci'); + await Promise.all([useRecoveryStore.getState().detect(client, key), useRecoveryStore.getState().detect(client, key)]); + expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1); + }); + + it('keeps one answer per login, even where the JMAP account ids are the same', async () => { + await useRecoveryStore.getState().detect(makeClient(async () => page([])), recoveryKey('acc-alice', 'ci')); + await useRecoveryStore.getState().detect(makeClient(refused), recoveryKey('acc-bob', 'ci')); + const { checks } = useRecoveryStore.getState(); + expect(checks['acc-alice|ci']?.supported).toBe(true); + expect(checks['acc-bob|ci']?.supported).toBe(false); + }); + + it("doesn't ask a server that isn't Stalwart (0.16 without the capability)", async () => { + const client = makeClient(async () => page([]), false); + await expect(useRecoveryStore.getState().detect(client, 'k')).resolves.toBe(false); + expect(client.queryArchivedItemIds).not.toHaveBeenCalled(); + expect(useRecoveryStore.getState().checks.k).toEqual({ supported: false, retryAt: Infinity }); + }); +}); + +describe('the Trash link check', () => { + it('looks once per days value for a while, and again after a restore', async () => { + const client = makeClient(async () => page([])); + const key = 'acc-alice|ci'; + await expect(useRecoveryStore.getState().checkRecent(client, key, 7)).resolves.toBe(true); + await useRecoveryStore.getState().checkRecent(client, key, 7); + expect(client.hasArchivedEmail).toHaveBeenCalledTimes(1); + expect(client.hasArchivedEmail).toHaveBeenCalledWith({ days: 7 }); + await useRecoveryStore.getState().checkRecent(client, key, 14); + expect(client.hasArchivedEmail).toHaveBeenCalledTimes(2); + useRecoveryStore.getState().forgetRecent(key); + await useRecoveryStore.getState().checkRecent(client, key, 14); + expect(client.hasArchivedEmail).toHaveBeenCalledTimes(3); + vi.setSystemTime(Date.now() + RECENT_TTL_MS + 1); + await useRecoveryStore.getState().checkRecent(client, key, 14); + expect(client.hasArchivedEmail).toHaveBeenCalledTimes(4); + }); + + it('records a refusal like detection does', async () => { + const client = makeClient(async () => page([]), true, refused); + await expect(useRecoveryStore.getState().checkRecent(client, 'k', 0)).resolves.toBe(false); + expect(useRecoveryStore.getState().checks.k?.supported).toBe(false); + }); +}); diff --git a/src/lib/recovery/archived-items.ts b/src/lib/recovery/archived-items.ts new file mode 100644 index 00000000..b99d1b75 --- /dev/null +++ b/src/lib/recovery/archived-items.ts @@ -0,0 +1,308 @@ +/** + * "Recover deleted items" (webmail lib/recovery/archived-items.ts, #1319): + * messages Stalwart keeps after they were deleted for good (an emptied + * Trash, an expunge, Trash cleaned up after its retention). Stalwart + * Enterprise only, and only when the admin sets `archiveDeletedItemsFor` + * (DataRetention). Pure functions; src/api/archived-items.ts sends what they + * build. + * + * - x:ArchivedItem/query, /get, /set: a user without the Impersonate + * permission only ever sees the items of the account the call names and + * is refused an `accountId` filter (`unsupportedFilter`). An admin's query + * covers every account unless it names one in the filter. So the query + * sends the filter, falls back to none on `unsupportedFilter`, and the + * client keeps only items whose `accountId` is the account it asked for. + * - Query order and paging: the default sort is by id, descending (newest + * first). Without `calculateTotal` Stalwart scans ascending, cut at + * `limit` or 5000, and only then reverses, so the newest items past 5000 + * are never seen and `position` is applied twice. With `calculateTotal: + * true` it pages correctly, so every page asks for the total. + * - The object: `ArchivedItem`, tagged by `@type` (Email, FileNode, + * CalendarEvent, ContactCard, SieveScript; all count in the query), and + * `ArchivedEmail` (from, subject, receivedAt, size, accountId, archivedAt, + * archivedUntil, blobId). No folder is kept. `from` is "Name
", + * the address or the name. + * - Restoring: x:ArchivedItem/set update `{ status: "requestRestore" }` + * removes the item at once and schedules a task that puts the message + * back in the Inbox, unread and without keywords. If that task fails the + * message is lost and the client can't tell. Only emails are restored. + * - Without a license every call answers `forbidden`. + */ + +import { jmapErrorType } from '../../api/jmap-result'; + +export const STALWART_CAPABILITY = 'urn:stalwart:jmap'; +export const ARCHIVED_ITEM_USING = ['urn:ietf:params:jmap:core', STALWART_CAPABILITY]; + +/** Ids per query page. */ +export const ARCHIVE_PAGE_SIZE = 500; +/** Items looked at before the list stops and offers "Show older". */ +export const ARCHIVE_LIST_CAP = 2000; +/** Items the Trash link looks at, at most, before giving up. */ +export const ARCHIVE_PEEK_LIMIT = 20; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** One deleted message the server still keeps. */ +export interface ArchivedEmail { + id: string; + /** Sender as the server stored it: a name, an address, or both. */ + fromName: string; + fromEmail: string; + subject: string; + /** The message's date, ISO. Null when the server had none. */ + receivedAt: string | null; + /** When it was deleted for good, ISO. */ + archivedAt: string; + /** Until when the server keeps it, ISO. */ + archivedUntil: string | null; + size: number; + accountId: string; +} + +export interface RestoreResult { + id: string; + ok: boolean; + /** The server's reason when it refused this one, or the error of the whole call. */ + error?: string; + /** The JMAP error type, when there was one (`notFound`, `forbidden`). */ + type?: string; +} + +export interface ArchiveQueryPage { + ids: string[]; + total: number | null; +} + +/** What the paging needs: one query page, and the raw items of some ids. */ +export interface ArchiveIo { + query: (position: number, limit: number) => Promise; + get: (ids: string[]) => Promise; +} + +export interface ArchiveListResult { + items: ArchivedEmail[]; + /** Where "Show older" goes on; null when there is nothing older to look at. */ + nextPosition: number | null; +} + +export type ArchiveCall = [string, Record, string]; + +function str(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function isoOrNull(value: unknown): string | null { + if (typeof value !== 'string' || !value) return null; + return Number.isNaN(Date.parse(value)) ? null : value; +} + +/** "Name
", "address" or "Name", as the server builds it. */ +export function parseArchivedFrom(from: string): { name: string; email: string } { + const value = from.trim(); + const angle = /^(.*?)\s*<([^<>\s]+)>$/.exec(value); + if (angle) return { name: angle[1].replace(/^"(.*)"$/, '$1').trim(), email: angle[2] }; + if (/^[^\s@]+@[^\s@]+$/.test(value)) return { name: '', email: value }; + return { name: value, email: '' }; +} + +function asRecord(raw: unknown): Record | null { + return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : null; +} + +/** + * The deleted messages of `accountId` in an x:ArchivedItem/get list. Other + * kinds (files, events, contacts, scripts) are left out: the server can't + * restore them. So is every item of another account, and every item that + * doesn't say whose it is. + */ +export function parseArchivedItems(list: unknown, accountId: string): ArchivedEmail[] { + if (!Array.isArray(list)) return []; + const out: ArchivedEmail[] = []; + for (const raw of list) { + const item = asRecord(raw); + if (!item || item['@type'] !== 'Email') continue; + const id = str(item.id); + const archivedAt = isoOrNull(item.archivedAt); + if (!id || !archivedAt) continue; + if (str(item.accountId) !== accountId) continue; + const from = parseArchivedFrom(str(item.from)); + out.push({ + id, + fromName: from.name, + fromEmail: from.email, + subject: str(item.subject), + receivedAt: isoOrNull(item.receivedAt), + archivedAt, + archivedUntil: isoOrNull(item.archivedUntil), + size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0, + accountId, + }); + } + return out; +} + +/** The start of the days window, or -Infinity for 0 (everything the server keeps). */ +export function daysCutoff(days: number, now: number): number { + return days > 0 ? now - days * DAY_MS : -Infinity; +} + +/** Whether an item is shown: deleted inside the window and still kept by the server. */ +function inWindow(item: ArchivedEmail, cutoff: number, now: number): boolean { + if (Date.parse(item.archivedAt) < cutoff) return false; + return !item.archivedUntil || Date.parse(item.archivedUntil) > now; +} + +/** + * What the user sees: items deleted within the last `days` (0 = all the + * server keeps), without those the server no longer keeps, newest first. + */ +export function visibleArchivedItems(items: ArchivedEmail[], days: number, now: number = Date.now()): ArchivedEmail[] { + const cutoff = daysCutoff(days, now); + return items + .filter((item) => inWindow(item, cutoff, now)) + .sort((a, b) => Date.parse(b.archivedAt) - Date.parse(a.archivedAt) || (a.id < b.id ? 1 : -1)); +} + +/** Every raw item of a page is older than the window (of any kind; a page with an undated item isn't). */ +function wholePageOlder(raw: unknown[], cutoff: number): boolean { + if (cutoff === -Infinity || raw.length === 0) return false; + return raw.every((r) => { + const at = isoOrNull(asRecord(r)?.archivedAt); + return at !== null && Date.parse(at) < cutoff; + }); +} + +/** + * Reads the archive newest first in pages of ARCHIVE_PAGE_SIZE from + * `startPosition`, with the details of each page. Stops at the end, once a + * whole page is older than the days window, or after ARCHIVE_LIST_CAP items + * (then `nextPosition` says where "Show older" goes on). + */ +export async function listArchivedEmailsPaged( + io: ArchiveIo, + accountId: string, + options: { days: number; now?: number; startPosition?: number; cap?: number; pageSize?: number }, +): Promise { + const now = options.now ?? Date.now(); + const cutoff = daysCutoff(options.days, now); + const pageSize = options.pageSize ?? ARCHIVE_PAGE_SIZE; + const cap = options.cap ?? ARCHIVE_LIST_CAP; + let position = options.startPosition ?? 0; + let looked = 0; + const items: ArchivedEmail[] = []; + for (;;) { + const page = await io.query(position, pageSize); + if (page.ids.length === 0) return { items, nextPosition: null }; + const raw = await io.get(page.ids); + items.push(...parseArchivedItems(raw, accountId).filter((item) => inWindow(item, cutoff, now))); + position += page.ids.length; + looked += page.ids.length; + if (wholePageOlder(raw, cutoff)) return { items, nextPosition: null }; + if (page.total !== null && position >= page.total) return { items, nextPosition: null }; + if (page.ids.length < pageSize && page.total === null) return { items, nextPosition: null }; + if (looked >= cap) return { items, nextPosition: position }; + } +} + +/** + * Whether the archive holds at least one message of `accountId` inside the + * days window, looking at one item at a time, newest first: stops at the + * first such message, at the first item older than the window, or after + * `limit` items. + */ +export async function hasArchivedEmail( + io: ArchiveIo, + accountId: string, + options: { days: number; now?: number; limit?: number }, +): Promise { + const now = options.now ?? Date.now(); + const cutoff = daysCutoff(options.days, now); + const limit = options.limit ?? ARCHIVE_PEEK_LIMIT; + for (let position = 0; position < limit; position++) { + const page = await io.query(position, 1); + if (page.ids.length === 0) return false; + const raw = await io.get(page.ids); + if (parseArchivedItems(raw, accountId).some((item) => inWindow(item, cutoff, now))) return true; + if (wholePageOlder(raw, cutoff)) return false; + if (page.total !== null && position + 1 >= page.total) return false; + } + return false; +} + +/** + * The calls that restore `ids`, split at maxObjectsInSet. The narrowest + * change a user may make: only `status` (and `archivedUntil`) are writable. + */ +export function buildRestoreCalls(ids: string[], accountId: string, maxObjectsInSet: number): ArchiveCall[] { + const step = Math.max(1, Math.floor(maxObjectsInSet)); + const calls: ArchiveCall[] = []; + for (let i = 0; i < ids.length; i += step) { + const update = Object.fromEntries(ids.slice(i, i + step).map((id) => [id, { status: 'requestRestore' }])); + calls.push(['x:ArchivedItem/set', { accountId, update }, `r${calls.length}`]); + } + return calls; +} + +/** Each id of a restore call: updated, refused with the reason, or failed with the call. */ +export function readRestoreResults(calls: ArchiveCall[], responses: ArchiveCall[]): RestoreResult[] { + const byCallId = new Map(responses.map((r) => [r[2], r])); + const results: RestoreResult[] = []; + for (const [, args, callId] of calls) { + const ids = Object.keys(args.update as Record); + const [method, result] = byCallId.get(callId) ?? []; + if (method !== 'x:ArchivedItem/set' || !result) { + const type = method === 'error' ? str(result?.type) || undefined : undefined; + const reason = method === 'error' ? str(result?.description) || type || 'error' : 'No answer'; + results.push(...ids.map((id) => ({ id, ok: false, error: reason, ...(type ? { type } : {}) }))); + continue; + } + const updated = (result.updated ?? {}) as Record; + const notUpdated = (result.notUpdated ?? {}) as Record; + for (const id of ids) { + if (id in updated) results.push({ id, ok: true }); + else { + const refused = notUpdated[id]; + results.push({ id, ok: false, error: refused?.description || refused?.type || 'notUpdated', ...(refused?.type ? { type: refused.type } : {}) }); + } + } + } + return results; +} + +/** + * How a failed archive call counts for detection: `unsupported` when the + * server says no (no Enterprise license: `forbidden`; not Stalwart or too + * old: `unknownMethod`, `unknownCapability`), `error` for anything else + * (offline, a server error), which is asked again sooner. + */ +export function classifyArchiveError(error: unknown): 'unsupported' | 'error' { + const type = jmapErrorType(error); + if (type === 'forbidden' || type === 'unknownMethod' || type === 'unknownCapability' || type === 'accountNotFound') return 'unsupported'; + const message = error instanceof Error ? error.message : String(error); + if (/unknownCapability|unknownMethod/.test(message)) return 'unsupported'; + return 'error'; +} + +export interface RestoreOutcome { + /** Restored: they leave the list. */ + done: Set; + /** No longer in the archive (`notFound`): they leave the list too. */ + gone: Set; + /** Failed: they stay selected to try again. */ + failed: string[]; + /** The server refused for want of a license: the page hides. */ + refused: boolean; +} + +/** What a restore of `ids` did, as the page shows it (webmail RecoverDeletedSettings.restore). */ +export function restoreOutcome(ids: string[], results: RestoreResult[]): RestoreOutcome { + const done = new Set(results.filter((r) => r.ok).map((r) => r.id)); + const gone = new Set(results.filter((r) => !r.ok && r.type === 'notFound').map((r) => r.id)); + return { + done, + gone, + failed: ids.filter((id) => !done.has(id) && !gone.has(id)), + refused: results.some((r) => r.type === 'forbidden'), + }; +} diff --git a/src/lib/recovery/policy.ts b/src/lib/recovery/policy.ts new file mode 100644 index 00000000..337aee86 --- /dev/null +++ b/src/lib/recovery/policy.ts @@ -0,0 +1,17 @@ +// What the organisation policy says about "Recover deleted items". The +// webmail reads it from its policy store: the admin can hide the page +// (restriction `deletedItemsRecovery`) and limit the list to the last N days +// (`organisation.recovery.days`, 0 = everything the server keeps). The app +// has no policy route yet, so this answers "shown, no limit"; once the +// policy arrives, this is the one place to plug it in. + +export interface RecoveryPolicy { + /** The admin hid the page: nothing is asked of the server. */ + hidden: boolean; + /** Days of deleted mail to list; 0 = all the server keeps. */ + days: number; +} + +export function recoveryPolicy(): RecoveryPolicy { + return { hidden: false, days: 0 }; +} diff --git a/src/lib/recovery/recovery-store.ts b/src/lib/recovery/recovery-store.ts new file mode 100644 index 00000000..1bc71f10 --- /dev/null +++ b/src/lib/recovery/recovery-store.ts @@ -0,0 +1,131 @@ +import { create } from 'zustand'; +import { classifyArchiveError, type ArchiveQueryPage } from './archived-items'; + +/** + * Whether "Recover deleted items" works for an account (webmail + * stores/recovery-store.ts), asked once per app run and account with a + * one-item x:ArchivedItem/query. The answer can change while the app runs + * (an admin installs the license, or it runs out), so a "no" is asked again + * after a while. Separately, whether the archive holds a recent message, + * which decides the Trash link. + */ + +/** A server that said no (no license, not Stalwart) is asked again after this. */ +export const UNSUPPORTED_RETRY_MS = 30 * 60_000; +/** An error that says nothing about the server (offline, a server error) is asked again sooner. */ +export const ERROR_RETRY_MS = 2 * 60_000; +/** How long the Trash link trusts its look at the archive. */ +export const RECENT_TTL_MS = 5 * 60_000; + +export interface RecoveryCheck { + supported: boolean; + /** When to ask again; null while supported, Infinity when it never changes. */ + retryAt: number | null; +} + +export interface RecentCheck { + has: boolean; + days: number; + checkedAt: number; +} + +/** The parts of src/api/archived-items the store asks, for one account. */ +export interface RecoveryClient { + advertisesArchivedItems: () => boolean; + queryArchivedItemIds: (options: { limit?: number }) => Promise; + hasArchivedEmail: (options: { days?: number }) => Promise; +} + +interface RecoveryState { + checks: Record; + recent: Record; + /** Asks the server unless a fresh answer is cached; resolves to whether it works. */ + detect: (client: RecoveryClient, key: string, options?: { force?: boolean }) => Promise; + /** Records what a list or restore call learned, so the page and the Trash link agree. */ + record: (key: string, outcome: 'supported' | 'unsupported' | 'error') => void; + /** Whether the archive holds a message inside the days window, for the Trash link. */ + checkRecent: (client: RecoveryClient, key: string, days: number) => Promise; + /** The archive changed (a restore): look again next time. */ + forgetRecent: (key: string) => void; + reset: () => void; +} + +const inFlight = new Map>(); +const recentInFlight = new Map>(); + +function checkFor(outcome: 'supported' | 'unsupported' | 'error', now: number): RecoveryCheck { + if (outcome === 'supported') return { supported: true, retryAt: null }; + return { supported: false, retryAt: now + (outcome === 'unsupported' ? UNSUPPORTED_RETRY_MS : ERROR_RETRY_MS) }; +} + +/** The cache key: the signed-in login and the JMAP account in it. */ +export function recoveryKey(localAccountId: string | null | undefined, jmapAccountId: string): string { + return `${localAccountId ?? ''}|${jmapAccountId}`; +} + +export const useRecoveryStore = create()((set, get) => ({ + checks: {}, + recent: {}, + + detect: (client, key, options) => { + const cached = get().checks[key]; + if (!options?.force && cached && (cached.supported || (cached.retryAt ?? 0) > Date.now())) { + return Promise.resolve(cached.supported); + } + if (!client.advertisesArchivedItems()) { + // Not Stalwart: nothing will change while the session lasts. + set((state) => ({ checks: { ...state.checks, [key]: { supported: false, retryAt: Infinity } } })); + return Promise.resolve(false); + } + const running = inFlight.get(key); + if (running) return running; + const probe = client.queryArchivedItemIds({ limit: 1 }) + .then(() => 'supported' as const) + .catch((error: unknown) => classifyArchiveError(error)) + .then((outcome) => { + get().record(key, outcome); + return outcome === 'supported'; + }) + .finally(() => inFlight.delete(key)); + inFlight.set(key, probe); + return probe; + }, + + record: (key, outcome) => { + set((state) => ({ checks: { ...state.checks, [key]: checkFor(outcome, Date.now()) } })); + }, + + checkRecent: (client, key, days) => { + const cached = get().recent[key]; + if (cached && cached.days === days && Date.now() - cached.checkedAt < RECENT_TTL_MS) return Promise.resolve(cached.has); + const flightKey = `${key}|${days}`; + const running = recentInFlight.get(flightKey); + if (running) return running; + const look = client.hasArchivedEmail({ days }) + .catch((error: unknown) => { + if (classifyArchiveError(error) === 'unsupported') get().record(key, 'unsupported'); + return false; + }) + .then((has) => { + set((state) => ({ recent: { ...state.recent, [key]: { has, days, checkedAt: Date.now() } } })); + return has; + }) + .finally(() => recentInFlight.delete(flightKey)); + recentInFlight.set(flightKey, look); + return look; + }, + + forgetRecent: (key) => { + set((state) => { + const recent = { ...state.recent }; + delete recent[key]; + return { recent }; + }); + }, + + reset: () => { + inFlight.clear(); + recentInFlight.clear(); + set({ checks: {}, recent: {} }); + }, +})); diff --git a/src/lib/recovery/use-deleted-items-recovery.ts b/src/lib/recovery/use-deleted-items-recovery.ts new file mode 100644 index 00000000..4afd5a2d --- /dev/null +++ b/src/lib/recovery/use-deleted-items-recovery.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { jmapClient } from '../../api/jmap-client'; +import { + advertisesArchivedItems, + hasArchivedEmail, + queryArchivedItemIds, +} from '../../api/archived-items'; +import { useAuthStore } from '../../stores/auth-store'; +import { recoveryPolicy } from './policy'; +import { recoveryKey, useRecoveryStore, type RecoveryClient } from './recovery-store'; + +/** The archive calls for one JMAP account, as the detection store asks them. */ +export function recoveryClientFor(accountId: string): RecoveryClient { + return { + advertisesArchivedItems: () => advertisesArchivedItems(accountId), + queryArchivedItemIds: (options) => queryArchivedItemIds(accountId, options), + hasArchivedEmail: (options) => hasArchivedEmail(accountId, options), + }; +} + +export interface DeletedItemsRecovery { + /** Offered for the active account (the server has the feature). */ + available: boolean; + /** The JMAP account the page is for; null while there is no session. */ + accountId: string | null; + /** The detection cache key of the active account. */ + key: string | null; +} + +/** + * Whether "Recover deleted items" is offered for the active account + * (webmail hooks/use-deleted-items-recovery.ts). Nothing is asked until the + * live session is in, nor when the policy hides it (see ./policy). Then the + * server must answer x:ArchivedItem/query (Stalwart with an Enterprise + * license). Asked once per account and app run, and again after a refusal + * once its retry time comes, also while Settings stays open. + */ +export function useDeletedItemsRecovery(): DeletedItemsRecovery { + const session = useAuthStore((s) => s.session); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const accountId = session ? jmapClient.accountId || null : null; + const off = recoveryPolicy().hidden; + const key = accountId ? recoveryKey(activeAccountId, accountId) : null; + const check = useRecoveryStore((s) => (key ? s.checks[key] : undefined)); + const detect = useRecoveryStore((s) => s.detect); + + useEffect(() => { + if (off || !accountId || !key) return undefined; + if (!check) { + void detect(recoveryClientFor(accountId), key); + return undefined; + } + if (check.supported || check.retryAt === null || !Number.isFinite(check.retryAt)) return undefined; + const timer = setTimeout( + () => void detect(recoveryClientFor(accountId), key, { force: true }), + Math.max(0, check.retryAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [off, accountId, key, check, detect]); + + return { available: !off && !!check?.supported, accountId, key }; +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index a8927529..ce6d9ffb 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -17,7 +17,7 @@ export type SettingsTabId = | 'account' | 'language' | 'notifications' | 'accessibility' | 'appearance' | 'layout' | 'reading' | 'composing' | 'identities' | 'vacation' - | 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads' + | 'filters' | 'recovery' | 'templates' | 'folders' | 'keywords' | 'downloads' | 'security' | 'content_senders' | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' // about_data is Storage & backup (the id predates the split from About); @@ -38,6 +38,8 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.account.accounts', 'settings.account.shared_accounts', ], + // Recover deleted items shows only where the server has it. + recovery: ['recover_deleted.title'], language: [ 'settings.appearance.language', 'settings.language_region.date_format', @@ -256,6 +258,7 @@ export const SETTINGS_SEARCH_PATHS: Record = { // translation doesn't contain the literal word. export const SETTINGS_SEARCH_KEYWORDS: Record = { account: 'profile email password user signin signout reorder switch default multi-account shared storage quota', + recovery: 'recover restore undelete deleted trash emptied lost messages', language: 'locale region date time format translation', notifications: 'alert push badge unifiedpush relay device reminder', accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm', diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3232fae5..96ffb360 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -9,7 +9,7 @@ import { Filter, FileText, FolderOpen, Tags, HardDrive, BookUser, PanelLeftClose, Bell, RefreshCw, LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download, - Database, Bug, Scale, + Database, Bug, Scale, RotateCcw, type AppIcon, } from '../components/icons'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; @@ -45,6 +45,8 @@ import { LanguageSettings } from '../components/settings/LanguageSettings'; import { ComposingSettings } from '../components/settings/ComposingSettings'; import { LayoutSettings } from '../components/settings/LayoutSettings'; import { DownloadsSettings } from '../components/settings/DownloadsSettings'; +import { RecoverDeletedSettings } from '../components/settings/RecoverDeletedSettings'; +import { useDeletedItemsRecovery } from '../lib/recovery/use-deleted-items-recovery'; import { useLocaleStore } from '../stores/locale-store'; import { useAuthStore } from '../stores/auth-store'; import { useManagedAccountStore } from '../stores/managed-account-store'; @@ -112,6 +114,7 @@ const TABS: TabDef[] = [ { id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true }, { id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true }, { id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: true }, + { id: 'recovery', label: 'Recover deleted items', icon: RotateCcw, group: 'mail', implemented: true }, { id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: true }, { id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true }, { id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true }, @@ -145,6 +148,7 @@ const TAB_LABEL_KEYS: Partial> = { debug: 'settings.tabs.troubleshooting', licences: 'settings.about.licences', privacy: 'settings.about.privacy', + recovery: 'recover_deleted.tab', }; const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`; @@ -164,6 +168,7 @@ const TAB_COMPONENTS: Partial>> = { folders: FolderSettings, keywords: KeywordSettings, downloads: DownloadsSettings, + recovery: RecoverDeletedSettings, security: AccountSecuritySettings, content_senders: ContentSendersSettings, calendar: CalendarSettings, @@ -185,11 +190,11 @@ const AVAILABLE_TABS: TabDef[] = TABS.filter( (t) => !t.hidden && t.id !== 'account' && (t.id !== 'updates' || supportsSideloadUpdates), ); -function groupTabs(only: ReadonlySet | null) { +function groupTabs(only: ReadonlySet | null, hidden: ReadonlySet) { return GROUP_ORDER.map(group => ({ group, label: GROUP_LABELS[group], - items: AVAILABLE_TABS.filter(t => t.group === group && (!only || only.has(t.id))), + items: AVAILABLE_TABS.filter(t => t.group === group && (!only || only.has(t.id)) && !hidden.has(t.id)), })).filter(g => g.items.length > 0); } @@ -217,6 +222,14 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin const hasFiles = useHasFiles(); const hasSieve = useHasSieve(); const hasVacation = useHasVacation(); + // Webmail 1.19: Recover deleted items is left out of the list, not shown + // as "Not on this server", where the server lacks it. + const recoveryAvailable = useDeletedItemsRecovery().available; + const hiddenTabs = React.useMemo(() => { + const set = new Set(); + if (!recoveryAvailable) set.add('recovery'); + return set; + }, [recoveryAvailable]); // A shared/group account picked under "Shared with me" scopes Settings to // the panes that account can be managed in (webmail: scoped settings). @@ -259,7 +272,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin }, [pendingTab, clearManagedAccount]); const groupedTabs = React.useMemo(() => { void locale; // dependency: re-translate on locale change - return groupTabs(scopedTabs).map((g) => ({ + return groupTabs(scopedTabs, hiddenTabs).map((g) => ({ ...g, label: t(`settings.tab_groups.${g.group}`, g.label), items: g.items.map((tab) => ({ @@ -267,7 +280,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin label: t(tabLabelKey(tab.id), tab.label), })), })); - }, [locale, t, scopedTabs]); + }, [locale, t, scopedTabs, hiddenTabs]); // The second line of each row: the pane's current value. const settings = useSettingsStore(); @@ -325,6 +338,12 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin .filter((g) => g.items.length > 0) : groupedTabs; + // A page whose feature went away (the license ran out, another account) + // closes instead of staying open empty. + useEffect(() => { + if (selectedTab && hiddenTabs.has(selectedTab)) setSelectedTab(null); + }, [selectedTab, hiddenTabs]); + // Back from a page opened inside another one returns to that page. const closePane = React.useCallback(() => { setSelectedTab((tab) => TABS.find((def) => def.id === tab)?.parent ?? null); From 8a5bb72823206108ada0d18c22d27492bf902698 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:29:50 +0100 Subject: [PATCH 2/3] feat: give a delegate access to mail, calendars and contacts in one step --- src/api/__tests__/delegation.test.ts | 243 ++++++++ .../live-stalwart-delegation.test.ts | 123 ++++ src/api/delegation.ts | 264 +++++++++ src/components/settings/DelegateDialog.tsx | 536 ++++++++++++++++++ src/components/settings/DelegateSettings.tsx | 302 ++++++++++ .../settings-search-recovery.test.ts | 14 +- .../delegation/__tests__/delegation.test.ts | 235 ++++++++ src/lib/delegation/__tests__/display.test.ts | 77 +++ .../__tests__/known-principals.test.ts | 53 ++ .../delegation/__tests__/share-batch.test.ts | 76 +++ src/lib/delegation/delegation.ts | 257 +++++++++ src/lib/delegation/display.ts | 100 ++++ src/lib/delegation/known-principals.ts | 86 +++ src/lib/delegation/policy.ts | 21 + src/lib/delegation/share-batch.ts | 183 ++++++ src/lib/delegation/share-rights.ts | 183 ++++++ .../delegation/use-delegation-available.ts | 16 + src/lib/settings-search.ts | 6 +- src/screens/SettingsScreen.tsx | 15 +- 19 files changed, 2780 insertions(+), 10 deletions(-) create mode 100644 src/api/__tests__/delegation.test.ts create mode 100644 src/api/__tests__/live-stalwart-delegation.test.ts create mode 100644 src/api/delegation.ts create mode 100644 src/components/settings/DelegateDialog.tsx create mode 100644 src/components/settings/DelegateSettings.tsx create mode 100644 src/lib/delegation/__tests__/delegation.test.ts create mode 100644 src/lib/delegation/__tests__/display.test.ts create mode 100644 src/lib/delegation/__tests__/known-principals.test.ts create mode 100644 src/lib/delegation/__tests__/share-batch.test.ts create mode 100644 src/lib/delegation/delegation.ts create mode 100644 src/lib/delegation/display.ts create mode 100644 src/lib/delegation/known-principals.ts create mode 100644 src/lib/delegation/policy.ts create mode 100644 src/lib/delegation/share-batch.ts create mode 100644 src/lib/delegation/share-rights.ts create mode 100644 src/lib/delegation/use-delegation-available.ts diff --git a/src/api/__tests__/delegation.test.ts b/src/api/__tests__/delegation.test.ts new file mode 100644 index 00000000..3420e008 --- /dev/null +++ b/src/api/__tests__/delegation.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'ci', + request: vi.fn(), + currentSession: null as unknown, + hasCapability: vi.fn(), + getMaxObjectsInSet: vi.fn(() => 500), + getMaxCallsInRequest: vi.fn(() => 16), + }, +})); + +vi.mock('../files', () => ({ + getAllFileNodes: vi.fn(async () => []), + filesAccountId: () => 'ci', + supportsFiles: vi.fn(() => true), +})); + +import { jmapClient } from '../jmap-client'; +import { getAllFileNodes, supportsFiles } from '../files'; +import { + LOGIN_SWITCHED, + applyShareChanges, + findPrincipalByEmail, + getShareTargets, + sharesSeenWithKeywords, + supportsDelegation, + supportsMailboxSharing, +} from '../delegation'; +import { buildGrantChanges, buildRevokeChanges } from '../../lib/delegation/delegation'; +import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS, ADDRESS_BOOK_PRESETS } from '../../lib/delegation/share-rights'; +import type { ShareChange, ShareTarget } from '../../lib/delegation/share-batch'; + +// Ported from the client part of the webmail's lib/__tests__/jmap-share-batch.test.ts, +// plus the capability checks of its settings-app supportsSharing. + +type Call = [string, Record, string]; +const mockRequest = jmapClient.request as ReturnType; +const mockHasCapability = jmapClient.hasCapability as ReturnType; +const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType; + +const ALL_CAPS = { + 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:mail:share': {}, 'urn:ietf:params:jmap:calendars': {}, + 'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:filenode': {}, +}; + +function setSession(accountCapabilities: Record, sessionCaps: string[] = ['urn:ietf:params:jmap:principals']) { + const capabilities = Object.fromEntries(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', ...sessionCaps].map((c) => [c, {}])); + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities, + primaryAccounts: {}, + accounts: { ci: { name: 'org-alice@example.org', isPersonal: true, isReadOnly: false, accountCapabilities } }, + }; + mockHasCapability.mockImplementation((urn: string) => urn in capabilities); +} + +const change = (kind: ShareChange['kind'], id: string, rights: ShareChange['rights'], principalId = 'ck'): ShareChange => + ({ kind, accountId: 'ci', id, name: id, principalId, rights }); + +const sent = () => mockRequest.mock.calls.map(([calls, using]) => ({ calls: calls as Call[], using: using as string[] })); +const allUpdated = (calls: Call[]) => ({ + methodResponses: calls.map(([method, args, id]) => [method, { updated: Object.fromEntries(Object.keys(args.update as object).map((k) => [k, null])) }, id]), +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockMaxCalls.mockReturnValue(16); + (supportsFiles as ReturnType).mockReturnValue(true); + setSession(ALL_CAPS); +}); + +describe('capability checks', () => { + it('shares mail folders only where the account lists mail:share', () => { + expect(supportsMailboxSharing()).toBe(true); + setSession({ 'urn:ietf:params:jmap:mail': {} }, ['urn:ietf:params:jmap:principals', 'urn:ietf:params:jmap:mail:share']); + expect(supportsMailboxSharing()).toBe(false); + }); + + it('offers Delegates with principals and something to share, and follows the app gates', () => { + const gates = { calendarsEnabled: true, contactsEnabled: true }; + expect(supportsDelegation(gates)).toBe(true); + // Nobody to share with. + setSession(ALL_CAPS, []); + expect(supportsDelegation(gates)).toBe(false); + // Only calendars to share: off with the calendar gate. + setSession({ 'urn:ietf:params:jmap:calendars': {} }); + expect(supportsDelegation(gates)).toBe(true); + expect(supportsDelegation({ calendarsEnabled: false, contactsEnabled: true })).toBe(false); + // Nothing to share at all. + setSession({ 'urn:ietf:params:jmap:mail': {} }); + expect(supportsDelegation(gates)).toBe(false); + }); + + it('knows a 1.0 account keeps seen and keywords as one right', () => { + expect(sharesSeenWithKeywords()).toBe(false); + setSession({ ...ALL_CAPS, 'urn:ietf:params:jmap:principals:owner': {} }); + expect(sharesSeenWithKeywords()).toBe(true); + }); +}); + +describe('getShareTargets', () => { + const lists = (calls: Call[]) => calls.map(([method, , id]) => [method, { + list: method === 'Mailbox/get' ? [{ id: 'a', name: 'Inbox', role: 'inbox', shareWith: { ck: MAILBOX_PRESETS.read } }] + : method === 'Calendar/get' ? [{ id: 'b', name: 'Calendar', isDefault: true, shareWith: {} }] + : [{ id: 'c', name: 'Contacts', isDefault: true, shareWith: {} }], + }, id]); + + it('reads folders, calendars and address books in one request with shareWith, and only the shared files', async () => { + mockRequest.mockImplementation(async (calls: Call[]) => ({ methodResponses: lists(calls) })); + (getAllFileNodes as ReturnType).mockResolvedValue([ + { id: 'n1', name: 'plan.pdf', parentId: null, shareWith: { ck: FILE_PRESETS.read } }, + { id: 'n2', name: 'notes.txt', parentId: null, shareWith: {} }, + ]); + const { targets, failed } = await getShareTargets(); + expect(mockRequest).toHaveBeenCalledTimes(1); + const [{ calls, using }] = sent(); + expect(calls).toEqual([ + ['Mailbox/get', { accountId: 'ci', ids: null, properties: ['id', 'name', 'role', 'parentId', 'shareWith'] }, 'm'], + ['Calendar/get', { accountId: 'ci', ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'c'], + ['AddressBook/get', { accountId: 'ci', ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'a'], + ]); + expect(using).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:mail:share', 'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:contacts'])); + expect(targets.map((t) => `${t.kind}:${t.id}`)).toEqual(['mailbox:a', 'calendar:b', 'addressBook:c', 'file:n1']); + expect(failed).toEqual([]); + }); + + it('leaves out folders on an account without mail:share', async () => { + setSession({ 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:calendars': {} }); + mockRequest.mockImplementation(async (calls: Call[]) => ({ methodResponses: lists(calls) })); + await getShareTargets({ files: false }); + expect(sent()[0].calls.map(([m]) => m)).toEqual(['Calendar/get']); + }); + + it('skips the types whose feature is off', async () => { + mockRequest.mockImplementation(async (calls: Call[]) => ({ methodResponses: lists(calls) })); + await getShareTargets({ calendars: false, contacts: false, files: false }); + expect(sent()[0].calls.map(([m]) => m)).toEqual(['Mailbox/get']); + expect(getAllFileNodes).not.toHaveBeenCalled(); + }); + + it('names the types whose read failed', async () => { + mockRequest.mockImplementation(async (calls: Call[]) => ({ + methodResponses: calls.map((call) => (call[0] === 'Calendar/get' ? ['error', { type: 'serverFail' }, call[2]] : lists([call])[0])), + })); + (getAllFileNodes as ReturnType).mockRejectedValue(new Error('offline')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { failed } = await getShareTargets(); + expect(failed.sort()).toEqual(['calendar', 'file']); + }); +}); + +describe('applyShareChanges', () => { + it('gives a delegate the Inbox, the default calendar and the default address book in one request', async () => { + mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls)); + const targets = [ + { kind: 'mailbox' as const, accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: {} }, + { kind: 'mailbox' as const, accountId: 'ci', id: 'b', name: 'Sent', role: 'sent', shareWith: {} }, + { kind: 'calendar' as const, accountId: 'ci', id: 'c1', name: 'Work', isDefault: true, shareWith: {} }, + { kind: 'addressBook' as const, accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} }, + ]; + const results = await applyShareChanges(buildGrantChanges(targets, 'ck', { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'write')); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(sent()[0].calls).toEqual([ + ['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': MAILBOX_PRESETS.readWrite } } }, 's0'], + ['Calendar/set', { accountId: 'ci', update: { c1: { 'shareWith/ck': CALENDAR_PRESETS.readWrite } } }, 's1'], + ['AddressBook/set', { accountId: 'ci', update: { ab: { 'shareWith/ck': ADDRESS_BOOK_PRESETS.readWrite } } }, 's2'], + ]); + expect(sent()[0].using).toEqual(expect.arrayContaining([ + 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail:share', 'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:contacts', + ])); + expect(results.every((r) => r.ok)).toBe(true); + }); + + it('takes every grant away with null patches, files included', async () => { + mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls)); + const targets: ShareTarget[] = [ + { kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: { ck: MAILBOX_PRESETS.read } }, + { kind: 'file', accountId: 'ci', id: 'n1', name: 'plan.pdf', shareWith: { ck: FILE_PRESETS.read, cj: FILE_PRESETS.read } }, + ]; + await applyShareChanges(buildRevokeChanges(targets, 'ck')); + expect(sent()[0].calls).toEqual([ + ['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': null } } }, 's0'], + ['FileNode/set', { accountId: 'ci', update: { n1: { 'shareWith/ck': null } } }, 's1'], + ]); + }); + + it('sends one request per maxCallsInRequest calls and reports every item', async () => { + mockMaxCalls.mockReturnValue(2); + mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls)); + const results = await applyShareChanges([ + change('mailbox', 'a', MAILBOX_PRESETS.read), + change('calendar', 'c2', CALENDAR_PRESETS.read), + change('file', 'n1', null), + ]); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(results.map((r) => [r.change.id, r.ok])).toEqual([['a', true], ['c2', true], ['n1', true]]); + }); + + it('marks the items of a failed request failed instead of throwing', async () => { + mockRequest.mockRejectedValue(new Error('offline')); + const results = await applyShareChanges([change('mailbox', 'a', MAILBOX_PRESETS.read)]); + expect(results).toEqual([{ change: expect.objectContaining({ id: 'a' }), ok: false, error: 'offline' }]); + }); + + it('sends nothing once the login the page was opened in is no longer active', async () => { + mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls)); + const results = await applyShareChanges([change('mailbox', 'a', MAILBOX_PRESETS.read)], () => false); + expect(mockRequest).not.toHaveBeenCalled(); + expect(results).toEqual([{ change: expect.objectContaining({ id: 'a' }), ok: false, error: LOGIN_SWITCHED }]); + }); +}); + +describe('findPrincipalByEmail', () => { + it('finds the principal with that address, whatever its case', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Principal/query', { ids: ['ck'] }, '0'], + ['Principal/get', { list: [{ id: 'ck', type: 'individual', name: 'Carol', email: 'org-carol@example.org' }] }, '1'], + ], + }); + const found = await findPrincipalByEmail(' Org-Carol@Example.org '); + expect(found).toEqual({ principal: expect.objectContaining({ id: 'ck' }), status: 'ok' }); + expect(sent()[0].calls[0][1].filter).toEqual({ email: 'org-carol@example.org' }); + }); + + it('says when directory queries are off (Stalwart 1.0)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['error', { type: 'forbidden', description: 'The administrator has disabled directory queries.' }, '0'], + ['error', { type: 'invalidResultReference' }, '1'], + ], + }); + expect(await findPrincipalByEmail('org-carol@example.org')).toEqual({ principal: null, status: 'forbidden', directoryQueriesOff: true }); + }); + + it('tells a refusal for another reason apart, and an unknown address', async () => { + mockRequest.mockResolvedValueOnce({ methodResponses: [['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0']] }); + expect(await findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'forbidden' }); + mockRequest.mockResolvedValueOnce({ methodResponses: [['Principal/query', { ids: [] }, '0'], ['Principal/get', { list: [] }, '1']] }); + expect(await findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'ok' }); + }); +}); diff --git a/src/api/__tests__/live-stalwart-delegation.test.ts b/src/api/__tests__/live-stalwart-delegation.test.ts new file mode 100644 index 00000000..f2a3dcfd --- /dev/null +++ b/src/api/__tests__/live-stalwart-delegation.test.ts @@ -0,0 +1,123 @@ +/** + * Live checks of Delegates (Settings > Sharing) against a real Stalwart + * server: the first login gives the second one access to its Inbox, its + * default calendar and its default address book in one batch, the second + * login sees them, and taking the access away removes all of it. + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`, + * `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2` + * defaults to the first password). + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-tablet-dark@example.org BULWARK_LIVE_PASS=… \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * npx vitest run src/api/__tests__/live-stalwart-delegation.test.ts + * + * Every grant to the second login is taken away again afterwards, also + * after a failed check. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { applyShareChanges, getShareTargets, supportsDelegation, supportsMailboxSharing } from '../delegation'; +import { buildGrantChanges, buildRevokeChanges, summarizeDelegates } from '../../lib/delegation/delegation'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS; +const live = SERVER && PASS && USER2 ? describe : describe.skip; + +type Response = [string, Record, string]; + +let owner: JMAPClient; +let delegate: JMAPClient; + +async function useOwner(): Promise { + const mod = await import('../jmap-client'); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot()); +} + +/** Takes every grant of the delegate away (afterAll and the remove step). */ +async function revokeAll(): Promise { + await useOwner(); + const { targets } = await getShareTargets(); + const results = await applyShareChanges(buildRevokeChanges(targets, delegate.accountId)); + return results.every((r) => r.ok); +} + +/** What the delegate sees of the owner's account: its folders, calendars and address books. */ +async function delegateView(): Promise<{ inSession: boolean; mailboxes: string[]; calendars: string[]; addressBooks: string[] }> { + const session = await delegate.refreshSession(); + const id = owner.accountId; + const inSession = !!session.accounts?.[id]; + const read = async (method: string, using: string[]) => { + if (!inSession) return []; + const res = (await delegate.request([[method, { accountId: id, ids: null, properties: ['id', 'name'] }, '0']], using)).methodResponses as Response[]; + return res[0][0] === method ? (res[0][1].list as { id: string }[]).map((x) => x.id) : []; + }; + return { + inSession, + mailboxes: await read('Mailbox/get', [CAPABILITIES.CORE, CAPABILITIES.MAIL]), + calendars: await read('Calendar/get', [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]), + addressBooks: await read('AddressBook/get', [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]), + }; +} + +live('live Stalwart: delegates', () => { + beforeAll(async () => { + owner = new JMAPClient(); + await owner.connect(SERVER!, USER, PASS); + delegate = new JMAPClient(); + await delegate.connect(SERVER!, USER2!, PASS2); + await useOwner(); + }, 60_000); + + afterAll(async () => { + if (owner && delegate) await revokeAll().catch(() => undefined); + }, 60_000); + + it('gives the second login the Inbox, the default calendar and address book in one step, and takes it all back', async () => { + expect(supportsDelegation({ calendarsEnabled: true, contactsEnabled: true })).toBe(true); + // The webmail shares folders where the account lists mail:share, on 0.16 as on 1.0. + expect(supportsMailboxSharing()).toBe(true); + + const { targets, failed } = await getShareTargets(); + expect(failed).toEqual([]); + const principalId = delegate.accountId; + const changes = buildGrantChanges(targets, principalId, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'read'); + const inbox = targets.find((t) => t.kind === 'mailbox' && t.role === 'inbox')!; + expect(changes.map((c) => c.id)).toContain(inbox.id); + // One batch over every type the account has. + for (const kind of ['calendar', 'addressBook'] as const) { + if (targets.some((t) => t.kind === kind)) expect(changes.filter((c) => c.kind === kind)).toHaveLength(1); + } + + const results = await applyShareChanges(changes); + expect(results.filter((r) => !r.ok)).toEqual([]); + + // The owner's list shows the delegate with the Inbox. + const after = await getShareTargets(); + const summary = summarizeDelegates(after.targets, [owner.accountId]).find((s) => s.principalId === principalId); + expect(summary?.mail).toMatchObject({ shared: 1, inbox: true }); + expect(summary?.level).toBe('read'); + + // The delegate sees the owner's account and exactly what was shared. + const seen = await delegateView(); + expect(seen.inSession).toBe(true); + expect(seen.mailboxes).toEqual([inbox.id]); + for (const c of changes.filter((x) => x.kind === 'calendar')) expect(seen.calendars).toContain(c.id); + for (const c of changes.filter((x) => x.kind === 'addressBook')) expect(seen.addressBooks).toContain(c.id); + + // Remove access: nothing of the owner's is left for the delegate. + expect(await revokeAll()).toBe(true); + const revoked = await getShareTargets(); + expect(summarizeDelegates(revoked.targets).some((s) => s.principalId === principalId)).toBe(false); + const gone = await delegateView(); + expect(gone.mailboxes).toEqual([]); + expect(gone.calendars.filter((id) => changes.some((c) => c.id === id))).toEqual([]); + expect(gone.addressBooks.filter((id) => changes.some((c) => c.id === id))).toEqual([]); + }, 120_000); +}); diff --git a/src/api/delegation.ts b/src/api/delegation.ts new file mode 100644 index 00000000..906aa79d --- /dev/null +++ b/src/api/delegation.ts @@ -0,0 +1,264 @@ +// Delegates (webmail JMAPClient.getShareTargets, applyShareChanges, +// findPrincipalByEmail and the capability checks Settings > Sharing uses): +// the JMAP side of giving someone access to mail, calendars and contacts in +// one step. The pure parts are in lib/delegation. + +import { jmapClient } from './jmap-client'; +import { CAPABILITIES, type JMAPMethodCall, type Principal } from './types'; +import { getAllFileNodes, filesAccountId, supportsFiles } from './files'; +import { batched } from './jmap-result'; +import { + SET_METHOD, + buildShareSetCalls, + failAll, + parseShareTargets, + readShareSetResults, + type ShareChange, + type ShareChangeResult, + type ShareTarget, +} from '../lib/delegation/share-batch'; +import type { AnyRights, ShareKind } from '../lib/delegation/share-rights'; + +export const MAIL_SHARE_CAPABILITY = 'urn:ietf:params:jmap:mail:share'; + +type MethodResponse = [string, Record, string]; + +function accountCaps(accountId: string): Record | undefined { + return jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities; +} + +/** The capability is in the account's own accountCapabilities (webmail hasAccountCapability). */ +function hasAccountCapability(capability: string, accountId: string): boolean { + const caps = accountCaps(accountId); + return !!caps && capability in caps; +} + +/** + * Whether the user may use a capability's methods with an account (webmail + * JMAPClient.accountSupports): the account's own list decides; a shared + * account that lists nothing falls back to the server-wide capability. + */ +function accountSupports(accountId: string, capability: string): boolean { + const session = jmapClient.currentSession; + const account = session?.accounts?.[accountId]; + if (!account) return false; + const caps = account.accountCapabilities; + if (caps?.[capability]) return true; + if (caps && Object.keys(caps).length > 0) return false; + return account.isPersonal === false && !!session?.capabilities?.[capability]; +} + +export function getCalendarsAccountId(): string { + return jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CALENDARS] || jmapClient.accountId; +} + +export function getContactsAccountId(): string { + return jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS] || jmapClient.accountId; +} + +/** Whether the account's server accepts `shareWith` on Mailbox objects. */ +export function supportsMailboxSharing(accountId: string = jmapClient.accountId): boolean { + return hasAccountCapability(MAIL_SHARE_CAPABILITY, accountId); +} + +export function supportsCalendarSharing(accountId: string = getCalendarsAccountId()): boolean { + return accountSupports(accountId, CAPABILITIES.CALENDARS); +} + +export function supportsContactSharing(accountId: string = getContactsAccountId()): boolean { + return accountSupports(accountId, CAPABILITIES.CONTACTS); +} + +export function supportsPrincipals(): boolean { + return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS); +} + +/** + * Whether Settings offers Delegates: someone to share with (principals) and + * something to share (webmail settings-app supportsSharing). + */ +export function supportsDelegation(gates: { calendarsEnabled: boolean; contactsEnabled: boolean }): boolean { + if (!jmapClient.currentSession || !supportsPrincipals()) return false; + return supportsMailboxSharing() + || (supportsCalendarSharing() && gates.calendarsEnabled) + || (supportsContactSharing() && gates.contactsEnabled); +} + +/** + * Whether the server keeps a folder's "mark as read" and keyword rights as + * one right, so a read-only share can also flag mail. Stalwart 1.0 does; + * every 1.0 account lists principals:owner, which 0.16 never does. + */ +export function sharesSeenWithKeywords(accountId: string = jmapClient.accountId): boolean { + return hasAccountCapability(CAPABILITIES.PRINCIPALS_OWNER, accountId); +} + +function withOwner(using: string[]): string[] { + return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS_OWNER) ? [...using, CAPABILITIES.PRINCIPALS_OWNER] : using; +} + +const MAILBOX_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, MAIL_SHARE_CAPABILITY]; +const calendarUsing = () => withOwner([CAPABILITIES.CORE, CAPABILITIES.CALENDARS]); +const contactUsing = () => withOwner([CAPABILITIES.CORE, CAPABILITIES.CONTACTS]); +const fileUsing = () => (jmapClient.hasCapability(CAPABILITIES.FILES) ? [CAPABILITIES.CORE, CAPABILITIES.FILES] : [CAPABILITIES.CORE]); + +/** + * Every folder, calendar and address book of the user's own account with + * its `shareWith`, and the files that are shared with someone. A type the + * account can't share is left out; one whose read failed is in `failed`, + * so the caller doesn't mistake it for "shared with nobody". + */ +export async function getShareTargets( + include: { calendars?: boolean; contacts?: boolean; files?: boolean } = {}, +): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> { + const calls: JMAPMethodCall[] = []; + const using = new Set([CAPABILITIES.CORE]); + const accounts: { mailbox?: string; calendar?: string; addressBook?: string } = {}; + const mailAccountId = jmapClient.accountId; + if (supportsMailboxSharing(mailAccountId)) { + accounts.mailbox = mailAccountId; + calls.push(['Mailbox/get', { accountId: mailAccountId, ids: null, properties: ['id', 'name', 'role', 'parentId', 'shareWith'] }, 'm']); + for (const u of MAILBOX_USING) using.add(u); + } + const calendarsAccountId = getCalendarsAccountId(); + if (include.calendars !== false && supportsCalendarSharing(calendarsAccountId)) { + accounts.calendar = calendarsAccountId; + calls.push(['Calendar/get', { accountId: calendarsAccountId, ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'c']); + for (const u of calendarUsing()) using.add(u); + } + const contactsAccountId = getContactsAccountId(); + if (include.contacts !== false && supportsContactSharing(contactsAccountId)) { + accounts.addressBook = contactsAccountId; + calls.push(['AddressBook/get', { accountId: contactsAccountId, ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'a']); + for (const u of contactUsing()) using.add(u); + } + + const readCollections = async (): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> => { + if (calls.length === 0) return { targets: [], failed: [] }; + try { + const res = await jmapClient.request(calls, [...using]); + return parseShareTargets(res.methodResponses as MethodResponse[], accounts); + } catch (err) { + console.warn('[delegation] reading sharing failed', err); + return { targets: [], failed: Object.keys(accounts) as ShareKind[] }; + } + }; + const readFiles = async (): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> => { + if (include.files === false || !supportsFiles()) return { targets: [], failed: [] }; + const accountId = filesAccountId(); + try { + const targets: ShareTarget[] = []; + for (const node of await getAllFileNodes()) { + if (!node.shareWith || Object.keys(node.shareWith).length === 0) continue; + targets.push({ + kind: 'file', + accountId, + id: node.id, + name: node.name, + parentId: node.parentId ?? null, + shareWith: node.shareWith as Record, + }); + } + return { targets, failed: [] }; + } catch (err) { + console.warn('[delegation] reading file sharing failed', err); + return { targets: [], failed: ['file'] }; + } + }; + // The file tree can take several requests; it is read alongside the rest. + const [collections, fileTargets] = await Promise.all([readCollections(), readFiles()]); + return { + targets: [...collections.targets, ...fileTargets.targets], + failed: [...collections.failed, ...fileTargets.failed], + }; +} + +/** The reason a change was not sent: another login is active now. */ +export const LOGIN_SWITCHED = 'The account was switched'; + +/** + * Applies `shareWith` changes to many items in as few requests as the + * server's limits allow, and says for each one whether it worked. Never + * throws: a request that fails, or one not sent because `stillSameLogin` + * says the login changed, marks its items failed. + */ +export async function applyShareChanges( + changes: ShareChange[], + stillSameLogin: () => boolean = () => true, +): Promise { + if (changes.length === 0) return []; + const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: jmapClient.getMaxObjectsInSet() }); + const usingFor: Record = { + [SET_METHOD.mailbox]: MAILBOX_USING, + [SET_METHOD.calendar]: calendarUsing(), + [SET_METHOD.addressBook]: contactUsing(), + [SET_METHOD.file]: fileUsing(), + }; + const results: ShareChangeResult[] = []; + for (const group of batched(calls, jmapClient.getMaxCallsInRequest())) { + if (!stillSameLogin()) { + results.push(...failAll(group, changesByCall, new Error(LOGIN_SWITCHED))); + continue; + } + const using = [...new Set(group.flatMap(([method]) => usingFor[method] ?? []))]; + try { + const res = await jmapClient.request(group, using); + results.push(...readShareSetResults(group, changesByCall, res.methodResponses as MethodResponse[])); + } catch (err) { + results.push(...failAll(group, changesByCall, err)); + } + } + return results; +} + +// Same as api/files: the Principal properties the share sheets read, and +// Stalwart 1.0's answer while directory queries are off (its default). +const PRINCIPAL_PROPERTIES = ['id', 'type', 'name', 'description', 'email']; +const DIRECTORY_QUERIES_OFF = 'The administrator has disabled directory queries.'; + +export interface PrincipalLookup { + principal: Principal | null; + status: 'ok' | 'forbidden' | 'error'; + directoryQueriesOff?: boolean; +} + +/** + * The principal with this address, through a directory query. `forbidden` + * when the server refuses queries (Stalwart 1.0 with directory queries off, + * the default), with `directoryQueriesOff` when that is the reason. + */ +export async function findPrincipalByEmail(email: string): Promise { + if (!supportsPrincipals()) return { principal: null, status: 'error' }; + const wanted = email.trim().toLowerCase(); + const accountId = jmapClient.accountId; + try { + const res = await jmapClient.request( + [ + ['Principal/query', { accountId, filter: { email: wanted }, limit: 10 }, '0'], + ['Principal/get', { + accountId, + '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' }, + properties: PRINCIPAL_PROPERTIES, + }, '1'], + ], + [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS], + ); + const responses = res.methodResponses ?? []; + const queryResp = responses.find((r) => r[0] === 'Principal/query'); + const getResp = responses.find((r) => r[0] === 'Principal/get'); + if (!queryResp || !getResp) { + // A refused query makes the chained get fail; only the query's error says why. + const error = responses.find((r) => r[0] === 'error')?.[1] as { type?: string; description?: string } | undefined; + if (error?.type === 'forbidden') { + return { principal: null, status: 'forbidden', ...(error.description === DIRECTORY_QUERIES_OFF ? { directoryQueriesOff: true } : {}) }; + } + return { principal: null, status: 'error' }; + } + const list = (getResp[1].list ?? []) as Principal[]; + const principal = list.find((p) => p.email?.toLowerCase() === wanted || p.name?.toLowerCase() === wanted) ?? null; + return { principal, status: 'ok' }; + } catch (err) { + console.warn('[delegation] looking up a principal failed', err); + return { principal: null, status: 'error' }; + } +} diff --git a/src/components/settings/DelegateDialog.tsx b/src/components/settings/DelegateDialog.tsx new file mode 100644 index 00000000..28bd89ff --- /dev/null +++ b/src/components/settings/DelegateDialog.tsx @@ -0,0 +1,536 @@ +import React, { useMemo, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Info, X } from '../icons'; +import Button from '../Button'; +import SenderAvatar from '../SenderAvatar'; +import { SafeAreaModal } from '../SafeAreaModal'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { findPrincipalByEmail } from '../../api/delegation'; +import type { Principal } from '../../api/types'; +import type { PrincipalListing } from '../../api/files'; +import type { ShareChange, ShareChangeResult, ShareTarget } from '../../lib/delegation/share-batch'; +import type { AccessLevel } from '../../lib/delegation/share-rights'; +import { + buildGrantChanges, + DEFAULT_DELEGATE_SCOPE, + failedChanges, + scopeIsEmpty, + type CollectionScope, + type DelegateScope, + type LevelChoice, + type MailScope, +} from '../../lib/delegation/delegation'; +import { + canLookUpAddress, + delegateCandidates, + lookupResult, + principalDisplay, + type Lookup, +} from '../../lib/delegation/display'; + +// Ported from the webmail's components/settings/delegates/delegate-dialog.tsx: +// "Give access" and "Change". One person, what they get, and with which +// rights, applied to every folder, calendar and address book in one batch. + +export interface DelegateDialogProps { + targets: ShareTarget[]; + directory: PrincipalListing | null; + /** The user's own principal ids, never offered. */ + ownIds: string[]; + /** The user's own addresses: typing one says "That's you". */ + ownEmails: string[]; + /** Ids that already have access to something. */ + existingIds: Set; + /** Set for "Change": the person, and what they have now. */ + initial?: { principal: Principal; scope: DelegateScope; level: LevelChoice }; + levels: AccessLevel[]; + /** The admin limited the rights (sharingRights). */ + levelsRestricted: boolean; + /** The server keeps "mark as read" and flags as one right on folders (Stalwart 1.0). */ + seenSetsKeywords: boolean; + /** Sends the changes; resolves to a result per change. */ + apply: (changes: ShareChange[]) => Promise; + onApplied: (principal: Principal, results: ShareChangeResult[]) => void; + onClose: () => void; +} + +export function DelegateDialog({ + targets, directory, ownIds, ownEmails, existingIds, initial, levels, levelsRestricted, seenSetsKeywords, apply, onApplied, onClose, +}: DelegateDialogProps) { + const c = useColors(); + const styles = useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const changing = !!initial; + + const [principal, setPrincipal] = useState(initial?.principal ?? null); + const [query, setQuery] = useState(''); + const [lookup, setLookup] = useState({ state: 'idle' }); + const [scope, setScope] = useState(initial?.scope ?? DEFAULT_DELEGATE_SCOPE); + const [level, setLevel] = useState(() => { + const wanted = initial?.level ?? 'read'; + return wanted === 'keep' || levels.includes(wanted) ? wanted : levels[0]; + }); + // "Keep as it is" is offered where Change started with it, and stays offered. + const keep = { + mail: initial?.scope.mail === 'keep', + calendars: initial?.scope.calendars === 'keep', + addressBooks: initial?.scope.addressBooks === 'keep', + level: initial?.level === 'keep', + }; + const [saving, setSaving] = useState(false); + const [failures, setFailures] = useState(null); + + const has = (kind: ShareTarget['kind']) => targets.some((target) => target.kind === kind); + const directoryOff = directory?.status === 'restricted' && !!directory.directoryQueriesOff; + + const candidates = useMemo( + () => delegateCandidates(directory?.principals ?? [], ownIds, query), + [directory, ownIds, query], + ); + const typedAddress = query.trim(); + const canLookUp = canLookUpAddress(query, candidates); + + const lookUp = async () => { + if (!canLookUp || lookup.state === 'busy') return; + if (ownEmails.some((email) => email.toLowerCase() === typedAddress.toLowerCase())) { + setLookup({ state: 'self' }); + return; + } + setLookup({ state: 'busy' }); + const found = await findPrincipalByEmail(typedAddress); + const result = lookupResult(typedAddress, found, ownIds); + if (result.picked) setPrincipal(result.picked); + setLookup(result.lookup); + }; + + const send = async (changes: ShareChange[]) => { + if (!principal) return; + setSaving(true); + try { + const results = await apply(changes); + const failed = failedChanges(results); + onApplied(principal, results); + if (failed.length === 0) onClose(); + else setFailures(failed); + } finally { + setSaving(false); + } + }; + + const changes = useMemo( + () => (principal ? buildGrantChanges(targets, principal.id, scope, level, { replace: changing }) : []), + [targets, principal, scope, level, changing], + ); + const nothingToDo = changing ? changes.length === 0 : scopeIsEmpty(scope); + + const submit = () => { + if (!principal || nothingToDo) return; + void send(changes); + }; + const retry = () => { + if (failures) void send(failures.map((f) => f.change)); + }; + const close = () => { + if (!saving) onClose(); + }; + + const keepLabel = t('delegates.keep', 'Keep as it is'); + const none = t('delegates.none', 'None'); + + return ( + + + + + + {changing && principal + ? t('delegates.dialog_title_change', 'Change access for {name}', { name: principalDisplay(principal).name }) + : t('delegates.dialog_title_add', 'Give access')} + + + + + + + {failures ? ( + + + {t('delegates.partial_title', "These couldn't be changed. The rest is done.")} + + + {failures.map((f, index) => ( + 0 && styles.rowBorder]}> + + {f.change.name} + {' · '}{t(`delegates.kind.${f.change.kind}`, f.change.kind)} + + {f.error ? {f.error} : null} + + ))} + + + ) : ( + + + {t('delegates.person', 'Person')} + {principal ? ( + + + + {!changing && ( + + )} + + ) : ( + + { setQuery(text); setLookup({ state: 'idle' }); }} + onSubmitEditing={() => void lookUp()} + placeholder={t('delegates.search_placeholder', 'Name or email address')} + placeholderTextColor={c.textMuted} + accessibilityLabel={t('delegates.person', 'Person')} + autoCapitalize="none" + autoCorrect={false} + keyboardType="email-address" + returnKeyType="search" + autoFocus + style={styles.input} + /> + {directory === null ? ( + + + {t('delegates.loading', 'Loading…')} + + ) : ( + + {candidates.length === 0 && !canLookUp && ( + {t('delegates.no_match', 'Nobody found.')} + )} + {candidates.slice(0, 50).map((p, index) => ( + setPrincipal(p)} + accessibilityRole="button" + style={({ pressed }) => [styles.candidate, index > 0 && styles.rowBorder, pressed && styles.pressed]} + > + + + {existingIds.has(p.id) && {t('delegates.has_access', 'Has access')}} + + ))} + {canLookUp && ( + void lookUp()} + disabled={lookup.state === 'busy'} + accessibilityRole="button" + style={({ pressed }) => [styles.candidate, candidates.length > 0 && styles.rowBorder, pressed && styles.pressed]} + > + {lookup.state === 'busy' && } + + {lookup.state === 'busy' + ? t('delegates.looking_up', 'Looking up…') + : t('delegates.use_address', 'Use {address}', { address: typedAddress })} + + + )} + + )} + + {lookup.state === 'notFound' && ( + {t('delegates.not_found', 'Nobody on this server has the address {address}.', { address: lookup.address })} + )} + {lookup.state === 'failed' && {t('delegates.lookup_failed', "Couldn't look up this address.")}} + {lookup.state === 'self' && {t('delegates.is_you', "That's you.")}} + {(directoryOff || lookup.state === 'directoryOff') && ( + + {directoryOff ? `${t('delegates.known_only', 'Only your groups and people who have shared something with you are listed.')} ` : ''} + {t('delegates.directory_off_note', 'Your administrator has to turn on directory lookups in Stalwart before you can add someone new.')} + + )} + {directory?.status === 'error' && ( + {t('delegates.directory_failed', "Couldn't load the list of people and groups.")} + )} + + + )} + + + + {t('delegates.what', 'What')} + {has('mailbox') && ( + + label={t('delegates.mail', 'Mail')} + value={scope.mail} + onChange={(mail) => setScope({ ...scope, mail })} + options={[ + ...keepOption(keep.mail, keepLabel), + ['all', t('delegates.mail_all', 'All folders')], + ['inbox', t('delegates.mail_inbox', 'Inbox only')], + ['none', none], + ]} + styles={styles} + /> + )} + {has('calendar') && ( + + label={t('delegates.calendars', 'Calendars')} + value={scope.calendars} + onChange={(calendars) => setScope({ ...scope, calendars })} + options={[ + ...keepOption(keep.calendars, keepLabel), + ['default', t('delegates.calendars_default', 'Default calendar')], + ['all', t('delegates.calendars_all', 'All calendars')], + ['none', none], + ]} + styles={styles} + /> + )} + {has('addressBook') && ( + + label={t('delegates.address_books', 'Contacts')} + value={scope.addressBooks} + onChange={(addressBooks) => setScope({ ...scope, addressBooks })} + options={[ + ...keepOption(keep.addressBooks, keepLabel), + ['default', t('delegates.address_books_default', 'Default address book')], + ['all', t('delegates.address_books_all', 'All address books')], + ['none', none], + ]} + styles={styles} + /> + )} + {!changing && scopeIsEmpty(scope) && ( + {t('delegates.empty_scope', 'Choose mail, calendars or contacts.')} + )} + {changing && ( + + {t('delegates.change_note', 'Folders, calendars and address books you leave out lose access. Shared files stay as they are; Remove access takes those away too.')} + + )} + + + + {t('delegates.rights', 'Rights')} + {[...(keep.level ? ['keep' as const] : []), ...levels].map((option) => { + const selected = level === option; + const hintKey = option === 'read' && seenSetsKeywords ? 'delegates.rights_read_hint_flags' : `delegates.rights_${option}_hint`; + return ( + setLevel(option)} + accessibilityRole="radio" + accessibilityState={{ selected }} + style={styles.radioRow} + > + + {selected && } + + + {t(`delegates.rights_${option}`, option)} + {t(hintKey, '')} + + + ); + })} + {levelsRestricted && ( + {t('settings.org_locked', 'Set by your organisation')} + )} + + + {changing && nothingToDo && ( + + {t('delegates.nothing_to_change', 'Nothing to change. Pick what they should get, or use Remove access.')} + + )} + + + + + {t('delegates.send_note', "Delegates can read and organise your mail and calendar. Stalwart doesn't let them send as you or on your behalf. To share one address between several people, ask your administrator for a shared mailbox.")} + + + + )} + + + {failures ? ( + <> + + + + ) : ( + <> + + + + )} + + + + + ); +} + +type Styles = ReturnType; + +function PrincipalText({ principal, styles, groupLabel }: { principal: Principal; styles: Styles; groupLabel: string }) { + const { name, detail } = principalDisplay(principal); + return ( + + + {name} + {principal.type === 'group' && {groupLabel}} + + {detail ? {detail} : null} + + ); +} + +function keepOption(offered: boolean, label: string): [T, string][] { + return offered ? [['keep' as T, label]] : []; +} + +function Choice({ label, value, onChange, options, styles }: { + label: string; + value: T; + onChange: (value: T) => void; + options: [T, string][]; + styles: Styles; +}) { + return ( + + {label} + + {options.map(([option, text]) => { + const selected = value === option; + return ( + onChange(option)} + accessibilityRole="radio" + accessibilityState={{ selected }} + hitSlop={4} + style={[styles.chip, selected && styles.chipOn]} + > + {text} + + ); + })} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: c.background }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + title: { ...typography.h3, color: c.text, flex: 1 }, + closeBtn: { padding: 6, borderRadius: radius.md }, + body: { flex: 1 }, + bodyContent: { padding: spacing.lg, gap: spacing.xl, width: '100%', maxWidth: 640, alignSelf: 'center' }, + field: { gap: spacing.sm }, + label: { ...typography.bodyMedium, color: c.text }, + text: { ...typography.body, color: c.text }, + textStrong: { ...typography.bodyMedium, color: c.text, flexShrink: 1 }, + small: { ...typography.caption, color: c.textMuted }, + errorSmall: { ...typography.caption, color: c.error }, + inline: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + input: { + minHeight: 44, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + color: c.text, + backgroundColor: c.surface, + ...typography.body, + }, + box: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, overflow: 'hidden' }, + candidate: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.md, minHeight: 48, paddingVertical: spacing.sm }, + candidatePad: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm }, + rowBorder: { borderTopWidth: 1, borderTopColor: c.border }, + pressed: { backgroundColor: c.muted }, + pickedCard: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + principalText: { flex: 1, minWidth: 0 }, + groupTag: { ...typography.caption, color: c.textMuted, backgroundColor: c.muted, paddingHorizontal: 4, borderRadius: radius.sm }, + failRow: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, gap: 2 }, + choice: { gap: spacing.xs }, + choiceLabel: { ...typography.body, color: c.textMuted }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: 6, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + }, + chipOn: { borderColor: c.primary, backgroundColor: c.muted }, + chipText: { ...typography.caption, color: c.textMuted }, + chipTextOn: { color: c.text }, + radioRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, paddingVertical: spacing.xs }, + radio: { + width: 20, height: 20, borderRadius: 10, + borderWidth: 2, borderColor: c.border, + alignItems: 'center', justifyContent: 'center', + marginTop: 1, + }, + radioOn: { borderColor: c.primary }, + radioDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: c.primary }, + radioText: { flex: 1, gap: 2 }, + note: { flexDirection: 'row', gap: spacing.sm, backgroundColor: c.muted, borderRadius: radius.md, padding: spacing.md }, + noteText: { flex: 1 }, + footer: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderTopWidth: 1, + borderTopColor: c.border, + }, + }); +} diff --git a/src/components/settings/DelegateSettings.tsx b/src/components/settings/DelegateSettings.tsx new file mode 100644 index 00000000..c349b279 --- /dev/null +++ b/src/components/settings/DelegateSettings.tsx @@ -0,0 +1,302 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native'; +import { UserPlus } from '../icons'; +import Button from '../Button'; +import SenderAvatar from '../SenderAvatar'; +import { SettingsSection } from './settings-section'; +import { DelegateDialog } from './DelegateDialog'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useAuthStore } from '../../stores/auth-store'; +import { useAccountStore } from '../../stores/account-store'; +import { toast } from '../../stores/toast-store'; +import { jmapClient } from '../../api/jmap-client'; +import { listPrincipals, type PrincipalListing } from '../../api/files'; +import { applyShareChanges, getShareTargets, sharesSeenWithKeywords } from '../../api/delegation'; +import type { Principal } from '../../api/types'; +import { servedLoginOf } from '../../lib/draft-login'; +import { loginMovedFrom } from '../../lib/compose-login'; +import type { ShareChange, ShareChangeResult, ShareTarget } from '../../lib/delegation/share-batch'; +import type { ShareKind } from '../../lib/delegation/share-rights'; +import { + allowedLevels, + buildRevokeChanges, + changeStartFor, + failedChanges, + summarizeDelegates, + type DelegateSummary, +} from '../../lib/delegation/delegation'; +import { describeSummary, principalDisplay, resolvePrincipal } from '../../lib/delegation/display'; +import { + pruneRememberedPrincipals, + rememberPrincipals, + rememberedPrincipals, +} from '../../lib/delegation/known-principals'; +import { delegationPolicy } from '../../lib/delegation/policy'; + +// Ported from the webmail's components/settings/delegates/delegates-settings.tsx: +// Settings > Sharing > Delegates. Give someone access to mail, calendars and +// contacts in one step, and see and take back who has access to what. The +// admin's hiding, rights limit and app gates come from lib/delegation/policy +// (nothing is limited until the app has the org policy). + +// Names kept for a login go with it: removed from the app, or everyone signed out. +useAccountStore.subscribe((state, prev) => { + if (state.accounts !== prev.accounts) void pruneRememberedPrincipals(state.accounts.map((a) => a.id)); +}); + +type DialogState = { mode: 'add' } | { mode: 'change'; summary: DelegateSummary } | null; + +export function DelegateSettings() { + const c = useColors(); + const styles = useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const session = useAuthStore((s) => s.session); + const accountKey = useAuthStore((s) => s.activeAccountId) ?? ''; + const username = useAuthStore((s) => s.username); + const policy = delegationPolicy(); + const levels = useMemo(() => allowedLevels(policy.allowedValues), [policy.allowedValues]); + const { calendarsEnabled, contactsEnabled, filesEnabled } = policy; + + const [targets, setTargets] = useState(null); + const [failedKinds, setFailedKinds] = useState([]); + const [directory, setDirectory] = useState(null); + const [remembered, setRemembered] = useState>(() => new Map()); + const [dialog, setDialog] = useState(null); + const [removingId, setRemovingId] = useState(null); + + // The login the page was read in: changes are sent only while it is active. + const ownerRef = useRef(null); + // Each read is numbered; an answer that arrives after a newer read started + // (another account) is dropped instead of shown. + const readSeq = useRef(0); + const reload = useCallback(async () => { + const seq = ++readSeq.current; + const owner = servedLoginOf(jmapClient); + const result = await getShareTargets({ calendars: calendarsEnabled, contacts: contactsEnabled, files: filesEnabled }); + if (seq !== readSeq.current || loginMovedFrom(owner)) return; + ownerRef.current = owner; + setTargets(result.targets); + setFailedKinds(result.failed); + }, [calendarsEnabled, contactsEnabled, filesEnabled]); + + useEffect(() => { + if (!session || policy.hidden) return undefined; + let cancelled = false; + const reads = readSeq; + setTargets(null); + setFailedKinds([]); + setDirectory(null); + setDialog(null); + void rememberedPrincipals(accountKey).then((map) => { + if (!cancelled) setRemembered(map); + }); + void reload(); + void listPrincipals().then((listing) => { + if (!cancelled) setDirectory(listing); + }); + return () => { + cancelled = true; + reads.current++; + }; + }, [session, accountKey, policy.hidden, reload]); + + const ownId = session ? jmapClient.accountId : ''; + const ownIds = useMemo(() => (ownId ? [ownId] : []), [ownId]); + const ownEmails = useMemo(() => { + const emails = new Set(); + if (username?.includes('@')) emails.add(username); + for (const p of directory?.principals ?? []) if (ownIds.includes(p.id) && p.email) emails.add(p.email); + return [...emails]; + }, [username, directory, ownIds]); + const summaries = useMemo(() => (targets ? summarizeDelegates(targets, ownIds) : []), [targets, ownIds]); + + // An account shared with the user is in the session under the owner's address. + const sessionAccounts = useMemo(() => { + const map = new Map(); + for (const [id, info] of Object.entries(session?.accounts ?? {})) { + if (id !== ownId && info.name) map.set(id, info.name); + } + return map; + }, [session, ownId]); + const principalFor = useCallback( + (id: string) => resolvePrincipal(id, { directory: directory?.principals ?? [], remembered, sessionAccounts }), + [directory, remembered, sessionAccounts], + ); + + // Remember the names of the people who have access, so the list can name + // them when the directory no longer does. Never the whole directory. + useEffect(() => { + if (!accountKey || summaries.length === 0 || !directory) return; + const named = summaries + .map((s) => directory.principals.find((p) => p.id === s.principalId)) + .filter((p): p is Principal => !!p); + if (named.length > 0) void rememberPrincipals(accountKey, named); + }, [accountKey, summaries, directory]); + + if (policy.hidden || !session) return null; + + // Change and Remove act on everything the person has; with a type unread, + // that isn't known, so they wait until a read succeeds. + const incomplete = failedKinds.length > 0; + + const send = (changes: ShareChange[]): Promise => { + const owner = ownerRef.current; + return applyShareChanges(changes, () => !loginMovedFrom(owner)); + }; + + const onApplied = (principal: Principal, results: ShareChangeResult[]) => { + void rememberPrincipals(accountKey, [principal]).then(() => rememberedPrincipals(accountKey)).then(setRemembered); + if (failedChanges(results).length === 0) { + toast.success(dialog?.mode === 'change' + ? t('delegates.updated', 'Access updated') + : t('delegates.applied', '{name} now has access', { name: principalDisplay(principal).name })); + } + void reload(); + }; + + const nameOf = (id: string) => { + const principal = principalFor(id); + return principal ? principalDisplay(principal).name : t('delegates.unknown_person', 'Unknown person'); + }; + + const remove = async (summary: DelegateSummary) => { + if (!targets || incomplete) return; + setRemovingId(summary.principalId); + try { + const results = await send(buildRevokeChanges(targets, summary.principalId)); + const failed = failedChanges(results); + if (failed.length === 0) toast.success(t('delegates.removed', 'Access removed')); + else toast.error(t('delegates.remove_partial', 'These still have access: {items}', { items: failed.map((f) => f.change.name).join(', ') })); + await reload(); + } finally { + setRemovingId(null); + } + }; + + const confirmRemove = (summary: DelegateSummary) => { + const name = nameOf(summary.principalId); + Alert.alert( + t('delegates.remove_title', 'Remove access for {name}?', { name }), + t('delegates.remove_message', '{name} loses access to everything you shared from your own account: mail, calendars, contacts and files.', { name }), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { text: t('delegates.remove', 'Remove access'), style: 'destructive', onPress: () => void remove(summary) }, + ], + ); + }; + + return ( + + + + + + + {t('delegates.who_has_access', 'Who has access')} + {incomplete && ( + + + {t('delegates.read_failed', "Some of your sharing couldn't be read, so Change and Remove access wait until it can.")} + + + + )} + {targets === null ? ( + + + {t('delegates.loading', 'Loading…')} + + ) : summaries.length === 0 ? ( + {t('delegates.nobody', 'Nobody has access to your mail, calendars, contacts or files.')} + ) : ( + + {summaries.map((summary, index) => { + const principal = principalFor(summary.principalId); + const display = principal + ? principalDisplay(principal) + : { name: t('delegates.unknown_person', 'Unknown person'), detail: summary.principalId }; + const busy = removingId === summary.principalId; + return ( + 0 && styles.rowBorder]}> + + + + + {display.name} + {principal?.type === 'group' && {t('delegates.group', 'Group')}} + + {display.detail ? {display.detail} : null} + {describeSummary(summary, t)} + + + + + + + + ); + })} + + )} + + + {dialog && targets && ( + s.principalId))} + initial={dialog.mode === 'change' + ? { + principal: principalFor(dialog.summary.principalId) + ?? { id: dialog.summary.principalId, name: t('delegates.unknown_person', 'Unknown person'), type: 'individual' }, + ...changeStartFor(targets, dialog.summary.principalId, levels), + } + : undefined} + levels={levels} + levelsRestricted={policy.allowedValues !== null && levels.length < 2} + seenSetsKeywords={sharesSeenWithKeywords()} + apply={send} + onApplied={onApplied} + onClose={() => setDialog(null)} + /> + )} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + giveRow: { flexDirection: 'row', marginTop: spacing.sm }, + block: { gap: spacing.sm, marginTop: spacing.lg }, + heading: { ...typography.bodyMedium, color: c.text }, + inline: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm }, + muted: { ...typography.body, color: c.textMuted }, + small: { ...typography.caption, color: c.textMuted }, + errorSmall: { ...typography.caption, color: c.error, flexShrink: 1 }, + list: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, overflow: 'hidden' }, + row: { gap: spacing.sm, paddingHorizontal: spacing.md, paddingVertical: spacing.md }, + rowBorder: { borderTopWidth: 1, borderTopColor: c.border }, + rowMain: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + rowText: { flex: 1, minWidth: 0, gap: 2 }, + name: { ...typography.bodyMedium, color: c.text, flexShrink: 1 }, + groupTag: { ...typography.caption, color: c.textMuted, backgroundColor: c.muted, paddingHorizontal: 4, borderRadius: radius.sm }, + rowButtons: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, paddingLeft: 44 }, + }); +} diff --git a/src/lib/__tests__/settings-search-recovery.test.ts b/src/lib/__tests__/settings-search-recovery.test.ts index 398acf29..852677e5 100644 --- a/src/lib/__tests__/settings-search-recovery.test.ts +++ b/src/lib/__tests__/settings-search-recovery.test.ts @@ -2,15 +2,21 @@ import { describe, expect, it } from 'vitest'; import { getDictionary, translate } from '../../i18n'; import { buildSettingsSearchIndex, normalizeSettingsQuery, tabMatchesQuery } from '../settings-search'; -// Settings search finds the Stalwart 1.0 pages (webmail lib/settings-search) -// by the words people use for them. +// Settings search finds the two Stalwart 1.0 pages (webmail lib/settings-search +// `sharing` and `recovery`) by the words people use for them. const en = getDictionary('en'); const tEn = (key: string) => translate('en', key); const index = buildSettingsSearchIndex(en, tEn); -const finds = (tab: 'recovery', label: string, q: string) => tabMatchesQuery(index, tab, label, normalizeSettingsQuery(q)); +const finds = (tab: 'sharing' | 'recovery', label: string, q: string) => tabMatchesQuery(index, tab, label, normalizeSettingsQuery(q)); + +describe('settings search for Delegates and Recover deleted items', () => { + it('finds Delegates under Sharing', () => { + expect(finds('sharing', 'Sharing', 'delegate')).toBe(true); + expect(finds('sharing', 'Sharing', 'Delegates')).toBe(true); + expect(finds('sharing', 'Sharing', 'undelete')).toBe(false); + }); -describe('settings search for Recover deleted items', () => { it('finds Recover deleted items', () => { expect(finds('recovery', 'Recover deleted items', 'undelete')).toBe(true); expect(finds('recovery', 'Recover deleted items', 'emptied')).toBe(true); diff --git a/src/lib/delegation/__tests__/delegation.test.ts b/src/lib/delegation/__tests__/delegation.test.ts new file mode 100644 index 00000000..c0b040c9 --- /dev/null +++ b/src/lib/delegation/__tests__/delegation.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; +import { + allowedLevels, + buildGrantChanges, + buildRevokeChanges, + changeStartFor, + selectTargets, + summarizeDelegates, +} from '../delegation'; +import type { ShareTarget } from '../share-batch'; +import { ADDRESS_BOOK_PRESETS, CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../share-rights'; + +// Ported from the webmail's lib/delegation/__tests__/delegation.test.ts. + +const CAROL = 'ck'; +const BOB = 'cj'; + +// A 1.0-style read grant of a folder: keywords come back with seen. +const MAILBOX_READ_STORED = { ...MAILBOX_PRESETS.read, maySetKeywords: true }; + +function targets(): ShareTarget[] { + return [ + { kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: {} }, + { kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Deleted Items', role: 'trash', shareWith: {} }, + { kind: 'mailbox', accountId: 'ci', id: 'f', name: 'Projects', role: null, shareWith: { [BOB]: MAILBOX_PRESETS.readWrite } }, + { kind: 'calendar', accountId: 'ci', id: 'c1', name: 'Work', isDefault: false, shareWith: {} }, + { kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', isDefault: true, shareWith: {} }, + { kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} }, + { kind: 'addressBook', accountId: 'ci', id: 'ts', name: 'Trusted Senders', isDefault: false, shareWith: {} }, + { kind: 'file', accountId: 'ci', id: 'n1', name: 'report.pdf', shareWith: { [CAROL]: FILE_PRESETS.read } }, + ]; +} + +const ids = (list: { kind: string; id: string }[]) => list.map((t) => `${t.kind}:${t.id}`); + +describe('selectTargets', () => { + it('takes every folder, the default calendar and the default address book by default', () => { + expect(ids(selectTargets(targets(), { mail: 'all', calendars: 'default', addressBooks: 'default' }))).toEqual([ + 'mailbox:a', 'mailbox:b', 'mailbox:f', 'calendar:c2', 'addressBook:ab', + ]); + }); + + it('takes only the Inbox, every calendar, and no address book', () => { + expect(ids(selectTargets(targets(), { mail: 'inbox', calendars: 'all', addressBooks: 'none' }))).toEqual([ + 'mailbox:a', 'calendar:c1', 'calendar:c2', + ]); + }); + + it('leaves out the trusted senders list and files even with "all"', () => { + const picked = ids(selectTargets(targets(), { mail: 'none', calendars: 'none', addressBooks: 'all' })); + expect(picked).toEqual(['addressBook:ab']); + }); + + it('uses the first calendar when none is marked default', () => { + const list = targets().map((t) => (t.kind === 'calendar' ? { ...t, isDefault: false } : t)); + expect(ids(selectTargets(list, { mail: 'none', calendars: 'default', addressBooks: 'none' }))).toEqual(['calendar:c1']); + }); +}); + +describe('buildGrantChanges', () => { + it('builds read patches per type', () => { + const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'read'); + expect(changes).toEqual([ + { kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', principalId: CAROL, rights: MAILBOX_PRESETS.read }, + { kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', principalId: CAROL, rights: CALENDAR_PRESETS.read }, + { kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', principalId: CAROL, rights: ADDRESS_BOOK_PRESETS.read }, + ]); + }); + + it('builds read and write patches that never let a delegate send or re-share', () => { + const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'write'); + const [mail, calendar, book] = changes.map((c) => c.rights as unknown as Record); + expect(mail).toMatchObject({ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetKeywords: true, maySubmit: false, mayShare: false }); + expect(calendar).toMatchObject({ mayReadItems: true, mayWriteAll: true, mayShare: false }); + expect(book).toMatchObject({ mayRead: true, mayWrite: true, mayShare: false }); + }); + + it('takes away what a change leaves out, but never files', () => { + const list = targets(); + list[2].shareWith[CAROL] = MAILBOX_READ_STORED; // Projects + list[3].shareWith[CAROL] = CALENDAR_PRESETS.read; // Work + const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'none' }, 'read', { replace: true }); + expect(changes.filter((c) => c.rights === null).map((c) => `${c.kind}:${c.id}`)).toEqual(['mailbox:f', 'calendar:c1']); + expect(changes.some((c) => c.kind === 'file')).toBe(false); + }); + + it('takes nothing away without replace', () => { + const list = targets(); + list[3].shareWith[CAROL] = CALENDAR_PRESETS.read; + const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, 'read'); + expect(changes.every((c) => c.rights !== null)).toBe(true); + }); +}); + +describe('buildRevokeChanges', () => { + it('removes the person from everything, files included, and leaves others alone', () => { + const list = targets(); + list[0].shareWith[CAROL] = MAILBOX_READ_STORED; + list[4].shareWith[CAROL] = CALENDAR_PRESETS.readWrite; + const changes = buildRevokeChanges(list, CAROL); + expect(changes.map((c) => [`${c.kind}:${c.id}`, c.principalId, c.rights])).toEqual([ + ['mailbox:a', CAROL, null], + ['calendar:c2', CAROL, null], + ['file:n1', CAROL, null], + ]); + }); +}); + +describe('summarizeDelegates', () => { + it('groups grants by person and counts each type', () => { + const list = targets(); + list[0].shareWith[CAROL] = MAILBOX_READ_STORED; + list[1].shareWith[CAROL] = MAILBOX_READ_STORED; + list[4].shareWith[CAROL] = CALENDAR_PRESETS.read; + const summary = summarizeDelegates(list); + expect(summary.map((s) => s.principalId)).toEqual([CAROL, BOB]); + const carol = summary[0]; + expect(carol.level).toBe('read'); + expect(carol.mail).toEqual({ shared: 2, total: 3, inbox: true }); + expect(carol.calendars).toEqual({ shared: 1, total: 2 }); + expect(carol.addressBooks).toEqual({ shared: 0, total: 1 }); + expect(carol.files).toBe(1); + expect(summary[1]).toMatchObject({ level: 'write', mail: { shared: 1, total: 3, inbox: false } }); + }); + + it('says "mixed" when read and write grants meet, and leaves out excluded ids', () => { + const list = targets(); + list[0].shareWith[CAROL] = MAILBOX_PRESETS.readWrite; + const summary = summarizeDelegates(list, [BOB]); + expect(summary.map((s) => [s.principalId, s.level])).toEqual([[CAROL, 'mixed']]); + }); + + it('gives a free/busy-only calendar no level', () => { + const list = targets().filter((t) => t.kind !== 'file' && t.id !== 'f'); + list.find((t) => t.id === 'c1')!.shareWith[CAROL] = CALENDAR_PRESETS.freeBusy; + expect(summarizeDelegates(list)[0].level).toBeNull(); + }); +}); + +describe('changeStartFor', () => { + const withCarol = (list: ShareTarget[], ids: string[], rights: object) => { + for (const t of list) if (ids.includes(t.id)) t.shareWith[CAROL] = rights as never; + return list; + }; + + it('starts from exact matches: every folder, the default calendar, read', () => { + const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_READ_STORED); + withCarol(list, ['c2'], CALENDAR_PRESETS.read); + expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'read' }); + }); + + it('starts from Inbox only and all calendars, read and write', () => { + const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite); + withCarol(list, ['c1', 'c2'], CALENDAR_PRESETS.readWrite); + expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'inbox', calendars: 'all', addressBooks: 'none' }, level: 'write' }); + }); + + it('keeps mail as it is when a folder was added after "all", and the patch leaves mail alone', () => { + // Inbox and Trash shared, Projects created later. + const list = withCarol(targets(), ['a', 'b'], MAILBOX_READ_STORED); + const start = changeStartFor(list, CAROL); + expect(start.scope.mail).toBe('keep'); + const changes = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true }); + expect(changes.filter((c) => c.kind === 'mailbox')).toEqual([]); + }); + + it('keeps one shared non-Inbox folder as it is instead of turning it into the Inbox', () => { + const list = withCarol(targets(), ['b'], MAILBOX_READ_STORED); + expect(changeStartFor(list, CAROL).scope.mail).toBe('keep'); + }); + + it('keeps a non-default calendar as it is', () => { + const list = withCarol(targets(), ['c1'], CALENDAR_PRESETS.read); + const start = changeStartFor(list, CAROL); + expect(start.scope.calendars).toBe('keep'); + expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]); + }); + + it('keeps free/busy only as it is, until the user picks a scope', () => { + const list = withCarol(targets(), ['c2'], CALENDAR_PRESETS.freeBusy); + const start = changeStartFor(list, CAROL); + expect(start.scope.calendars).toBe('keep'); + expect(start.level).toBe('keep'); + expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]); + // Picking "Default calendar" with read replaces it. + const picked = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true }); + expect(picked.map((c) => [c.id, c.rights])).toEqual([['c2', CALENDAR_PRESETS.read]]); + }); + + it('keeps manager rights untouched', () => { + const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.manager); + const start = changeStartFor(list, CAROL); + expect(start).toEqual({ scope: { mail: 'keep', calendars: 'none', addressBooks: 'none' }, level: 'keep' }); + expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]); + }); + + it('keeps mixed levels per type: grants stay, anything added gets that type\'s level', () => { + const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.readWrite); + withCarol(list, ['c2'], CALENDAR_PRESETS.read); + const start = changeStartFor(list, CAROL); + expect(start).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'keep' }); + expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]); + // Widening calendars to all adds Work at the calendars' level (read), not mail's (write). + const wider = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'all' }, 'keep', { replace: true }); + expect(wider.map((c) => [c.id, c.rights])).toEqual([['c1', CALENDAR_PRESETS.read]]); + }); + + it('counts a shared trusted senders book as something the dialog cannot express', () => { + const list = withCarol(targets(), ['ab', 'ts'], ADDRESS_BOOK_PRESETS.read); + expect(changeStartFor(list, CAROL).scope.addressBooks).toBe('keep'); + }); + + it('clamps a write delegate to read when the admin allows only read', () => { + const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite); + expect(changeStartFor(list, CAROL, ['read'])).toEqual({ scope: { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, level: 'read' }); + // Mixed with write starts at read too, so nothing keeps write by default. + withCarol(list, ['c2'], CALENDAR_PRESETS.read); + expect(changeStartFor(list, CAROL, ['read']).level).toBe('read'); + }); +}); + +describe('allowedLevels', () => { + it('offers both without a restriction', () => { + expect(allowedLevels(null)).toEqual(['read', 'write']); + }); + + it('offers only what the admin allows', () => { + expect(allowedLevels(['read'])).toEqual(['read']); + expect(allowedLevels(['write'])).toEqual(['write']); + }); + + it('falls back to read when the list names neither', () => { + expect(allowedLevels(['manager'])).toEqual(['read']); + }); +}); diff --git a/src/lib/delegation/__tests__/display.test.ts b/src/lib/delegation/__tests__/display.test.ts new file mode 100644 index 00000000..2a42db63 --- /dev/null +++ b/src/lib/delegation/__tests__/display.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { translate } from '../../../i18n'; +import { + canLookUpAddress, + delegateCandidates, + describeSummary, + lookupResult, + principalDisplay, + resolvePrincipal, +} from '../display'; +import { summarizeDelegates } from '../delegation'; +import type { ShareTarget } from '../share-batch'; +import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../share-rights'; +import type { Principal } from '../../../api/types'; + +const tEn = (key: string, fallback?: string, params?: Record) => translate('en', key, fallback, params); + +const bob: Principal = { id: 'cj', type: 'individual', name: 'Bob', email: 'bob@example.org' }; +const team: Principal = { id: 'cg', type: 'group', name: 'team@example.org', email: 'team@example.org' }; + +describe('principalDisplay', () => { + it('names a person and shows their address under the name', () => { + expect(principalDisplay(bob)).toEqual({ name: 'Bob', detail: 'bob@example.org' }); + expect(principalDisplay(team)).toEqual({ name: 'team@example.org', detail: null }); + expect(principalDisplay({ id: 'x', name: '', email: null, description: 'Room 1' })).toEqual({ name: 'x', detail: 'Room 1' }); + }); +}); + +describe('resolvePrincipal', () => { + it('asks the directory, then the names kept on the device, then the shared accounts in the session', () => { + const remembered = new Map([['ck', { id: 'ck', type: 'individual' as const, name: 'Carol', email: 'carol@example.org' }]]); + const sessionAccounts = new Map([['cm', 'mallory@example.org']]); + const sources = { directory: [bob], remembered, sessionAccounts }; + expect(resolvePrincipal('cj', sources)?.name).toBe('Bob'); + expect(resolvePrincipal('ck', sources)?.name).toBe('Carol'); + expect(resolvePrincipal('cm', sources)).toEqual({ id: 'cm', name: 'mallory@example.org', email: 'mallory@example.org', type: 'individual' }); + expect(resolvePrincipal('zz', sources)).toBeNull(); + }); +}); + +describe('describeSummary', () => { + const targets: ShareTarget[] = [ + { kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: { cj: MAILBOX_PRESETS.read, ck: MAILBOX_PRESETS.readWrite } }, + { kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Sent', role: 'sent', shareWith: { ck: MAILBOX_PRESETS.readWrite } }, + { kind: 'calendar', accountId: 'ci', id: 'c', name: 'Work', isDefault: true, shareWith: { ck: CALENDAR_PRESETS.read } }, + { kind: 'file', accountId: 'ci', id: 'n', name: 'a.pdf', shareWith: { cf: FILE_PRESETS.read } }, + ]; + const [bobSummary, carol, filesOnly] = summarizeDelegates(targets); + + it('says what each person has, in the words of the webmail catalog', () => { + expect(describeSummary(bobSummary, tEn)).toBe('Inbox · Read'); + expect(describeSummary(carol, tEn)).toBe('All mail folders · 1 calendar · Different rights'); + expect(describeSummary(filesOnly, tEn)).toBe('1 file or folder · Read'); + }); +}); + +describe('the person picker', () => { + it('never offers the user, and searches names and addresses', () => { + const me: Principal = { id: 'ci', type: 'individual', name: 'Me', email: 'me@example.org' }; + expect(delegateCandidates([me, bob, team], ['ci'], '').map((p) => p.id)).toEqual(['cj', 'cg']); + expect(delegateCandidates([me, bob, team], ['ci'], 'TEAM').map((p) => p.id)).toEqual(['cg']); + }); + + it('offers a lookup only for an address nobody listed has', () => { + expect(canLookUpAddress('dave@example.org', [bob])).toBe(true); + expect(canLookUpAddress('Bob@example.org', [bob])).toBe(false); + expect(canLookUpAddress('dave', [])).toBe(false); + }); + + it('turns the lookup into what the dialog shows', () => { + expect(lookupResult('bob@example.org', { principal: bob, status: 'ok' }, ['ci'])).toEqual({ lookup: { state: 'idle' }, picked: bob }); + expect(lookupResult('me@example.org', { principal: { ...bob, id: 'ci' }, status: 'ok' }, ['ci']).lookup).toEqual({ state: 'self' }); + expect(lookupResult('x@example.org', { principal: null, status: 'forbidden' }, ['ci']).lookup).toEqual({ state: 'directoryOff' }); + expect(lookupResult('x@example.org', { principal: null, status: 'ok' }, ['ci']).lookup).toEqual({ state: 'notFound', address: 'x@example.org' }); + expect(lookupResult('x@example.org', { principal: null, status: 'error' }, ['ci']).lookup).toEqual({ state: 'failed' }); + }); +}); diff --git a/src/lib/delegation/__tests__/known-principals.test.ts b/src/lib/delegation/__tests__/known-principals.test.ts new file mode 100644 index 00000000..bc4dade6 --- /dev/null +++ b/src/lib/delegation/__tests__/known-principals.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + DELEGATE_NAMES_KEY, + pruneRememberedPrincipals, + rememberPrincipals, + rememberedPrincipals, +} from '../known-principals'; +import type { Principal } from '../../../api/types'; + +// Ported from the webmail's lib/delegation/__tests__/known-principals.test.ts +// (AsyncStorage instead of localStorage; logins that are gone are pruned). + +const person = (id: string): Principal => ({ id, type: 'individual', name: `${id}@example.org`, email: `${id}@example.org` }); + +beforeEach(async () => { + await AsyncStorage.clear(); +}); + +describe('known principals', () => { + it('keeps names per login', async () => { + await rememberPrincipals('acc-1', [person('ck')]); + await rememberPrincipals('acc-2', [person('cj')]); + expect([...(await rememberedPrincipals('acc-1')).keys()]).toEqual(['ck']); + expect([...(await rememberedPrincipals('acc-2')).keys()]).toEqual(['cj']); + }); + + it('keeps both of two remembers sent at once', async () => { + await Promise.all([rememberPrincipals('acc-1', [person('ck')]), rememberPrincipals('acc-1', [person('cj')])]); + expect([...(await rememberedPrincipals('acc-1')).keys()].sort()).toEqual(['cj', 'ck']); + }); + + it('forgets the logins that are gone and leaves the others', async () => { + await rememberPrincipals('acc-1', [person('ck')]); + await rememberPrincipals('acc-2', [person('cj')]); + await pruneRememberedPrincipals(['acc-2']); + expect((await rememberedPrincipals('acc-1')).size).toBe(0); + expect([...(await rememberedPrincipals('acc-2')).keys()]).toEqual(['cj']); + }); + + it('leaves no key behind when everyone signed out', async () => { + await rememberPrincipals('acc-1', [person('ck')]); + await pruneRememberedPrincipals([]); + expect(await AsyncStorage.getItem(DELEGATE_NAMES_KEY)).toBeNull(); + }); + + it('keeps at most 200 per login, dropping the oldest', async () => { + await rememberPrincipals('acc-1', Array.from({ length: 205 }, (_, i) => person(`p${i}`))); + const ids = [...(await rememberedPrincipals('acc-1')).keys()]; + expect(ids).toHaveLength(200); + expect(ids[0]).toBe('p5'); + }); +}); diff --git a/src/lib/delegation/__tests__/share-batch.test.ts b/src/lib/delegation/__tests__/share-batch.test.ts new file mode 100644 index 00000000..1cc47378 --- /dev/null +++ b/src/lib/delegation/__tests__/share-batch.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { buildShareSetCalls, parseShareTargets, readShareSetResults, type ShareChange } from '../share-batch'; +import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../share-rights'; + +// Ported from the pure part of the webmail's lib/__tests__/jmap-share-batch.test.ts. + +const change = (kind: ShareChange['kind'], id: string, rights: ShareChange['rights'], principalId = 'ck', accountId = 'ci'): ShareChange => + ({ kind, accountId, id, name: id, principalId, rights }); + +describe('buildShareSetCalls', () => { + it('puts each type in one call with a shareWith patch per item', () => { + const { calls } = buildShareSetCalls([ + change('mailbox', 'a', MAILBOX_PRESETS.read), + change('mailbox', 'b', MAILBOX_PRESETS.read), + change('calendar', 'c2', CALENDAR_PRESETS.read), + change('file', 'n1', null), + ], { maxObjectsInSet: 500 }); + expect(calls).toEqual([ + ['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': MAILBOX_PRESETS.read }, b: { 'shareWith/ck': MAILBOX_PRESETS.read } } }, 's0'], + ['Calendar/set', { accountId: 'ci', update: { c2: { 'shareWith/ck': CALENDAR_PRESETS.read } } }, 's1'], + ['FileNode/set', { accountId: 'ci', update: { n1: { 'shareWith/ck': null } } }, 's2'], + ]); + }); + + it('splits a type past maxObjectsInSet', () => { + const changes = ['a', 'b', 'c'].map((id) => change('mailbox', id, null)); + const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 2 }); + expect(calls.map(([, args]) => Object.keys(args.update as object))).toEqual([['a', 'b'], ['c']]); + expect(changesByCall.get('s1')!.map((c) => c.id)).toEqual(['c']); + }); + + it('converts rights through wireRights, but never a revoke', () => { + const { calls } = buildShareSetCalls([change('file', 'n1', FILE_PRESETS.read), change('file', 'n2', null)], { + maxObjectsInSet: 500, + wireRights: () => ({ mayRead: true, mayWrite: false, mayShare: false }), + }); + expect(calls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: false, mayShare: false } }, n2: { 'shareWith/ck': null } }); + }); +}); + +describe('readShareSetResults', () => { + it('reports each item: updated, refused with the reason, or the whole call failing', () => { + const changes = [ + change('mailbox', 'a', MAILBOX_PRESETS.read), + change('mailbox', 'b', MAILBOX_PRESETS.read), + change('mailbox', 'z', MAILBOX_PRESETS.read), + change('calendar', 'c2', CALENDAR_PRESETS.read), + ]; + const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 500 }); + const results = readShareSetResults(calls, changesByCall, [ + ['Mailbox/set', { updated: { a: null }, notUpdated: { b: { type: 'forbidden', description: 'You may not share this folder' } } }, 's0'], + ['error', { type: 'forbidden' }, 's1'], + ]); + expect(results.map((r) => [r.change.id, r.ok, r.error])).toEqual([ + ['a', true, undefined], + ['b', false, 'You may not share this folder'], + ['z', false, undefined], + ['c2', false, 'forbidden'], + ]); + }); +}); + +describe('parseShareTargets', () => { + it('reads every type and names the ones whose call failed', () => { + const { targets, failed } = parseShareTargets([ + ['Mailbox/get', { list: [{ id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } }] }, 'm'], + ['error', { type: 'serverFail' }, 'c'], + ['AddressBook/get', { list: [{ id: 'ab', name: 'Contacts', isDefault: true, shareWith: null }] }, 'a'], + ], { mailbox: 'ci', calendar: 'ci', addressBook: 'ci' }); + expect(targets).toEqual([ + { kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } }, + { kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} }, + ]); + expect(failed).toEqual(['calendar']); + }); +}); diff --git a/src/lib/delegation/delegation.ts b/src/lib/delegation/delegation.ts new file mode 100644 index 00000000..8a3d8158 --- /dev/null +++ b/src/lib/delegation/delegation.ts @@ -0,0 +1,257 @@ +/** + * Delegates (Settings > Sharing; webmail lib/delegation/delegation.ts): give + * one person access to mail, calendars and contacts in one step, list who + * has access to what, and take it away everywhere. Pure functions over the + * user's shareable items (api/delegation getShareTargets); applyShareChanges + * sends what they build. + * + * Stalwart has no "send as" or "send on behalf" for a person's own account, + * so delegates get no submission right; the dialog says so. + */ + +import type { ShareChange, ShareChangeResult, ShareTarget } from './share-batch'; +import { accessLevelOf, accessRights, detectPreset, type AccessLevel, type ShareKind } from './share-rights'; + +/** `keep`: "Keep as it is", offered by Change when what the person has fits no other choice. */ +export type MailScope = 'all' | 'inbox' | 'none' | 'keep'; +export type CollectionScope = 'default' | 'all' | 'none' | 'keep'; +export type LevelChoice = AccessLevel | 'keep'; + +/** What a delegate gets: which folders, calendars and address books. */ +export interface DelegateScope { + mail: MailScope; + calendars: CollectionScope; + addressBooks: CollectionScope; +} + +type DelegateKind = 'mailbox' | 'calendar' | 'addressBook'; +const SCOPE_KEY: Record = { mailbox: 'mail', calendar: 'calendars', addressBook: 'addressBooks' }; +const DELEGATE_KINDS: DelegateKind[] = ['mailbox', 'calendar', 'addressBook']; + +export const DEFAULT_DELEGATE_SCOPE: DelegateScope = { mail: 'all', calendars: 'default', addressBooks: 'default' }; + +export const ACCESS_LEVELS: readonly AccessLevel[] = ['read', 'write']; + +// Bulwark's own list of senders whose images load (stores/contacts-store, +// TRUSTED_SENDERS_BOOK_NAME). It is an address book on the server, but not +// one a delegate needs. +const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders'; + +function ofKind(targets: ShareTarget[], kind: ShareKind): ShareTarget[] { + return targets.filter((t) => t.kind === kind); +} + +function defaultOf(collections: ShareTarget[]): ShareTarget[] { + const flagged = collections.find((c) => c.isDefault); + return flagged ? [flagged] : collections.slice(0, 1); +} + +function delegableAddressBooks(targets: ShareTarget[]): ShareTarget[] { + return ofKind(targets, 'addressBook').filter((b) => b.name !== TRUSTED_SENDERS_BOOK_NAME || b.isDefault); +} + +/** The items of one type the dialog's choices are about. */ +function itemsOfType(targets: ShareTarget[], kind: DelegateKind): ShareTarget[] { + return kind === 'addressBook' ? delegableAddressBooks(targets) : ofKind(targets, kind); +} + +const isInbox = (t: ShareTarget) => t.role?.toLowerCase() === 'inbox'; + +/** The items one choice covers; null for "Keep as it is". */ +function itemsFor(targets: ShareTarget[], kind: DelegateKind, choice: MailScope | CollectionScope): ShareTarget[] | null { + if (choice === 'keep') return null; + const items = itemsOfType(targets, kind); + if (choice === 'none') return []; + if (choice === 'all') return items; + if (choice === 'inbox') return items.filter(isInbox); + return defaultOf(items); +} + +/** The items a scope covers. Files are never part of it, nor types left as they are. */ +export function selectTargets(targets: ShareTarget[], scope: DelegateScope): ShareTarget[] { + return DELEGATE_KINDS.flatMap((kind) => itemsFor(targets, kind, scope[SCOPE_KEY[kind]]) ?? []); +} + +function change(target: ShareTarget, principalId: string, rights: ShareChange['rights']): ShareChange { + return { kind: target.kind, accountId: target.accountId, id: target.id, name: target.name, principalId, rights }; +} + +/** read or write when every grant of one type has exactly that preset, otherwise null. */ +function uniformLevel(items: ShareTarget[], kind: ShareKind, principalId: string): AccessLevel | null { + const presets = new Set(items.filter((t) => principalId in t.shareWith).map((t) => detectPreset(kind, t.shareWith[principalId]))); + if (presets.size !== 1) return null; + const [preset] = [...presets]; + return preset === 'read' ? 'read' : preset === 'readWrite' ? 'write' : null; +} + +/** + * The changes that give `principalId` access to what `scope` covers at + * `level`. A type left as it is ("keep") gets no change at all. With + * `replace` (the "Change" of someone who already has access), the items of a + * chosen type outside the scope lose their access; shared files stay as they + * are, since the dialog doesn't offer them. With level "keep", items the + * person has keep their rights and items added get the rights the rest of + * that type has, or read. + */ +export function buildGrantChanges( + targets: ShareTarget[], + principalId: string, + scope: DelegateScope, + level: LevelChoice, + options: { replace?: boolean } = {}, +): ShareChange[] { + const changes: ShareChange[] = []; + for (const kind of DELEGATE_KINDS) { + const chosen = itemsFor(targets, kind, scope[SCOPE_KEY[kind]]); + if (!chosen) continue; + const all = itemsOfType(targets, kind); + const typeLevel = uniformLevel(all, kind, principalId) ?? 'read'; + const ids = new Set(chosen.map((t) => t.id)); + for (const t of chosen) { + if (level === 'keep') { + if (principalId in t.shareWith) continue; + changes.push(change(t, principalId, accessRights(kind, typeLevel))); + } else { + changes.push(change(t, principalId, accessRights(kind, level))); + } + } + if (options.replace) { + for (const t of all) { + if (!ids.has(t.id) && principalId in t.shareWith) changes.push(change(t, principalId, null)); + } + } + } + return changes; +} + +/** The changes that take every grant of `principalId` away, files included. */ +export function buildRevokeChanges(targets: ShareTarget[], principalId: string): ShareChange[] { + return targets.filter((t) => principalId in t.shareWith).map((t) => change(t, principalId, null)); +} + +export interface DelegateItem { + kind: ShareKind; + id: string; + name: string; + /** null: shared without read access (calendar free/busy only). */ + level: AccessLevel | null; +} + +export interface DelegateSummary { + principalId: string; + /** "mixed" when some items are read only and others writable; null when nothing is readable. */ + level: AccessLevel | 'mixed' | null; + mail: { shared: number; total: number; inbox: boolean }; + calendars: { shared: number; total: number }; + addressBooks: { shared: number; total: number }; + files: number; + items: DelegateItem[]; +} + +/** + * Everyone who has access to something of the user's, one entry per person + * or group, in the order they first appear. `excludeIds` leaves out the + * user's own principal, should a server list it. + */ +export function summarizeDelegates(targets: ShareTarget[], excludeIds: string[] = []): DelegateSummary[] { + const exclude = new Set(excludeIds); + const byPrincipal = new Map(); + for (const target of targets) { + for (const [principalId, rights] of Object.entries(target.shareWith)) { + if (exclude.has(principalId) || !rights) continue; + const items = byPrincipal.get(principalId) ?? []; + items.push({ kind: target.kind, id: target.id, name: target.name, level: accessLevelOf(target.kind, rights) }); + byPrincipal.set(principalId, items); + } + } + + const totals = { + mailbox: ofKind(targets, 'mailbox').length, + calendar: ofKind(targets, 'calendar').length, + addressBook: delegableAddressBooks(targets).length, + }; + const inboxIds = new Set(ofKind(targets, 'mailbox').filter((m) => m.role?.toLowerCase() === 'inbox').map((m) => m.id)); + + return [...byPrincipal.entries()].map(([principalId, items]) => { + const count = (kind: ShareKind) => items.filter((i) => i.kind === kind).length; + const levels = new Set(items.map((i) => i.level).filter((l): l is AccessLevel => l !== null)); + return { + principalId, + level: levels.size === 0 ? null : levels.size > 1 ? 'mixed' : [...levels][0], + mail: { shared: count('mailbox'), total: totals.mailbox, inbox: items.some((i) => i.kind === 'mailbox' && inboxIds.has(i.id)) }, + calendars: { shared: count('calendar'), total: totals.calendar }, + addressBooks: { shared: count('addressBook'), total: totals.addressBook }, + files: count('file'), + items, + }; + }); +} + +export interface ChangeStart { + scope: DelegateScope; + level: LevelChoice; +} + +/** + * The dialog's starting point for "Change". A type gets a choice only when + * the person's grants match it exactly (every folder for "all", exactly the + * Inbox for "Inbox only", exactly the default calendar, ...), all with the + * read or the read-and-write preset. Anything else (a folder added since, + * another single folder, free/busy only, manager or custom rights) starts + * as "Keep as it is", which leaves that type out of the patch. The level is + * read or write when every grant has that preset, else "keep". Without + * write among `allowed`, a level that would keep or give write starts at + * read: saving then lowers the types the user chose to read. + */ +export function changeStartFor(targets: ShareTarget[], principalId: string, allowed: AccessLevel[] = [...ACCESS_LEVELS]): ChangeStart { + const scopeFor = (kind: DelegateKind): MailScope | CollectionScope => { + // Address books Delegates doesn't offer still count: a grant there isn't one the dialog can express. + const relevant = kind === 'addressBook' ? ofKind(targets, kind) : itemsOfType(targets, kind); + const shared = relevant.filter((t) => principalId in t.shareWith); + if (shared.length === 0) return 'none'; + if (shared.some((t) => !['read', 'readWrite'].includes(detectPreset(kind, t.shareWith[principalId])))) return 'keep'; + const ids = shared.map((t) => t.id).sort().join('\n'); + const exact = (choice: MailScope | CollectionScope) => { + const covered = itemsFor(targets, kind, choice) ?? []; + return covered.length > 0 && covered.map((t) => t.id).sort().join('\n') === ids; + }; + if (kind === 'mailbox') return exact('all') ? 'all' : exact('inbox') ? 'inbox' : 'keep'; + return exact('default') ? 'default' : exact('all') ? 'all' : 'keep'; + }; + const scope: DelegateScope = { + mail: scopeFor('mailbox') as MailScope, + calendars: scopeFor('calendar') as CollectionScope, + addressBooks: scopeFor('addressBook') as CollectionScope, + }; + + const granted = targets.filter((t) => t.kind !== 'file' && principalId in t.shareWith); + const presets = new Set(granted.map((t) => detectPreset(t.kind, t.shareWith[principalId]))); + let level: LevelChoice = presets.size === 1 && presets.has('read') ? 'read' + : presets.size === 1 && presets.has('readWrite') ? 'write' + : 'keep'; + const hasWrite = granted.some((t) => accessLevelOf(t.kind, t.shareWith[principalId]) === 'write'); + if (!allowed.includes('write') && (level === 'write' || (level === 'keep' && hasWrite))) level = 'read'; + else if (level !== 'keep' && !allowed.includes(level)) level = 'keep'; + return { scope, level }; +} + +/** + * The levels the admin allows (`sharingRights.allowedValues`). Values + * other than "read" and "write" are ignored; a list with neither leaves + * read only, the safe end. + */ +export function allowedLevels(allowedValues: unknown[] | null): AccessLevel[] { + if (!allowedValues) return [...ACCESS_LEVELS]; + const allowed = ACCESS_LEVELS.filter((level) => allowedValues.includes(level)); + return allowed.length > 0 ? allowed : ['read']; +} + +/** Whether a scope gives access to nothing at all. */ +export function scopeIsEmpty(scope: DelegateScope): boolean { + return Object.values(scope).every((choice) => choice === 'none'); +} + +/** The changes that failed, for the report and for "Try again". */ +export function failedChanges(results: ShareChangeResult[]): ShareChangeResult[] { + return results.filter((r) => !r.ok); +} diff --git a/src/lib/delegation/display.ts b/src/lib/delegation/display.ts new file mode 100644 index 00000000..540063d3 --- /dev/null +++ b/src/lib/delegation/display.ts @@ -0,0 +1,100 @@ +// What the Delegates page shows about people and their access (webmail +// components/settings/delegates/principal-display.ts, describeSummary in +// delegates-settings.tsx and the search of delegate-dialog.tsx). Kept free +// of React so it can be unit-tested. + +import type { Principal } from '../../api/types'; +import type { DelegateSummary } from './delegation'; + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +/** The line to show for a principal, and the smaller one under it. */ +export function principalDisplay(principal: Pick): { name: string; detail: string | null } { + const name = principal.name || principal.email || principal.id; + const detail = principal.email && principal.email !== name + ? principal.email + : principal.description && principal.description !== name ? principal.description : null; + return { name, detail }; +} + +/** + * The principal behind an id: from the directory, the names kept on this + * device, or, as a last way to name someone the directory won't (Stalwart + * 1.0 with directory queries off), the account they shared with the user, + * which the session lists under the owner's address. + */ +export function resolvePrincipal( + id: string, + sources: { directory: Principal[]; remembered: Map; sessionAccounts: Map }, +): Principal | null { + const known = sources.directory.find((p) => p.id === id) ?? sources.remembered.get(id); + if (known) return known; + const accountName = sources.sessionAccounts.get(id); + if (!accountName) return null; + // The webmail tells groups apart by the policy subject's groups, which the app doesn't have. + return { id, name: accountName, email: accountName.includes('@') ? accountName : null, type: 'individual' }; +} + +/** "All mail folders · 1 calendar · Read", in the order of the dialog. */ +export function describeSummary(summary: DelegateSummary, t: Translate): string { + const parts: string[] = []; + const { mail, calendars, addressBooks, files } = summary; + if (mail.shared > 0) { + parts.push(mail.shared === mail.total + ? t('delegates.summary.mail_all', 'All mail folders') + : mail.shared === 1 && mail.inbox + ? t('delegates.summary.mail_inbox', 'Inbox') + : t('delegates.summary.mail_some', '{count, plural, one {# mail folder} other {# mail folders}}', { count: mail.shared })); + } + if (calendars.shared > 0) { + parts.push(t('delegates.summary.calendars', '{count, plural, one {# calendar} other {# calendars}}', { count: calendars.shared })); + } + if (addressBooks.shared > 0) { + parts.push(t('delegates.summary.address_books', '{count, plural, one {# address book} other {# address books}}', { count: addressBooks.shared })); + } + if (files > 0) { + parts.push(t('delegates.summary.files', '{count, plural, one {# file or folder} other {# files or folders}}', { count: files })); + } + const level = summary.level ?? 'none'; + const levelFallback = { read: 'Read', write: 'Read and write', mixed: 'Different rights', none: 'Free/busy only' }[level]; + parts.push(t(`delegates.level.${level}`, levelFallback)); + return parts.join(' · '); +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** The people the dialog offers for a search: never the user. */ +export function delegateCandidates(directory: Principal[], ownIds: string[], query: string): Principal[] { + const own = new Set(ownIds); + const q = query.trim().toLowerCase(); + return directory + .filter((p) => !own.has(p.id)) + .filter((p) => !q || p.name?.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q)); +} + +/** Whether "Use {address}" is offered: a typed address nobody listed has. */ +export function canLookUpAddress(query: string, candidates: Principal[]): boolean { + const typed = query.trim(); + return EMAIL_RE.test(typed) && !candidates.some((p) => p.email?.toLowerCase() === typed.toLowerCase()); +} + +export type Lookup = + | { state: 'idle' } + | { state: 'busy' } + | { state: 'notFound'; address: string } + | { state: 'directoryOff' } + | { state: 'self' } + | { state: 'failed' }; + +/** What a lookup of `address` found, as the dialog shows it; `picked` is set when it found someone. */ +export function lookupResult( + address: string, + found: { principal: Principal | null; status: 'ok' | 'forbidden' | 'error' }, + ownIds: string[], +): { lookup: Lookup; picked: Principal | null } { + if (found.principal && ownIds.includes(found.principal.id)) return { lookup: { state: 'self' }, picked: null }; + if (found.principal) return { lookup: { state: 'idle' }, picked: found.principal }; + if (found.status === 'forbidden') return { lookup: { state: 'directoryOff' }, picked: null }; + if (found.status === 'ok') return { lookup: { state: 'notFound', address }, picked: null }; + return { lookup: { state: 'failed' }, picked: null }; +} diff --git a/src/lib/delegation/known-principals.ts b/src/lib/delegation/known-principals.ts new file mode 100644 index 00000000..b354f6d1 --- /dev/null +++ b/src/lib/delegation/known-principals.ts @@ -0,0 +1,86 @@ +/** + * Names of the people and groups the user gave access to, kept on this + * device (webmail lib/delegation/known-principals.ts). Stalwart 1.0 with + * directory queries off (its default) won't name a principal the user + * shares with unless that principal also shares something back, so the + * "Who has access" list would otherwise show "Unknown person" for the very + * people the user just picked by name. Only the people who have access and + * the one just picked are kept, never the directory; per login, at most MAX + * entries, gone when the login is removed (pruneRememberedPrincipals) or + * everyone signs out. A convenience: losing it costs names, not access. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { Principal } from '../../api/types'; + +export const DELEGATE_NAMES_KEY = 'bulwark-delegate-names'; +const MAX = 200; + +type Entry = Pick; +type Store = Record>; + +async function readStore(): Promise { + try { + const raw = await AsyncStorage.getItem(DELEGATE_NAMES_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Store) : {}; + } catch { + return {}; + } +} + +async function writeStore(store: Store): Promise { + try { + if (Object.keys(store).length === 0) await AsyncStorage.removeItem(DELEGATE_NAMES_KEY); + else await AsyncStorage.setItem(DELEGATE_NAMES_KEY, JSON.stringify(store)); + } catch { + // Storage unavailable: the list shows "Unknown person" instead. + } +} + +// Writes one after the other, so two quick remembers don't drop each other. +let queue: Promise = Promise.resolve(); +function serial(task: () => Promise): Promise { + const next = queue.then(task, task); + queue = next.catch(() => undefined); + return next; +} + +/** Remember these principals for `accountKey` (the signed-in login). */ +export function rememberPrincipals(accountKey: string, principals: Principal[]): Promise { + if (!accountKey || principals.length === 0) return Promise.resolve(); + return serial(async () => { + const store = await readStore(); + const own = { ...(store[accountKey] ?? {}) }; + for (const p of principals) { + delete own[p.id]; + own[p.id] = { id: p.id, name: p.name, email: p.email ?? null, type: p.type }; + } + const ids = Object.keys(own); + for (const id of ids.slice(0, Math.max(0, ids.length - MAX))) delete own[id]; + store[accountKey] = own; + await writeStore(store); + }); +} + +/** The principals remembered for `accountKey`, by id. */ +export async function rememberedPrincipals(accountKey: string): Promise> { + await queue; + const own = (await readStore())[accountKey] ?? {}; + return new Map(Object.values(own).map((entry) => [entry.id, entry as Principal])); +} + +/** + * Forget the names kept for every login not in `liveAccountKeys` (removed + * from the app, or everyone signed out). + */ +export function pruneRememberedPrincipals(liveAccountKeys: string[]): Promise { + return serial(async () => { + const store = await readStore(); + const live = new Set(liveAccountKeys); + const stale = Object.keys(store).filter((key) => !live.has(key)); + if (stale.length === 0) return; + for (const key of stale) delete store[key]; + await writeStore(store); + }); +} diff --git a/src/lib/delegation/policy.ts b/src/lib/delegation/policy.ts new file mode 100644 index 00000000..36d7ee06 --- /dev/null +++ b/src/lib/delegation/policy.ts @@ -0,0 +1,21 @@ +// What the organisation policy says about Delegates. The webmail reads it +// from its policy store: the admin can hide the page (restriction +// `delegation`), limit the rights offered (`sharingRights.allowedValues`, +// read and/or write) and switch calendars, contacts or files off +// (`calendarEnabled`, `contactsEnabled`, `filesEnabled`). The app has no +// policy route yet, so this answers "shown, every right, every app"; once +// the policy arrives, this is the one place to plug it in. + +export interface DelegationPolicy { + /** The admin hid the page. */ + hidden: boolean; + /** `sharingRights.allowedValues`; null = no limit. */ + allowedValues: unknown[] | null; + calendarsEnabled: boolean; + contactsEnabled: boolean; + filesEnabled: boolean; +} + +export function delegationPolicy(): DelegationPolicy { + return { hidden: false, allowedValues: null, calendarsEnabled: true, contactsEnabled: true, filesEnabled: true }; +} diff --git a/src/lib/delegation/share-batch.ts b/src/lib/delegation/share-batch.ts new file mode 100644 index 00000000..dd8ba021 --- /dev/null +++ b/src/lib/delegation/share-batch.ts @@ -0,0 +1,183 @@ +/** + * Changing the `shareWith` of many folders, calendars, address books and + * files in one go, for the Delegates page (webmail lib/jmap/share-batch.ts): + * the method calls for one request and a result for every item, so a + * partial failure can say what failed. + * The single-item helpers (setCalendarShare, setFileNodeShare) send the same + * `shareWith/` patch. + */ + +import { batched } from '../../api/jmap-result'; +import type { AnyRights, ShareKind } from './share-rights'; + +/** One shareable item of the user's own account, with its grants. */ +export interface ShareTarget { + kind: ShareKind; + accountId: string; + /** The id in `accountId`. */ + id: string; + name: string; + /** Mailboxes: the folder role ("inbox", "sent", ...). */ + role?: string | null; + parentId?: string | null; + /** Calendars and address books: the account's default one. */ + isDefault?: boolean; + shareWith: Record; +} + +/** Grant (`rights`) or revoke (`null`) one principal's access to one item. */ +export interface ShareChange { + kind: ShareKind; + accountId: string; + id: string; + name: string; + principalId: string; + rights: AnyRights | null; +} + +export interface ShareChangeResult { + change: ShareChange; + ok: boolean; + /** The server's reason, when it gave one. */ + error?: string; +} + +type MethodCall = [string, Record, string]; +type MethodResponse = [string, Record, string]; + +export const SET_METHOD: Record = { + mailbox: 'Mailbox/set', + calendar: 'Calendar/set', + addressBook: 'AddressBook/set', + file: 'FileNode/set', +}; + +export interface ShareSetCalls { + calls: MethodCall[]; + /** The changes each call carries, by call id. */ + changesByCall: Map; +} + +/** + * One /set call per type and account, split so none touches more than + * `maxObjectsInSet` items. `wireRights` lets the caller convert rights for + * a server that names them differently (old file rights). + */ +export function buildShareSetCalls( + changes: ShareChange[], + options: { maxObjectsInSet: number; wireRights?: (change: ShareChange) => unknown }, +): ShareSetCalls { + const groups = new Map(); + for (const change of changes) { + const key = `${change.kind}\u0000${change.accountId}`; + const list = groups.get(key); + if (list) list.push(change); + else groups.set(key, [change]); + } + + const calls: MethodCall[] = []; + const changesByCall = new Map(); + for (const group of groups.values()) { + const { kind, accountId } = group[0]; + // Batch by item, not by change: two principals on one item are one object. + const ids = [...new Set(group.map((c) => c.id))]; + for (const idBatch of batched(ids, options.maxObjectsInSet)) { + const inBatch = new Set(idBatch); + const batchChanges = group.filter((c) => inBatch.has(c.id)); + const update: Record> = {}; + for (const change of batchChanges) { + const rights = change.rights && options.wireRights ? options.wireRights(change) : change.rights; + (update[change.id] ??= {})[`shareWith/${change.principalId}`] = rights; + } + const callId = `s${calls.length}`; + calls.push([SET_METHOD[kind], { accountId, update }, callId]); + changesByCall.set(callId, batchChanges); + } + } + return { calls, changesByCall }; +} + +function describe(error: unknown): string | undefined { + const e = error as { description?: unknown; type?: unknown } | undefined; + if (typeof e?.description === 'string' && e.description) return e.description; + if (typeof e?.type === 'string' && e.type) return e.type; + return undefined; +} + +/** A result for every change of `calls`, from the responses to them. */ +export function readShareSetResults( + calls: MethodCall[], + changesByCall: Map, + responses: MethodResponse[] | undefined, +): ShareChangeResult[] { + const results: ShareChangeResult[] = []; + for (const [, , callId] of calls) { + const changes = changesByCall.get(callId) ?? []; + const response = responses?.find((r) => r[2] === callId); + if (!response) { + for (const change of changes) results.push({ change, ok: false }); + continue; + } + const [name, result] = response; + if (name === 'error') { + const error = describe(result); + for (const change of changes) results.push({ change, ok: false, error }); + continue; + } + const updated = (result.updated ?? {}) as Record; + const notUpdated = (result.notUpdated ?? {}) as Record; + for (const change of changes) { + if (change.id in notUpdated) results.push({ change, ok: false, error: describe(notUpdated[change.id]) }); + else if (change.id in updated) results.push({ change, ok: true }); + else results.push({ change, ok: false }); + } + } + return results; +} + +/** Every change of `calls` failed with one error (the request didn't go through). */ +export function failAll(calls: MethodCall[], changesByCall: Map, error: unknown): ShareChangeResult[] { + const message = error instanceof Error ? error.message : describe(error); + return calls.flatMap(([, , callId]) => (changesByCall.get(callId) ?? []).map((change) => ({ change, ok: false, error: message }))); +} + +function asShareWith(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {}; +} + +/** + * The items of a Mailbox/get, Calendar/get and AddressBook/get response + * (call ids `m`, `c`, `a`), and the types whose call failed. + */ +export function parseShareTargets( + responses: MethodResponse[] | undefined, + accounts: { mailbox?: string; calendar?: string; addressBook?: string }, +): { targets: ShareTarget[]; failed: ShareKind[] } { + const targets: ShareTarget[] = []; + const failed: ShareKind[] = []; + const read = (kind: ShareKind, callId: string, method: string, accountId: string | undefined) => { + if (!accountId) return; + const response = responses?.find((r) => r[2] === callId); + if (!response || response[0] !== method) { + failed.push(kind); + return; + } + const list = (response[1].list ?? []) as Array>; + for (const item of list) { + if (typeof item.id !== 'string') continue; + targets.push({ + kind, + accountId, + id: item.id, + name: typeof item.name === 'string' ? item.name : item.id, + ...(kind === 'mailbox' ? { role: (item.role as string | null | undefined) ?? null, parentId: (item.parentId as string | null | undefined) ?? null } : {}), + ...(kind !== 'mailbox' ? { isDefault: item.isDefault === true } : {}), + shareWith: asShareWith(item.shareWith), + }); + } + }; + read('mailbox', 'm', 'Mailbox/get', accounts.mailbox); + read('calendar', 'c', 'Calendar/get', accounts.calendar); + read('addressBook', 'a', 'AddressBook/get', accounts.addressBook); + return { targets, failed }; +} diff --git a/src/lib/delegation/share-rights.ts b/src/lib/delegation/share-rights.ts new file mode 100644 index 00000000..a895aa51 --- /dev/null +++ b/src/lib/delegation/share-rights.ts @@ -0,0 +1,183 @@ +/** + * The rights Bulwark grants when it shares a folder, calendar, address book + * or file (`shareWith/`), as named presets (webmail + * lib/jmap/share-rights.ts). The Delegates page builds its patches from here. + * + * What Stalwart stores differs from what it was sent in places: + * - 1.0 keeps `maySetSeen` and `maySetKeywords` of a mailbox as one right, so + * a "read" grant (seen yes, keywords no) reads back with both true. + * - v1.0.0 adds `maySetMetadata` to every type. Bulwark never sends it (0.16 + * would refuse the property), and the server stores false. + * So presets are compared on the rights they define, and the mailbox + * keyword right is allowed to follow the seen right. + */ + +export type ShareKind = 'calendar' | 'addressBook' | 'file' | 'mailbox'; +export type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager' | 'custom'; +type NamedPreset = Exclude; + +export interface ShareCalendarRights { + mayReadFreeBusy: boolean; + mayReadItems: boolean; + mayWriteAll: boolean; + mayWriteOwn: boolean; + mayUpdatePrivate: boolean; + mayRSVP: boolean; + mayShare: boolean; + mayDelete: boolean; +} + +export interface ShareAddressBookRights { + mayRead: boolean; + mayWrite: boolean; + mayShare?: boolean; + mayDelete: boolean; +} + +export interface ShareFileRights { + mayRead: boolean; + mayAddChildren: boolean; + mayRename: boolean; + mayDelete: boolean; + mayModifyContent: boolean; + mayShare: boolean; +} + +export interface ShareMailboxRights { + mayReadItems: boolean; + mayAddItems: boolean; + mayRemoveItems: boolean; + maySetSeen: boolean; + maySetKeywords: boolean; + mayCreateChild: boolean; + mayRename: boolean; + mayDelete: boolean; + maySubmit: boolean; + mayShare?: boolean; +} + +export type AnyRights = ShareCalendarRights | ShareAddressBookRights | ShareFileRights | ShareMailboxRights; + +export const CALENDAR_PRESETS: Record, ShareCalendarRights> = { + freeBusy: { + mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false, + mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false, + }, + read: { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, + mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false, + }, + readWrite: { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false, + }, + manager: { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, + }, +}; + +export const ADDRESS_BOOK_PRESETS: Record = { + read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }, + readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false }, + manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, +}; + +export const FILE_PRESETS: Record = { + read: { + mayRead: true, mayAddChildren: false, mayRename: false, + mayDelete: false, mayModifyContent: false, mayShare: false, + }, + readWrite: { + mayRead: true, mayAddChildren: true, mayRename: true, + mayDelete: true, mayModifyContent: true, mayShare: false, + }, + manager: { + mayRead: true, mayAddChildren: true, mayRename: true, + mayDelete: true, mayModifyContent: true, mayShare: true, + }, +}; + +// 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. +export const MAILBOX_PRESETS: Record = { + 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 presetsFor(kind: ShareKind): Partial> { + if (kind === 'calendar') return CALENDAR_PRESETS; + if (kind === 'file') return FILE_PRESETS; + if (kind === 'mailbox') return MAILBOX_PRESETS; + return ADDRESS_BOOK_PRESETS; +} + +function matches(kind: ShareKind, preset: AnyRights, rights: AnyRights): boolean { + const have = rights as unknown as Record; + return Object.entries(preset).every(([key, want]) => { + const got = have[key] ?? false; + if (got === want) return true; + // Stalwart 1.0 stores the two as one right (see the top of this file). + return kind === 'mailbox' && key === 'maySetKeywords' && got === true && have.maySetSeen === true; + }); +} + +/** The rights a preset grants; undefined for "custom". */ +export function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined { + if (preset === 'custom') return undefined; + return presetsFor(kind)[preset]; +} + +/** The preset a stored grant amounts to, or "custom". */ +export function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset { + for (const [name, preset] of Object.entries(presetsFor(kind)) as [RolePreset, AnyRights][]) { + if (matches(kind, preset, rights)) return name; + } + return 'custom'; +} + +/** Read or read and write, as the Delegates page offers them. */ +export type AccessLevel = 'read' | 'write'; + +/** The rights Delegates grants for one level. */ +export function accessRights(kind: ShareKind, level: AccessLevel): AnyRights { + return presetRights(kind, level === 'write' ? 'readWrite' : 'read')!; +} + +/** + * What a stored grant lets the grantee do, in the two words of the Delegates + * page: "write" when they may change anything inside, "read" when they may + * only read, null when they may not even read (free/busy only, or nothing). + */ +export function accessLevelOf(kind: ShareKind, rights: AnyRights | null | undefined): AccessLevel | null { + if (!rights) return null; + const r = rights as unknown as Record; + switch (kind) { + case 'mailbox': + if (!r.mayReadItems) return null; + return r.mayAddItems || r.mayRemoveItems ? 'write' : 'read'; + case 'calendar': + if (!r.mayReadItems) return null; + return r.mayWriteAll || r.mayWriteOwn ? 'write' : 'read'; + case 'addressBook': + if (!r.mayRead) return null; + return r.mayWrite ? 'write' : 'read'; + case 'file': + if (!r.mayRead) return null; + return r.mayModifyContent || r.mayAddChildren || r.mayRename || r.mayDelete || r.mayWrite ? 'write' : 'read'; + } +} diff --git a/src/lib/delegation/use-delegation-available.ts b/src/lib/delegation/use-delegation-available.ts new file mode 100644 index 00000000..aa0127e0 --- /dev/null +++ b/src/lib/delegation/use-delegation-available.ts @@ -0,0 +1,16 @@ +import { useAuthStore } from '../../stores/auth-store'; +import { supportsDelegation } from '../../api/delegation'; +import { delegationPolicy } from './policy'; + +/** + * Whether Settings lists Delegates for the active account: not hidden by + * the policy, and the server has someone to share with and something to + * share (webmail settings-app supportsSharing). Re-read with every session. + */ +export function useDelegationAvailable(): boolean { + return useAuthStore((s) => { + if (!s.session) return false; + const policy = delegationPolicy(); + return !policy.hidden && supportsDelegation(policy); + }); +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index ce6d9ffb..894dac45 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -14,7 +14,7 @@ import { supportsDeviceSync } from './platform-capabilities'; export type SettingsTabId = - | 'account' | 'language' | 'notifications' | 'accessibility' + | 'account' | 'sharing' | 'language' | 'notifications' | 'accessibility' | 'appearance' | 'layout' | 'reading' | 'composing' | 'identities' | 'vacation' | 'filters' | 'recovery' | 'templates' | 'folders' | 'keywords' | 'downloads' @@ -38,7 +38,8 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.account.accounts', 'settings.account.shared_accounts', ], - // Recover deleted items shows only where the server has it. + // Delegates and Recover deleted items show only where the server has them. + sharing: ['delegates.title'], recovery: ['recover_deleted.title'], language: [ 'settings.appearance.language', @@ -258,6 +259,7 @@ export const SETTINGS_SEARCH_PATHS: Record = { // translation doesn't contain the literal word. export const SETTINGS_SEARCH_KEYWORDS: Record = { account: 'profile email password user signin signout reorder switch default multi-account shared storage quota', + sharing: 'delegate delegation share access assistant permissions revoke folders calendars contacts colleague', recovery: 'recover restore undelete deleted trash emptied lost messages', language: 'locale region date time format translation', notifications: 'alert push badge unifiedpush relay device reminder', diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 96ffb360..a1463bcd 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -9,7 +9,7 @@ import { Filter, FileText, FolderOpen, Tags, HardDrive, BookUser, PanelLeftClose, Bell, RefreshCw, LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download, - Database, Bug, Scale, RotateCcw, + Database, Bug, Scale, Users, RotateCcw, type AppIcon, } from '../components/icons'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; @@ -45,8 +45,10 @@ import { LanguageSettings } from '../components/settings/LanguageSettings'; import { ComposingSettings } from '../components/settings/ComposingSettings'; import { LayoutSettings } from '../components/settings/LayoutSettings'; import { DownloadsSettings } from '../components/settings/DownloadsSettings'; +import { DelegateSettings } from '../components/settings/DelegateSettings'; import { RecoverDeletedSettings } from '../components/settings/RecoverDeletedSettings'; import { useDeletedItemsRecovery } from '../lib/recovery/use-deleted-items-recovery'; +import { useDelegationAvailable } from '../lib/delegation/use-delegation-available'; import { useLocaleStore } from '../stores/locale-store'; import { useAuthStore } from '../stores/auth-store'; import { useManagedAccountStore } from '../stores/managed-account-store'; @@ -100,6 +102,7 @@ const GROUP_ORDER: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'ap const TABS: TabDef[] = [ // General { id: 'account', label: 'Account', icon: User, group: 'general', implemented: true }, + { id: 'sharing', label: 'Sharing', icon: Users, group: 'general', implemented: true }, { id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: true }, { id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true }, { id: 'accessibility', label: 'Accessibility', icon: Accessibility, group: 'general', implemented: true }, @@ -148,6 +151,7 @@ const TAB_LABEL_KEYS: Partial> = { debug: 'settings.tabs.troubleshooting', licences: 'settings.about.licences', privacy: 'settings.about.privacy', + sharing: 'delegates.tab', recovery: 'recover_deleted.tab', }; const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`; @@ -168,6 +172,7 @@ const TAB_COMPONENTS: Partial>> = { folders: FolderSettings, keywords: KeywordSettings, downloads: DownloadsSettings, + sharing: DelegateSettings, recovery: RecoverDeletedSettings, security: AccountSecuritySettings, content_senders: ContentSendersSettings, @@ -222,14 +227,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin const hasFiles = useHasFiles(); const hasSieve = useHasSieve(); const hasVacation = useHasVacation(); - // Webmail 1.19: Recover deleted items is left out of the list, not shown - // as "Not on this server", where the server lacks it. + // Webmail 1.19: Delegates and Recover deleted items are left out of the + // list, not shown as "Not on this server", where the server lacks them. const recoveryAvailable = useDeletedItemsRecovery().available; + const delegationAvailable = useDelegationAvailable(); const hiddenTabs = React.useMemo(() => { const set = new Set(); if (!recoveryAvailable) set.add('recovery'); + if (!delegationAvailable) set.add('sharing'); return set; - }, [recoveryAvailable]); + }, [recoveryAvailable, delegationAvailable]); // A shared/group account picked under "Shared with me" scopes Settings to // the panes that account can be managed in (webmail: scoped settings). From dae154431f1a408ee2317d383c1726befd04f7dd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:30:09 +0100 Subject: [PATCH 3/3] docs: tick the recover deleted and delegate rows --- docs/parity/10-webmail-1.10-to-1.18.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parity/10-webmail-1.10-to-1.18.md b/docs/parity/10-webmail-1.10-to-1.18.md index a6ea9519..c89144a1 100644 --- a/docs/parity/10-webmail-1.10-to-1.18.md +++ b/docs/parity/10-webmail-1.10-to-1.18.md @@ -411,11 +411,11 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - What WEB does: retries read-only batches only (`lib/jmap/client.ts:245`). - What RN does: blocks the retry only for EmailSubmission/set, Email/import, Blob/upload (`src/api/jmap-client.ts:102-109`, `:853-857`). -- [ ] **Recover deleted items (#1319)** — `P3` — `missing` +- [x] **Recover deleted items (#1319)** — `P3` — `missing` — fixed in 113e96bd (Settings page only; the admin day limit and hiding wait for the org policy and plug in at `src/lib/recovery/policy.ts`; the Trash folder's "Recover deleted items" link is not ported, the store's `checkRecent` is ready for it) - What WEB does: `x:ArchivedItem/query` (`lib/recovery/archived-items.ts:139`, `lib/jmap/client.ts:12119`, `components/settings/recover-deleted-settings.tsx`). - What RN does: nothing (`src/screens/SettingsScreen.tsx:112`). The day limit and hiding need the policy. -- [ ] **Delegates** — `P3` — `missing` +- [x] **Delegates** — `P3` — `missing` — fixed in 8a5bb728 (Settings > Sharing; the admin hiding, rights limit and app gates wait for the org policy and plug in at `src/lib/delegation/policy.ts`) - What WEB does: Mailbox/AddressBook/Calendar shareWith in one batch (`lib/delegation/delegation.ts:96`). - What RN does: no mailbox sharing (`src/components/settings/FolderSettings.tsx`).