fix: stop reporting a send as failed when only filing it into Sent failed

`sendEmail` threw on any method error in the response. Stalwart appends
one for the implicit `onSuccessUpdateEmail` Email/set that moves the
sent message from Drafts to Sent, so a message that had already gone
out showed "Send failed" and a retry sent it a second time. A method
error that follows a created submission is now returned as a filing
warning, like the record-level filing failures already were; errors
before the submission exists still fail the send.
This commit is contained in:
Linus Rath
2026-09-24 18:43:56 +02:00
parent e67947aa93
commit c51a848dfb
2 changed files with 64 additions and 1 deletions
+55 -1
View File
@@ -11,7 +11,7 @@ vi.mock('../jmap-client', () => ({
})); }));
import { jmapClient } from '../jmap-client'; import { jmapClient } from '../jmap-client';
import { rescheduleScheduledSend } from '../email'; import { rescheduleScheduledSend, sendEmail } from '../email';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>; const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -102,3 +102,57 @@ describe('rescheduleScheduledSend', () => {
expect(mockRequest.mock.calls[0][0][0][1].create.replacement).toEqual({ emailId: 'e-1', identityId: 'id-1' }); expect(mockRequest.mock.calls[0][0][0][1].create.replacement).toEqual({ emailId: 'e-1', identityId: 'id-1' });
}); });
}); });
describe('sendEmail', () => {
const OUTGOING = {
from: [{ email: 'me@example.com' }],
to: [{ email: 'you@example.com' }],
subject: 'Hello',
textBody: 'Hi',
};
it('reports a failed post-send filing as a warning, not a failed send (B23)', async () => {
// Stalwart answers the implicit onSuccessUpdateEmail Email/set with a
// method error after the submission was created: the mail went out.
mockRequest.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1', sendAt: '2026-09-24T10:00:30Z' } } }, '1'],
['error', { type: 'forbidden', description: 'You do not have access to this mailbox' }, '1'],
],
});
const result = await sendEmail(OUTGOING, 'identity-1', 'sent-mb', 30, { draftsMailboxId: 'drafts-mb' });
expect(result).toMatchObject({
scheduled: true,
emailId: 'e-new',
emailSubmissionId: 's-1',
sendAt: '2026-09-24T10:00:30Z',
filingWarning: 'You do not have access to this mailbox',
});
});
it('still fails when the submission itself was refused', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
['error', { type: 'invalidArguments', description: 'Invalid envelope' }, '1'],
],
});
await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb', undefined, { draftsMailboxId: 'drafts-mb' }))
.rejects.toThrow('Invalid envelope');
});
it('still fails when the message could not be created', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
['error', { type: 'serverFail', description: 'Disk full' }, '0'],
['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'invalidProperties' } } }, '1'],
],
});
await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb')).rejects.toThrow('Disk full');
});
});
+9
View File
@@ -1177,6 +1177,15 @@ export async function sendEmail(
let filingWarning: string | undefined; let filingWarning: string | undefined;
for (const [methodName, result] of res.methodResponses) { for (const [methodName, result] of res.methodResponses) {
if (methodName === 'error' || methodName.endsWith('/error')) { if (methodName === 'error' || methodName.endsWith('/error')) {
// Once the submission exists the message has left (or is held), so a
// later error is the implicit `onSuccessUpdateEmail` Email/set that
// Stalwart appends failing to file it. Failing the send here made the
// user retry and send the message twice.
if (emailSubmissionId) {
const err = result as { description?: string; type?: string };
filingWarning = filingWarning ?? (err.description || err.type || 'post-send filing failed');
continue;
}
throw new Error((result as { description?: string }).description ?? 'Send failed'); throw new Error((result as { description?: string }).description ?? 'Send failed');
} }
if (methodName === 'Email/set') { if (methodName === 'Email/set') {