mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Follow-up to 0e0d99e7, which sends the batch actions of a unified view,
a tag view or an "All folders" search to each message's own login and
account.
- Batch Archive in a tag view or the default "All folders" search
failed with "archive mailbox not found", and "Not spam" in a tag view
entered from Junk with "Inbox not found": the folder lists came from
a cache only the unified views fill. In the Pro shell the list cached
under the bare account id could be another server's, so a browsed
login's messages were archived or deleted into whatever folder has
the other server's Archive or Deleted Items id. A batch's Archive,
Inbox and Deleted Items now come from the list cached for its login
and account, else from that login's own folder list, never from the
bare account id.
- "Move to" in a list that mixes accounts looked the picked folder up
in the login browsed before. In the Pro shell, after browsing a
folder of another login, the active login's mail went to that other
server. The menus list the active login's folders, so the folder is
looked up there now.
- Dragging one row moved every listed row with its id, including
another account's unrelated message. The drop hands the store the
dragged rows now.
- "Spam" and "Not spam" on a selected id that is not a row of the list,
such as a member of an expanded thread, wrote to the account the list
is reached through, where the id can name another message. Such ids
are looked up among the expanded threads now, and one found nowhere
is reported and left alone.
- A move sent in batches (500 messages each by default) stopped at a
refused batch while the list counted the unsent ones as moved. The
other batches go out, and only what the server left alone stays
listed.
151 lines
6.0 KiB
TypeScript
151 lines
6.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { JMAPClient, RequestTimeoutError } from '../jmap/client';
|
|
import { jmapErrorFailedIds } from '../jmap/jmap-error';
|
|
|
|
// #956: Email/set failures were swallowed by the delete/move helpers because
|
|
// request() only rejects on HTTP errors. Method-level errors and per-id
|
|
// notUpdated/notDestroyed entries must reject so the store keeps the rows.
|
|
|
|
function makeSession() {
|
|
return {
|
|
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
|
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
|
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
|
apiUrl: 'https://mail.example.com/jmap/api',
|
|
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
|
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
|
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const notUpdated = () =>
|
|
jsonResponse({
|
|
methodResponses: [
|
|
['Email/set', { updated: null, notUpdated: { e1: { type: 'invalidProperties', description: 'Mailbox does not exist' } } }, '0'],
|
|
],
|
|
});
|
|
const notDestroyed = () =>
|
|
jsonResponse({
|
|
methodResponses: [
|
|
['Email/set', { destroyed: null, notDestroyed: { e1: { type: 'forbidden', description: 'No permission' } } }, '0'],
|
|
],
|
|
});
|
|
const methodError = () =>
|
|
jsonResponse({
|
|
methodResponses: [['error', { type: 'accountNotFound', description: 'Account not found' }, '0']],
|
|
});
|
|
const success = () =>
|
|
jsonResponse({
|
|
methodResponses: [['Email/set', { updated: { e1: null }, destroyed: ['e1'] }, '0']],
|
|
});
|
|
|
|
describe('JMAPClient Email/set failure surfacing (#956)', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
async function connectedClient(): Promise<JMAPClient> {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
|
|
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
return client;
|
|
}
|
|
|
|
it('deleteEmail rejects on notDestroyed', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockResolvedValueOnce(notDestroyed());
|
|
await expect(client.deleteEmail('e1')).rejects.toThrow(/No permission/);
|
|
});
|
|
|
|
it('moveToTrash rejects on notUpdated', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockResolvedValueOnce(notUpdated());
|
|
await expect(client.moveToTrash('e1', 'trash')).rejects.toThrow(/Mailbox does not exist/);
|
|
});
|
|
|
|
it('batchDeleteEmails rejects on notDestroyed', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockResolvedValueOnce(notDestroyed());
|
|
await expect(client.batchDeleteEmails(['e1', 'e2'])).rejects.toThrow(/No permission/);
|
|
});
|
|
|
|
it('batchMoveEmails rejects on notUpdated', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockResolvedValueOnce(notUpdated());
|
|
await expect(client.batchMoveEmails(['e1', 'e2'], 'archive')).rejects.toThrow(/Mailbox does not exist/);
|
|
});
|
|
|
|
it('rejects on a method-level error response', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockResolvedValueOnce(methodError());
|
|
await expect(client.batchMoveEmails(['e1'], 'archive')).rejects.toThrow(/Account not found/);
|
|
});
|
|
|
|
// Stalwart takes up to maxObjectsInSet ids per Email/set (500 by default
|
|
// on 0.16 and 1.0); a larger move goes out in batches.
|
|
describe('a move sent in batches', () => {
|
|
async function clientWithBatchesOf(size: number): Promise<JMAPClient> {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse({ ...makeSession(), capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInSet: size } } }));
|
|
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
return client;
|
|
}
|
|
const moved = (ids: string[], refused: Record<string, unknown> = {}) => jsonResponse({
|
|
methodResponses: [['Email/set', {
|
|
updated: Object.fromEntries(ids.filter((id) => !(id in refused)).map((id) => [id, null])),
|
|
...(Object.keys(refused).length ? { notUpdated: refused } : {}),
|
|
}, '0']],
|
|
});
|
|
|
|
it('moves the batches after a refused one and names only what was left where it was', async () => {
|
|
const client = await clientWithBatchesOf(2);
|
|
fetchSpy
|
|
.mockResolvedValueOnce(moved(['e1', 'e2']))
|
|
.mockResolvedValueOnce(moved(['e3', 'e4'], { e3: { type: 'forbidden', description: 'No permission' } }))
|
|
.mockResolvedValueOnce(moved(['e5']));
|
|
|
|
const error = await client.batchMoveEmails(['e1', 'e2', 'e3', 'e4', 'e5'], 'archive').catch((e: unknown) => e);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(jmapErrorFailedIds(error)).toEqual(['e3']);
|
|
});
|
|
|
|
it('names every message from the batch whose answer never came on', async () => {
|
|
const client = await clientWithBatchesOf(2);
|
|
fetchSpy
|
|
.mockResolvedValueOnce(moved(['e1', 'e2']))
|
|
.mockRejectedValueOnce(new RequestTimeoutError(30_000));
|
|
|
|
const error = await client.batchMoveEmails(['e1', 'e2', 'e3', 'e4', 'e5'], 'archive').catch((e: unknown) => e);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
expect(error).toBeInstanceOf(RequestTimeoutError);
|
|
expect(jmapErrorFailedIds(error)).toEqual(['e3', 'e4', 'e5']);
|
|
});
|
|
});
|
|
|
|
it('still resolves when the server accepted the change', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockResolvedValueOnce(success());
|
|
await expect(client.deleteEmail('e1')).resolves.toBeUndefined();
|
|
fetchSpy.mockResolvedValueOnce(success());
|
|
await expect(client.batchMoveEmails(['e1'], 'archive')).resolves.toBeUndefined();
|
|
});
|
|
});
|