fix: show the translated check-your-Sent message for an unconfirmed send #1019

A send answered without an EmailSubmission threw a fixed English
message, which the composer showed as is in every language. It now
throws `SendUnconfirmedError`, and the composer shows the existing
`send_timeout` text, the same advice as for a send whose answer was
lost: check Sent before sending again.
This commit is contained in:
Linus Rath
2026-10-02 10:33:10 +02:00
parent dfa82f7cca
commit 07625bc513
3 changed files with 20 additions and 5 deletions
+3 -2
View File
@@ -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')
+5 -2
View File
@@ -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 () => {
+12 -1
View File
@@ -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