Merge pull request #1019 from lucamzanon/fix/compose-error-recovery

fix: keep the draft when a send is not confirmed
This commit is contained in:
Linus Rath
2026-10-02 10:32:27 +02:00
2 changed files with 27 additions and 3 deletions
+17 -1
View File
@@ -29,7 +29,7 @@ interface CapturedRequest { methodCalls: JMAPMethodCall[] }
* Email/set create come back as notCreated; `failSubmission` does the same
* for EmailSubmission/set.
*/
function mockFlow({ failCreate = false, failSubmission = false } = {}) {
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;
@@ -56,11 +56,14 @@ function mockFlow({ failCreate = false, failSubmission = false } = {}) {
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' } } }
@@ -230,3 +233,16 @@ describe('draft replace lifecycle (#849)', () => {
]);
});
});
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();
expect(emailSetCalls(captured).some(call => call.destroy)).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');
});
});
+10 -2
View File
@@ -3868,7 +3868,8 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Email/set") {
const result = response.methodResponses[0][1];
if (result.notCreated) {
// An empty notCreated map is a success some servers spell out.
if (result.notCreated && Object.keys(result.notCreated).length) {
const errors = result.notCreated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Draft save error:', firstError);
@@ -4134,7 +4135,7 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (result.notCreated) {
if (result.notCreated && Object.keys(result.notCreated).length) {
// Include method name + full error object so it's clear whether the
// failure came from Email/set (draft create) or EmailSubmission/set
// (actual send) and which JMAP error type/properties were returned.
@@ -4188,6 +4189,13 @@ export class JMAPClient implements IJMAPClient {
}
}
// No EmailSubmission came back - a method error, or no answer for it at
// all. Nothing confirms the message left: report a failure and keep the
// old draft rather than replacing it as if the send had worked.
if (!emailSubmissionId) {
throw new Error('Send confirmation was not received. Check Sent before sending again. Your draft has been kept.');
}
// With every recipient refused nothing was queued, yet the submission
// exists and its onSuccessUpdateEmail filed the copy into Sent (#1123).
// Drop that copy and keep the old draft: the send failed.