From 3eac700aa2a1e27f41374518457617b5f50f1297 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:04:01 +0100 Subject: [PATCH] fix: keep the draft and say so when the server does not confirm a send --- locales/rn/en.json | 3 +++ src/api/__tests__/email-submission.test.ts | 20 +++++++++++++++++++- src/api/email.ts | 20 ++++++++++++++++++++ src/components/email/QuickReplyBox.tsx | 13 ++++++++++++- src/screens/ComposeScreen.tsx | 15 ++++++++++++++- 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index dacf2c97..194a134d 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -334,6 +334,8 @@ "send_now": "Send now", "send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.", "send_timeout_title": "No answer from the server", + "send_unconfirmed_body": "The server did not confirm the send. Check your Sent folder before sending again. Your draft has been kept.", + "send_unconfirmed_title": "Send not confirmed", "submission_limit": "The server refused to send the message because a limit was reached: {reason}", "submission_quota": "The server has too many sent-message records for this account. Bulwark removed the ones it could; if this keeps happening, ask your administrator to raise the email submission limit.", "toolbar": { @@ -451,6 +453,7 @@ "unsupported": "No preview for this file type." }, "quick_reply_login_switched": "This message is in another account. Switch back to it to reply.", + "quick_reply_unconfirmed": "The server did not confirm the send. Check your Sent folder before sending again.", "received_via": "to {name}", "remove_color": "Remove tag", "replied": "Replied", diff --git a/src/api/__tests__/email-submission.test.ts b/src/api/__tests__/email-submission.test.ts index 0b5213fe..7306ba4b 100644 --- a/src/api/__tests__/email-submission.test.ts +++ b/src/api/__tests__/email-submission.test.ts @@ -17,7 +17,9 @@ vi.mock('../jmap-client', () => ({ })); import { jmapClient } from '../jmap-client'; -import { cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email'; +import { + cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail, SendUnconfirmedError, +} from '../email'; import { ScheduleTooLateError } from '../jmap-result'; const mockRequest = jmapClient.request as ReturnType; @@ -257,6 +259,22 @@ describe('sendEmail', () => { .rejects.toThrow('Invalid envelope'); }); + // #1019: an answer without the submission confirms nothing. The draft + // stays, and so does the copy, which may be the one that went out. + it('reports a send the server did not confirm as unconfirmed, and keeps the draft', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } } }, '0'], + ], + }); + + await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb', undefined, { + draftsMailboxId: 'drafts-mb', draftId: 'old-draft', + })).rejects.toBeInstanceOf(SendUnconfirmedError); + // No destroy of the old draft version, nor of the copy. + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + it('still fails when the message could not be created', async () => { mockRequest.mockResolvedValueOnce({ methodResponses: [ diff --git a/src/api/email.ts b/src/api/email.ts index 75aaabc4..0126a54f 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1650,6 +1650,18 @@ function submissionError(err: { description?: string; type?: string }, fallback: return new ScheduleTooLateError(limit); } +/** + * The send request came back without an EmailSubmission and without an + * error: nothing confirms the message left, and it may have (webmail + * SendUnconfirmedError, #1019). 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'; + } +} + export interface SendEmailOptions { /** * Drafts folder id. When given, the message is created there with `$draft` @@ -1804,6 +1816,14 @@ export async function sendEmail( } } + // No submission came back and nothing said why: an answer cut short, or a + // server that skipped the call. Nothing confirms the message left, and it + // may have. Reporting it as sent dropped the draft and closed the composer + // (#1019); this keeps both, and the copy made for it too, since it may be + // the one that went out. + if (!failure && !emailSubmissionId) failure = new SendUnconfirmedError(); + if (failure instanceof SendUnconfirmedError) throw failure; + if (failure) { // Nothing went out. A message created before the submission was refused // must not stay behind: nobody tracked that copy, so a retry left a diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index bbd0bddc..3d3f7068 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -11,7 +11,7 @@ import { toast } from '../../stores/toast-store'; import { currentSendLogin, useSendUndoStore } from '../../stores/send-undo-store'; import { useAccountStore } from '../../stores/account-store'; import { loginMovedFrom } from '../../lib/compose-login'; -import { sendEmail, patchKeywordsForEmails } from '../../api/email'; +import { sendEmail, patchKeywordsForEmails, SendUnconfirmedError } from '../../api/email'; import { jmapClient } from '../../api/jmap-client'; import { pruneEmailSubmissions } from '../../api/submissions'; import { buildReplyRecipients } from '../../lib/reply-recipients'; @@ -167,6 +167,17 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF toast.success(t('notifications.email_sent', 'Email sent successfully')); } } catch (err) { + if (err instanceof SendUnconfirmedError) { + // It may have gone out: the text stays, and Sent tells (#1019). + Alert.alert( + t('email_composer.send_unconfirmed_title', 'Send not confirmed'), + t( + 'email_viewer.quick_reply_unconfirmed', + 'The server did not confirm the send. Check your Sent folder before sending again.', + ), + ); + return; + } Alert.alert(t('email_composer.send_failed', 'Failed to send'), sendFailureMessage(err, t)); } finally { setSending(false); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index bee59575..572d29bb 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -43,7 +43,8 @@ import { toast } from '../stores/toast-store'; import { type EmailTemplate } from '../stores/templates-store'; import { getIdentities } from '../api/identity'; import { - sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail, + sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, SendUnconfirmedError, + type OutgoingAttachment, type OutgoingEmail, } from '../api/email'; import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client'; import { pruneEmailSubmissions } from '../api/submissions'; @@ -2299,6 +2300,18 @@ export default function ComposeScreen({ route, navigation }: Props) { ); return; } + if (e instanceof SendUnconfirmedError) { + // The message may have gone out: the composer and the draft stay, + // and the user checks Sent rather than sending it twice (#1019). + Alert.alert( + t('email_composer.send_unconfirmed_title', 'Send not confirmed'), + t( + 'email_composer.send_unconfirmed_body', + 'The server did not confirm the send. Check your Sent folder before sending again. Your draft has been kept.', + ), + ); + return; + } if (e instanceof ScheduleTooLateError) { // The server refused the hold; the pickers now only offer times // within the limit it named.