fix: don't move a queued message to drafts when an earlier attempt already sent it

This commit is contained in:
Linus Rath
2026-10-31 18:27:57 +01:00
parent 1c33f8daef
commit ac875ffe1d
5 changed files with 144 additions and 25 deletions
+26 -1
View File
@@ -18,7 +18,7 @@ vi.mock('../../lib/compose-send-account', () => ({
}));
import { jmapClient } from '../jmap-client';
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
import { findCopiesByMessageId, runQueuedSend, wasQueuedSendSent } from '../queued-send';
import { newQueuedSend, type QueuedSendPayload } from '../../lib/send-queue';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -85,6 +85,31 @@ describe('runQueuedSend', () => {
});
});
describe('wasQueuedSendSent', () => {
it('finds a copy an earlier attempt sent, and removes the old draft', async () => {
mockRequest.mockResolvedValueOnce(found([
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
{ id: 'sent-1', keywords: { $seen: true }, messageId: ['m1@x.de'] },
]));
expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, true), [])).toBe(true);
expect(destroyEmails).toHaveBeenCalledWith(['draft-1'], 'acc-1');
});
it('says not sent when only drafts are there, and removes nothing', async () => {
mockRequest.mockResolvedValueOnce(found([
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
{ id: 'left-1', keywords: { $draft: true, $seen: true }, messageId: ['m1@x.de'] },
]));
expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, true), [])).toBe(false);
expect(destroyEmails).not.toHaveBeenCalled();
});
it('does not look for a message no attempt reached the server with', async () => {
expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, false), [])).toBe(false);
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('findCopiesByMessageId', () => {
it('reads the recent messages of Sent and Drafts and keeps exact matches only', async () => {
mockRequest.mockResolvedValueOnce(found([
+54 -24
View File
@@ -8,7 +8,7 @@ import type { Mailbox } from './types';
import { sendTargetFor } from '../lib/compose-send-account';
import { supportsSubmissionExtension } from '../lib/submission-options';
import { stripMessageIdBrackets } from '../lib/email-threading';
import { holdForScheduled, verdictFromCopies, type FoundCopy, type QueuedSend } from '../lib/send-queue';
import { holdForScheduled, verdictFromCopies, type CopyVerdict, type FoundCopy, type QueuedSend } from '../lib/send-queue';
/** How far before the queueing a copy can be dated (the server's clock may differ). */
const CLOCK_SKEW_MS = 10 * 60_000;
@@ -68,34 +68,64 @@ export type QueuedSendOutcome =
* went out; copies a refused attempt left in Drafts are removed before the
* new attempt.
*/
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise<QueuedSendOutcome> {
/** The account and folders a queued message goes through, as the composer works them out. */
async function queuedSendTarget(send: QueuedSend, mailboxes: Mailbox[]) {
const p = send.payload;
const target = await sendTargetFor(p.identity, p.fromEmail, mailboxes);
const accountId = target.accountId ?? jmapClient.accountId;
const draftAccountId = p.draftAccountId ?? jmapClient.accountId;
return {
target,
accountId: target.accountId ?? jmapClient.accountId,
draftAccountId: p.draftAccountId ?? jmapClient.accountId,
sentMailboxId: target.sentMailboxId ?? p.sentMailboxId,
draftsMailboxId: target.accountId ? target.draftsMailboxId : p.draftsMailboxId,
};
}
const sentMailboxId = target.sentMailboxId ?? p.sentMailboxId;
const draftsMailboxId = target.accountId ? target.draftsMailboxId : p.draftsMailboxId;
type Resolved = Awaited<ReturnType<typeof queuedSendTarget>>;
/**
* Whether an earlier, unanswered attempt at `send` went out. When it did, the
* old draft that send would have removed is removed now.
*/
async function earlierAttempt(send: QueuedSend, r: Resolved): Promise<CopyVerdict> {
const p = send.payload;
const copies = await findCopiesByMessageId(p.outgoing.messageId!, {
accountId: r.target.accountId,
mailboxIds: [r.sentMailboxId, r.draftsMailboxId ?? ''],
since: send.createdAt,
});
// The autosaved draft lives in this account only when the draft did.
const draftHere = p.draftId && r.draftAccountId === r.accountId ? p.draftId : undefined;
const verdict = verdictFromCopies(copies, draftHere);
if (verdict.kind === 'sent' && p.draftId && p.draftId !== verdict.copyId) {
// The send went out but its answer was lost, so the old draft it would
// have removed is still there.
await destroyEmails([p.draftId], r.draftAccountId).catch((err) => {
console.warn('[send-queue] could not remove the draft of a sent message', err);
});
}
return verdict;
}
/**
* Before a queued message is taken back (moved to Drafts): true when an
* earlier attempt that may have reached the server did send it. A message no
* attempt reached the server with is never looked up.
*/
export async function wasQueuedSendSent(send: QueuedSend, mailboxes: Mailbox[]): Promise<boolean> {
if (!send.maybeSent || !send.payload.outgoing.messageId) return false;
const verdict = await earlierAttempt(send, await queuedSendTarget(send, mailboxes));
return verdict.kind === 'sent';
}
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise<QueuedSendOutcome> {
const p = send.payload;
const resolved = await queuedSendTarget(send, mailboxes);
const { target, accountId, sentMailboxId, draftsMailboxId } = resolved;
if (send.maybeSent && p.outgoing.messageId) {
const copies = await findCopiesByMessageId(p.outgoing.messageId, {
accountId: target.accountId,
mailboxIds: [sentMailboxId, draftsMailboxId ?? ''],
since: send.createdAt,
});
// The autosaved draft lives in this account only when the draft did.
const draftHere = p.draftId && draftAccountId === accountId ? p.draftId : undefined;
const verdict = verdictFromCopies(copies, draftHere);
if (verdict.kind === 'sent') {
// The send went out but its answer was lost, so the old draft it would
// have removed is still there.
if (p.draftId && p.draftId !== verdict.copyId) {
await destroyEmails([p.draftId], draftAccountId).catch((err) => {
console.warn('[send-queue] could not remove the draft of a sent message', err);
});
}
return { kind: 'alreadySent', accountId: target.accountId };
}
const verdict = await earlierAttempt(send, resolved);
if (verdict.kind === 'sent') return { kind: 'alreadySent', accountId: target.accountId };
if (verdict.staleIds.length > 0) {
await destroyEmails(verdict.staleIds, accountId);
}