mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: keep the draft and say so when the server does not confirm a send
This commit is contained in:
@@ -334,6 +334,8 @@
|
|||||||
"send_now": "Send now",
|
"send_now": "Send now",
|
||||||
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
"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_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_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.",
|
"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": {
|
"toolbar": {
|
||||||
@@ -451,6 +453,7 @@
|
|||||||
"unsupported": "No preview for this file type."
|
"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_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}",
|
"received_via": "to {name}",
|
||||||
"remove_color": "Remove tag",
|
"remove_color": "Remove tag",
|
||||||
"replied": "Replied",
|
"replied": "Replied",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ vi.mock('../jmap-client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { jmapClient } from '../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';
|
import { ScheduleTooLateError } from '../jmap-result';
|
||||||
|
|
||||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
@@ -257,6 +259,22 @@ describe('sendEmail', () => {
|
|||||||
.rejects.toThrow('Invalid envelope');
|
.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 () => {
|
it('still fails when the message could not be created', async () => {
|
||||||
mockRequest.mockResolvedValueOnce({
|
mockRequest.mockResolvedValueOnce({
|
||||||
methodResponses: [
|
methodResponses: [
|
||||||
|
|||||||
@@ -1650,6 +1650,18 @@ function submissionError(err: { description?: string; type?: string }, fallback:
|
|||||||
return new ScheduleTooLateError(limit);
|
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 {
|
export interface SendEmailOptions {
|
||||||
/**
|
/**
|
||||||
* Drafts folder id. When given, the message is created there with `$draft`
|
* 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) {
|
if (failure) {
|
||||||
// Nothing went out. A message created before the submission was refused
|
// Nothing went out. A message created before the submission was refused
|
||||||
// must not stay behind: nobody tracked that copy, so a retry left a
|
// must not stay behind: nobody tracked that copy, so a retry left a
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { toast } from '../../stores/toast-store';
|
|||||||
import { currentSendLogin, useSendUndoStore } from '../../stores/send-undo-store';
|
import { currentSendLogin, useSendUndoStore } from '../../stores/send-undo-store';
|
||||||
import { useAccountStore } from '../../stores/account-store';
|
import { useAccountStore } from '../../stores/account-store';
|
||||||
import { loginMovedFrom } from '../../lib/compose-login';
|
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 { jmapClient } from '../../api/jmap-client';
|
||||||
import { pruneEmailSubmissions } from '../../api/submissions';
|
import { pruneEmailSubmissions } from '../../api/submissions';
|
||||||
import { buildReplyRecipients } from '../../lib/reply-recipients';
|
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'));
|
toast.success(t('notifications.email_sent', 'Email sent successfully'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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));
|
Alert.alert(t('email_composer.send_failed', 'Failed to send'), sendFailureMessage(err, t));
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ import { toast } from '../stores/toast-store';
|
|||||||
import { type EmailTemplate } from '../stores/templates-store';
|
import { type EmailTemplate } from '../stores/templates-store';
|
||||||
import { getIdentities } from '../api/identity';
|
import { getIdentities } from '../api/identity';
|
||||||
import {
|
import {
|
||||||
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
|
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, SendUnconfirmedError,
|
||||||
|
type OutgoingAttachment, type OutgoingEmail,
|
||||||
} from '../api/email';
|
} from '../api/email';
|
||||||
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
|
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
|
||||||
import { pruneEmailSubmissions } from '../api/submissions';
|
import { pruneEmailSubmissions } from '../api/submissions';
|
||||||
@@ -2299,6 +2300,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
);
|
);
|
||||||
return;
|
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) {
|
if (e instanceof ScheduleTooLateError) {
|
||||||
// The server refused the hold; the pickers now only offer times
|
// The server refused the hold; the pickers now only offer times
|
||||||
// within the limit it named.
|
// within the limit it named.
|
||||||
|
|||||||
Reference in New Issue
Block a user