mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { JMAPClient, SendUnconfirmedError } from '../jmap/client';
|
|
|
|
/**
|
|
* #849: replacing a draft (autosave, send) used to bundle the destroy of the
|
|
* previous version with the create. The two are processed independently, so a
|
|
* failed create - e.g. the new version referencing part blobs of the old one
|
|
* that die with it - still destroyed the user's only copy. The destroy must
|
|
* only ever go out after the replacement (or the outgoing message) is
|
|
* confirmed created.
|
|
*/
|
|
|
|
function createClient(): JMAPClient {
|
|
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
|
Object.assign(client, {
|
|
apiUrl: 'https://jmap.example.com/api',
|
|
accountId: 'account-1',
|
|
username: 'user@example.com',
|
|
});
|
|
return client;
|
|
}
|
|
|
|
interface JMAPMethodCall { 0: string; 1: Record<string, unknown>; 2: string }
|
|
interface CapturedRequest { methodCalls: JMAPMethodCall[] }
|
|
|
|
/**
|
|
* Capture every request; answer Mailbox/get, Identity/get, Email/set
|
|
* (create and/or destroy) and EmailSubmission/set. `failCreate` makes the
|
|
* Email/set create come back as notCreated; `failSubmission` does the same
|
|
* for EmailSubmission/set.
|
|
*/
|
|
function mockFlow({ failCreate = false, failSubmission = false, methodError = false, missingSubmission = false, emptyErrors = false } = {}) {
|
|
const captured: CapturedRequest[] = [];
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
|
|
captured.push(body);
|
|
|
|
const methodResponses: unknown[] = [];
|
|
for (const [method, args, callId] of body.methodCalls as unknown as Array<[string, Record<string, unknown>, string]>) {
|
|
if (method === 'Mailbox/get') {
|
|
methodResponses.push(['Mailbox/get', {
|
|
list: [
|
|
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
|
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
|
],
|
|
}, callId]);
|
|
} else if (method === 'Identity/get') {
|
|
methodResponses.push(['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, callId]);
|
|
} else if (method === 'Email/set') {
|
|
const result: Record<string, unknown> = {};
|
|
if (args.create) {
|
|
const key = Object.keys(args.create as Record<string, unknown>)[0];
|
|
if (failCreate) {
|
|
result.notCreated = { [key]: { type: 'blobNotFound', description: 'blobId does not exist on this server' } };
|
|
} else {
|
|
result.created = { [key]: { id: 'email-new-1' } };
|
|
}
|
|
}
|
|
if (emptyErrors) result.notCreated = {};
|
|
if (args.destroy) {
|
|
result.destroyed = args.destroy;
|
|
}
|
|
methodResponses.push(['Email/set', result, callId]);
|
|
} else if (method === 'EmailSubmission/set') {
|
|
if (missingSubmission) continue;
|
|
if (methodError) { methodResponses.push(['error', { type: 'serverFail', description: 'Google unavailable; draft retained' }, callId]); continue; }
|
|
methodResponses.push(['EmailSubmission/set',
|
|
failSubmission
|
|
? { notCreated: { '1': { type: 'forbiddenFrom', description: 'not allowed' } } }
|
|
: { created: { '1': { id: 'sub-1' } } },
|
|
callId]);
|
|
}
|
|
}
|
|
|
|
const payload = { methodResponses };
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: () => Promise.resolve(JSON.stringify(payload)),
|
|
json: () => Promise.resolve(payload),
|
|
} as Response;
|
|
});
|
|
return captured;
|
|
}
|
|
|
|
/** Every Email/set call across all captured requests, in wire order. */
|
|
function emailSetCalls(captured: CapturedRequest[]): Array<Record<string, unknown>> {
|
|
return captured.flatMap(r => r.methodCalls.filter(c => c[0] === 'Email/set').map(c => c[1]));
|
|
}
|
|
|
|
describe('draft replace lifecycle (#849)', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
it('createDraft without a previous draft issues a single create and no destroy', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow();
|
|
|
|
const id = await client.createDraft(['bob@example.com'], 'Subject', 'body');
|
|
|
|
expect(id).toBe('email-new-1');
|
|
const sets = emailSetCalls(captured);
|
|
expect(sets).toHaveLength(1);
|
|
expect(sets[0].destroy).toBeUndefined();
|
|
});
|
|
|
|
it('createDraft keeps a reply draft in its thread', async () => {
|
|
// A reply draft re-opened (or restored by Undo) has nothing else to
|
|
// rebuild In-Reply-To / References from, so the draft must carry them.
|
|
const client = createClient();
|
|
const captured = mockFlow();
|
|
|
|
await client.createDraft(
|
|
['bob@example.com'], 'Re: Subject', 'body',
|
|
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
|
['<parent@example.com>'], ['root@example.com', 'parent@example.com'],
|
|
);
|
|
|
|
const created = Object.values(emailSetCalls(captured)[0].create as Record<string, Record<string, unknown>>)[0];
|
|
expect(created.inReplyTo).toEqual(['parent@example.com']);
|
|
expect(created.references).toEqual(['root@example.com', 'parent@example.com']);
|
|
});
|
|
|
|
it('createDraft destroys the previous version only after the create succeeded', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow();
|
|
|
|
const id = await client.createDraft(
|
|
['bob@example.com'], 'Subject', 'body',
|
|
undefined, undefined, undefined, undefined,
|
|
'draft-old-1',
|
|
[{ blobId: 'blob-part-1', name: 'report.pdf', type: 'application/pdf', size: 1234 }],
|
|
);
|
|
|
|
expect(id).toBe('email-new-1');
|
|
const sets = emailSetCalls(captured);
|
|
expect(sets).toHaveLength(2);
|
|
// First request: create only - no destroy riding along.
|
|
expect(sets[0].create).toBeDefined();
|
|
expect(sets[0].destroy).toBeUndefined();
|
|
// Second, separate request: the destroy of the old version.
|
|
expect(sets[1].create).toBeUndefined();
|
|
expect(sets[1].destroy).toEqual(['draft-old-1']);
|
|
});
|
|
|
|
it('createDraft keeps the previous version when the create fails', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow({ failCreate: true });
|
|
|
|
await expect(client.createDraft(
|
|
['bob@example.com'], 'Subject', 'body',
|
|
undefined, undefined, undefined, undefined,
|
|
'draft-old-1',
|
|
[{ blobId: 'blob-dead', name: 'report.pdf', type: 'application/pdf', size: 1234 }],
|
|
)).rejects.toThrow(/blobId does not exist/);
|
|
|
|
for (const set of emailSetCalls(captured)) {
|
|
expect(set.destroy).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('sendEmail destroys the draft only after the submission succeeded', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow();
|
|
|
|
const result = await client.sendEmail(
|
|
['bob@example.com'], 'Subject', 'body',
|
|
undefined, undefined, 'identity-1', 'user@example.com',
|
|
'draft-old-1',
|
|
);
|
|
|
|
expect(result.emailId).toBe('email-new-1');
|
|
const sets = emailSetCalls(captured);
|
|
expect(sets).toHaveLength(2);
|
|
expect(sets[0].create).toBeDefined();
|
|
expect(sets[0].destroy).toBeUndefined();
|
|
expect(sets[1].destroy).toEqual(['draft-old-1']);
|
|
// The destroy request must come after the submission, not before it.
|
|
const destroyRequestIndex = captured.findIndex(r => r.methodCalls.some(c => c[0] === 'Email/set' && c[1].destroy));
|
|
const submissionRequestIndex = captured.findIndex(r => r.methodCalls.some(c => c[0] === 'EmailSubmission/set'));
|
|
expect(destroyRequestIndex).toBeGreaterThan(submissionRequestIndex);
|
|
});
|
|
|
|
it('sendEmail keeps the draft when the create fails', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow({ failCreate: true });
|
|
|
|
await expect(client.sendEmail(
|
|
['bob@example.com'], 'Subject', 'body',
|
|
undefined, undefined, 'identity-1', 'user@example.com',
|
|
'draft-old-1',
|
|
)).rejects.toThrow();
|
|
|
|
for (const set of emailSetCalls(captured)) {
|
|
expect(set.destroy).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('sendEmail keeps the draft when the submission fails', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow({ failSubmission: true });
|
|
|
|
await expect(client.sendEmail(
|
|
['bob@example.com'], 'Subject', 'body',
|
|
undefined, undefined, 'identity-1', 'user@example.com',
|
|
'draft-old-1',
|
|
)).rejects.toThrow();
|
|
|
|
// Only the unsent copy may go; the user's draft stays for a retry.
|
|
for (const set of emailSetCalls(captured)) {
|
|
expect(set.destroy ?? []).not.toContain('draft-old-1');
|
|
}
|
|
});
|
|
|
|
it('createDraft round-trips cid and disposition on attachments', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow();
|
|
|
|
await client.createDraft(
|
|
['bob@example.com'], 'Subject', 'body',
|
|
undefined, undefined, undefined, undefined, undefined,
|
|
[
|
|
{ blobId: 'blob-1', name: 'logo.png', type: 'image/png', size: 10, cid: 'img1@local', disposition: 'inline' },
|
|
{ blobId: 'blob-2', name: 'report.pdf', type: 'application/pdf', size: 20 },
|
|
],
|
|
);
|
|
|
|
const create = Object.values(emailSetCalls(captured)[0].create as Record<string, Record<string, unknown>>)[0];
|
|
expect(create.attachments).toEqual([
|
|
{ blobId: 'blob-1', name: 'logo.png', type: 'image/png', cid: 'img1@local', disposition: 'inline' },
|
|
{ blobId: 'blob-2', name: 'report.pdf', type: 'application/pdf', disposition: 'attachment' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('compose failure recovery', () => {
|
|
beforeEach(() => { vi.restoreAllMocks(); vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
it.each([{ methodError: true }, { missingSubmission: true }])('does not claim success or delete the old draft without confirmation: %j', async options => {
|
|
const client = createClient(); const captured = mockFlow(options);
|
|
await expect(client.sendEmail(['bob@example.com'], 'Subject', 'body', undefined, undefined, 'identity-1', 'user@example.com', 'old-draft')).rejects.toThrow(
|
|
// Only the missing submission is unconfirmed; a method error is a plain failure.
|
|
options.missingSubmission ? SendUnconfirmedError : Error,
|
|
);
|
|
expect(emailSetCalls(captured).some(call => (call.destroy as string[] | undefined)?.includes('old-draft'))).toBe(false);
|
|
});
|
|
it('accepts empty notCreated maps as success', async () => {
|
|
const client = createClient(); mockFlow({ emptyErrors: true });
|
|
expect(await client.createDraft(['bob@example.com'], 'Subject', 'body')).toBe('email-new-1');
|
|
});
|
|
});
|