mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: don't move a queued message to drafts when an earlier attempt already sent it
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user