mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
A message that failed for a passing reason (a dropped connection, a server error, an expired token) used to count as done, so continuing the import skipped it for good, and a slow upload followed by a cancel imported the messages after it twice. - The checkpoint keeps every message that is done, also past a gap, and the next run of the same import tries only the rest again. - Ten failures in a row, a full mailbox (overQuota) or a refusal (forbidden) stop the import with a message instead of going through the whole file. - An upload without an answer pauses the import instead of failing the message: 30 seconds, then until the upload window ends. - Email/import, the export's Blob/get and folder listing pass the client's own 429 pause by, and a 429 on them doesn't pause the rest of the app. - Signing out stops the job of that account only, writes no checkpoint and imports nothing more afterwards; another account's sign-out leaves the job alone. - One tab imports a file at a time (Web Locks), and Import waits until the dialog knows whether an earlier run can be continued. The import dialog tests now wait for the Import button to be enabled before clicking it, since it stays off during that lookup.
77 lines
3.9 KiB
TypeScript
77 lines
3.9 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { JMAPClient } from '../jmap/client';
|
|
|
|
type Call = [string, Record<string, unknown>, string];
|
|
|
|
function client() {
|
|
const c = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
|
Object.assign(c, { accountId: 'me', downloadUrl: 'https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}' });
|
|
return c;
|
|
}
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
describe('importUploadedEmails', () => {
|
|
it('imports several uploaded messages in one Email/import into the given account, one result each', async () => {
|
|
const c = client();
|
|
const calls: Call[][] = [];
|
|
vi.spyOn(c as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request').mockImplementation(async (req: Call[]) => {
|
|
calls.push(req);
|
|
return { methodResponses: [['Email/import', { created: { imp0: { id: 'e1' } }, notCreated: { imp1: { type: 'invalidEmail', description: 'Blob does not contain a valid RFC 5322 message.' } } }, '0']] };
|
|
});
|
|
const results = await c.importUploadedEmails([
|
|
{ blobId: 'b1', mailboxIds: { inbox: true }, keywords: { $seen: true }, receivedAt: '2020-01-02T03:04:05Z' },
|
|
{ blobId: 'b2', mailboxIds: { inbox: true }, keywords: {} },
|
|
], 'shared');
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0][0][0]).toBe('Email/import');
|
|
expect(calls[0][0][1]).toEqual({
|
|
accountId: 'shared',
|
|
emails: {
|
|
imp0: { blobId: 'b1', mailboxIds: { inbox: true }, keywords: { $seen: true }, receivedAt: '2020-01-02T03:04:05Z' },
|
|
imp1: { blobId: 'b2', mailboxIds: { inbox: true }, keywords: {} },
|
|
},
|
|
});
|
|
expect(results).toEqual([{ id: 'e1' }, { error: 'Blob does not contain a valid RFC 5322 message.', type: 'invalidEmail' }]);
|
|
});
|
|
});
|
|
|
|
describe('the transfer\'s own JMAP requests', () => {
|
|
const entry = { blobId: 'b1', mailboxIds: { inbox: true }, keywords: {} };
|
|
|
|
it('throw RateLimitError on a 429 without pausing the rest of the client', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('slow down', { status: 429, headers: { 'Retry-After': '20' } })));
|
|
const c = client();
|
|
Object.assign(c, { apiUrl: 'https://jmap.example.com/jmap/' });
|
|
await expect(c.importUploadedEmails([entry])).rejects.toMatchObject({ name: 'RateLimitError', retryAfterMs: 20_000 });
|
|
expect((c as unknown as { isRateLimited(): boolean }).isRateLimited()).toBe(false);
|
|
});
|
|
|
|
it('still go out while the client waits out a 429 of its own', async () => {
|
|
const fetch = vi.fn(async () => new Response(JSON.stringify({ methodResponses: [['Email/import', { created: { imp0: { id: 'e1' } } }, '0']] })));
|
|
vi.stubGlobal('fetch', fetch);
|
|
const c = client();
|
|
Object.assign(c, { apiUrl: 'https://jmap.example.com/jmap/' });
|
|
(c as unknown as { setRateLimited(ms: number): void }).setRateLimited(60_000);
|
|
await expect(c.importUploadedEmails([entry])).resolves.toEqual([{ id: 'e1' }]);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('fetchBlobForTransfer', () => {
|
|
it('downloads with the account in the URL', async () => {
|
|
const fetch = vi.fn(async () => new Response(new Uint8Array([1, 2, 3])));
|
|
vi.stubGlobal('fetch', fetch);
|
|
const bytes = await client().fetchBlobForTransfer('blob1', 'shared');
|
|
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
|
expect(String((fetch.mock.calls[0] as unknown[])[0])).toContain('/download/shared/blob1/');
|
|
});
|
|
|
|
it('reports a 429 to the export without pausing the rest of the client', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('slow down', { status: 429, headers: { 'Retry-After': '30' } })));
|
|
const c = client();
|
|
await expect(c.fetchBlobForTransfer('blob1', undefined)).rejects.toMatchObject({ name: 'RateLimitError', retryAfterMs: 30_000 });
|
|
expect((c as unknown as { isRateLimited(): boolean }).isRateLimited()).toBe(false);
|
|
});
|
|
});
|