fix: keep the draft and say so when the server does not confirm a send

This commit is contained in:
Linus Rath
2026-10-29 03:04:14 +01:00
parent 4e57b19112
commit 3eac700aa2
5 changed files with 68 additions and 3 deletions
+3
View File
@@ -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",
+19 -1
View File
@@ -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<typeof vi.fn>;
@@ -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: [
+20
View File
@@ -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
+12 -1
View File
@@ -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);
+14 -1
View File
@@ -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.