diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index b9d0e9bcf..e34180cb6 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -46,7 +46,7 @@ import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignat import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading"; -import { RecipientsRejectedError, RequestTimeoutError, ScheduleTooLateError, formatRejectedRecipients } from "@/lib/jmap/client"; +import { RecipientsRejectedError, RequestTimeoutError, ScheduleTooLateError, SendUnconfirmedError, formatRejectedRecipients } from "@/lib/jmap/client"; import { rewriteCidImagesForEditor, replaceInlineImagePlaceholders, @@ -2607,8 +2607,9 @@ export function EmailComposer({ // A timeout is not a clean failure: the submission may have reached the // server and gone out, with only the answer lost. Saying "send failed" // would invite a re-send and a duplicate, so point at Sent instead (#702). + // The same goes for a send the server answered without confirming it. toast.error( - err instanceof RequestTimeoutError + err instanceof RequestTimeoutError || err instanceof SendUnconfirmedError ? t('send_timeout') : err instanceof ScheduleTooLateError ? t('schedule_send_too_late') diff --git a/lib/__tests__/jmap-draft-lifecycle.test.ts b/lib/__tests__/jmap-draft-lifecycle.test.ts index f876368a0..0b657dfc1 100644 --- a/lib/__tests__/jmap-draft-lifecycle.test.ts +++ b/lib/__tests__/jmap-draft-lifecycle.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { JMAPClient } from '../jmap/client'; +import { JMAPClient, SendUnconfirmedError } from '../jmap/client'; /** * #849: replacing a draft (autosave, send) used to bundle the destroy of the @@ -238,7 +238,10 @@ 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(); + 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)).toBe(false); }); it('accepts empty notCreated maps as success', async () => { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f7a64de5c..320187428 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -775,6 +775,17 @@ export class RecipientsRejectedError extends Error { } } +/** + * The send request came back without an EmailSubmission: nothing confirms the + * message left, and it may still have. The draft is kept. + */ +export class SendUnconfirmedError extends Error { + constructor() { + super('Send confirmation was not received. Check Sent before sending again. Your draft has been kept.'); + this.name = 'SendUnconfirmedError'; + } +} + /** "report.pdf" -> "report (2).pdf", the way Stalwart's onExists "rename" names copies. */ function numberedFileName(name: string, n: number): string { const dot = name.lastIndexOf('.'); @@ -4193,7 +4204,7 @@ export class JMAPClient implements IJMAPClient { // 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.'); + throw new SendUnconfirmedError(); } // With every recipient refused nothing was queued, yet the submission