mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Stalwart 1.0 counts every EmailSubmission record of an account against `maxSubmissions` (500 by default), whatever its status or age, and its own purge of old records never runs. Bulwark never destroyed a submission, so after about 500 JMAP sends an account could not send at all: every send, read receipt and reschedule failed with a raw "There are too many email submissions" error, and each failed attempt left one more copy of the message in Drafts. Bulwark now removes the records that can no longer be cancelled. Destroying a record never stops its delivery, but a pending one could no longer be cancelled, so pending records are never touched: - an immediate send (no hold, no undo window) destroys its own record right after the send, riding along with the old-draft cleanup; only where `urn:stalwart:jmap` is advertised, and never decided by the created `undoStatus`, which 0.16.10-0.16.16 report as final for held sends - a throttled background prune destroys canceled records and final records whose `sendAt` has passed, going by `EmailSubmission/get`, in every submission account; it runs at startup, after sends, after undo and on EmailSubmission state changes - a confirmed cancel (undo, cancel, reschedule) destroys the canceled record in a request of its own - an `overQuota` on a submission create prunes at once, on Stalwart walks the change log for records older than the query's three-day window, retries once, and otherwise throws `SubmissionQuotaError`, shown as a translated explanation in the composer, quick reply, read receipts and the scheduled-send actions Failed sends now clean up after themselves and say so: - a failed send removes the Drafts copy it created and keeps the user's draft; the raw send paths remove their import the same way - a read receipt that did not go out is taken back out of Sent, a method-level error counts as a failure, `$MDNSent` is only set after a sent receipt, and the automatic receipt no longer retries on every refresh of the open message - quick-reply failures, "Send now", "Reschedule" and a failed undo are shown instead of only logged - the scheduled list reads every page of `EmailSubmission/query`; it stopped after the first because `total` needs `calculateTotal`
242 lines
9.6 KiB
TypeScript
242 lines
9.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { JMAPClient } 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 } = {}) {
|
|
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 (args.destroy) {
|
|
result.destroyed = args.destroy;
|
|
}
|
|
methodResponses.push(['Email/set', result, callId]);
|
|
} else if (method === 'EmailSubmission/set') {
|
|
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 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 but removes its own Drafts copy 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();
|
|
|
|
// Every attempt creates a fresh copy to submit; left behind, each failed
|
|
// send added one more duplicate to Drafts. The user's draft stays.
|
|
const destroyed = emailSetCalls(captured).flatMap(set => (set.destroy as string[] | undefined) ?? []);
|
|
expect(destroyed).toEqual(['email-new-1']);
|
|
});
|
|
|
|
it('sendEmail removes its Drafts copy when the submission call fails outright', async () => {
|
|
const client = createClient();
|
|
const captured = mockFlow();
|
|
const fetchMock = vi.mocked(globalThis.fetch);
|
|
const answer = fetchMock.getMockImplementation()!;
|
|
fetchMock.mockImplementation(async (url, init) => {
|
|
const response = await answer(url, init);
|
|
const payload = await response.json() as { methodResponses: Array<[string, unknown, string]> };
|
|
// A method-level error instead of EmailSubmission/set's answer.
|
|
const methodResponses = payload.methodResponses.map(([name, result, callId]) => (
|
|
name === 'EmailSubmission/set' ? ['error', { type: 'serverFail', description: 'Temporary failure' }, callId] : [name, result, callId]
|
|
));
|
|
return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify({ methodResponses })) } as Response;
|
|
});
|
|
|
|
await expect(client.sendEmail(
|
|
['bob@example.com'], 'Subject', 'body',
|
|
undefined, undefined, 'identity-1', 'user@example.com',
|
|
'draft-old-1',
|
|
)).rejects.toThrow('Temporary failure');
|
|
|
|
const destroyed = emailSetCalls(captured).flatMap(set => (set.destroy as string[] | undefined) ?? []);
|
|
expect(destroyed).toEqual(['email-new-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' },
|
|
]);
|
|
});
|
|
});
|