mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
Merge branch 'cu-recovery' into catch-up-2026-11
This commit is contained in:
@@ -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, unknown>, string];
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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, unknown>, string];
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
||||
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<string, unknown>, 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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' });
|
||||
});
|
||||
});
|
||||
@@ -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, any>, string];
|
||||
|
||||
let owner: JMAPClient;
|
||||
let delegate: JMAPClient;
|
||||
|
||||
async function useOwner(): Promise<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
});
|
||||
@@ -582,3 +582,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);
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
/** 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<ArchiveQueryPage> {
|
||||
const args: Record<string, unknown> = { 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<ArchiveListResult> {
|
||||
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<boolean> {
|
||||
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<RestoreResult[]> {
|
||||
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<string, unknown>).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;
|
||||
}
|
||||
@@ -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, unknown>, string];
|
||||
|
||||
function accountCaps(accountId: string): Record<string, unknown> | 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<string>([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<string, AnyRights>,
|
||||
});
|
||||
}
|
||||
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<ShareChangeResult[]> {
|
||||
if (changes.length === 0) return [];
|
||||
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: jmapClient.getMaxObjectsInSet() });
|
||||
const usingFor: Record<string, string[]> = {
|
||||
[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<PrincipalLookup> {
|
||||
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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user