mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: remove the unsent copy from Drafts when a send fails
This commit is contained in:
@@ -208,8 +208,9 @@ describe('draft replace lifecycle (#849)', () => {
|
||||
'draft-old-1',
|
||||
)).rejects.toThrow();
|
||||
|
||||
// Only the unsent copy may go; the user's draft stays for a retry.
|
||||
for (const set of emailSetCalls(captured)) {
|
||||
expect(set.destroy).toBeUndefined();
|
||||
expect(set.destroy ?? []).not.toContain('draft-old-1');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -242,7 +243,7 @@ describe('compose failure recovery', () => {
|
||||
// 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);
|
||||
expect(emailSetCalls(captured).some(call => (call.destroy as string[] | undefined)?.includes('old-draft'))).toBe(false);
|
||||
});
|
||||
it('accepts empty notCreated maps as success', async () => {
|
||||
const client = createClient(); mockFlow({ emptyErrors: true });
|
||||
|
||||
@@ -225,6 +225,16 @@ describe('JMAPClient.sendEmail refused recipients (#1123)', () => {
|
||||
await expect(send(createClient())).rejects.toThrow('Sending is disabled.');
|
||||
});
|
||||
|
||||
it('removes the unsent copy from Drafts when the submission is refused', async () => {
|
||||
// The copy was created in Drafts next to the user's own draft. Left
|
||||
// there, every failed attempt added a duplicate that outlived the send.
|
||||
const { destroyed } = mockServer({ refuseSubmission: true });
|
||||
|
||||
await expect(send(createClient(), { draftId: 'draft-1' })).rejects.toThrow('Sending is disabled.');
|
||||
|
||||
expect(destroyed).toEqual(['email-9']);
|
||||
});
|
||||
|
||||
it('treats a server that cannot answer the read-back as a plain success', async () => {
|
||||
mockServer({ noSubmissionGet: true });
|
||||
|
||||
@@ -288,6 +298,14 @@ describe('JMAPClient raw sends refused recipients (#1123)', () => {
|
||||
expect(destroyed).toEqual(['email-9']);
|
||||
});
|
||||
|
||||
it('sendRawEmail removes the imported copy when the submission is refused', async () => {
|
||||
const { destroyed } = mockServer({ refuseSubmission: true });
|
||||
|
||||
await expect(rawClient().sendRawEmail(new Blob(['raw']), 'identity-1', 'mb-sent', 'mb-drafts'))
|
||||
.rejects.toThrow('Sending is disabled.');
|
||||
expect(destroyed).toEqual(['email-9']);
|
||||
});
|
||||
|
||||
it('submitRawEmail fails when every recipient was refused', async () => {
|
||||
const { requests } = mockServer({
|
||||
deliveryStatus: { 'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' } },
|
||||
|
||||
@@ -4179,8 +4179,23 @@ export class JMAPClient implements IJMAPClient {
|
||||
const statusReadBack = takeDeliveryStatus(response);
|
||||
response = statusReadBack.response;
|
||||
|
||||
// The outgoing copy is created in Drafts, next to the user's own draft.
|
||||
// When the submission fails it never moves to Sent, so remove it before
|
||||
// reporting the failure; the user's draft is kept for the retry.
|
||||
const unsentCopyId = response.methodResponses
|
||||
?.find(([name]) => name === 'Email/set')?.[1]?.created?.[emailId]?.id as string | undefined;
|
||||
const dropUnsentCopy = async () => {
|
||||
if (!unsentCopyId) return;
|
||||
try {
|
||||
await this.request([["Email/set", { accountId: targetAccountId, destroy: [unsentCopyId] }, "0"]]);
|
||||
} catch (err) {
|
||||
console.error('[sendEmail] removing the unsent copy from Drafts failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const { failure, filing } = sendMethodErrors(response.methodResponses);
|
||||
if (failure) {
|
||||
await dropUnsentCopy();
|
||||
console.error('[sendEmail] JMAP method error:', failure);
|
||||
const propsHint = failure.properties?.length ? ` (properties: ${failure.properties.join(', ')})` : '';
|
||||
throw new Error(`${failure.description || `Failed to send email: ${failure.type}`}${propsHint}`);
|
||||
@@ -4208,6 +4223,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
`[sendEmail] ${methodName} notCreated:`,
|
||||
JSON.stringify(errors, null, 2),
|
||||
);
|
||||
if (methodName === 'EmailSubmission/set') await dropUnsentCopy();
|
||||
this.throwIfHoldTooLong(firstError);
|
||||
const propsHint = firstError?.properties?.length
|
||||
? ` (properties: ${firstError.properties.join(', ')})`
|
||||
@@ -9234,14 +9250,29 @@ export class JMAPClient implements IJMAPClient {
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
|
||||
// A failed submission leaves the imported copy behind; drop it so retries
|
||||
// do not pile up copies next to the draft (see sendEmail).
|
||||
const importedId = (response.methodResponses ?? [])
|
||||
.find(([name]) => name === 'Email/import')?.[1]?.created?.['raw-import']?.id as string | undefined;
|
||||
const dropUnsentCopy = async () => {
|
||||
if (!importedId) return;
|
||||
try {
|
||||
await this.request([['Email/set', { accountId: this.accountId, destroy: [importedId] }, '0']]);
|
||||
} catch (err) {
|
||||
console.error('[sendRawEmail] removing the unsent copy failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Check for errors
|
||||
const { failure } = sendMethodErrors(response.methodResponses);
|
||||
if (failure) {
|
||||
await dropUnsentCopy();
|
||||
throw new Error(failure.description || `Failed: ${failure.type}`);
|
||||
}
|
||||
for (const [methodName, result] of response.methodResponses ?? []) {
|
||||
const r = result as { notCreated?: Record<string, { description?: string; type?: string }> };
|
||||
if (r.notCreated) {
|
||||
if (methodName === 'EmailSubmission/set') await dropUnsentCopy();
|
||||
const firstErr = Object.values(r.notCreated)[0];
|
||||
this.throwIfHoldTooLong(firstErr);
|
||||
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
||||
|
||||
Reference in New Issue
Block a user