Merge branch 'cu-recovery' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 05:30:58 +01:00
31 changed files with 4518 additions and 9 deletions
+142
View File
@@ -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();
});
});
+243
View File
@@ -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);
});
+155
View File
@@ -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;
}
+264
View File
@@ -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' };
}
}