mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 00:07:28 +00:00
170 lines
8.6 KiB
TypeScript
170 lines
8.6 KiB
TypeScript
/**
|
|
* Live checks of the offline send queue's rule against sending twice: a
|
|
* message is found by its Message-ID, a copy that went out stops a resend,
|
|
* and the autosaved draft alone does not.
|
|
*
|
|
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
|
* `BULWARK_LIVE_PASS` are set.
|
|
*
|
|
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
|
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
|
* npx vitest run src/api/__tests__/live-stalwart-send-queue.test.ts
|
|
*
|
|
* Mail goes to the user's own address; everything it creates is removed.
|
|
* Verified against Stalwart 0.16.27 and 1.0.1 (2026-10-31).
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
|
|
import { JMAPClient } from '../jmap-client';
|
|
import { cancelScheduledSend, destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
|
|
import { getIdentities } from '../identity';
|
|
import { findCopiesByMessageId, runQueuedSend, takeBackQueuedSend } from '../queued-send';
|
|
import { cancelScheduledToDrafts } from '../../lib/compose-scheduled';
|
|
import { generateMessageId } from '../../lib/email-threading';
|
|
import { newQueuedSend } from '../../lib/send-queue';
|
|
import type { Identity, Mailbox } from '../types';
|
|
|
|
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
|
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org';
|
|
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
|
const live = SERVER && PASS ? describe : describe.skip;
|
|
|
|
const RUN_STARTED = Date.now();
|
|
const RUN = RUN_STARTED.toString(36);
|
|
|
|
let singleton: JMAPClient;
|
|
|
|
live('live Stalwart: offline send queue', () => {
|
|
let drafts = '';
|
|
let sent = '';
|
|
let boxes: Mailbox[] = [];
|
|
let identity: Identity;
|
|
const tag = `bulwark-sendqueue-${RUN}`;
|
|
|
|
beforeAll(async () => {
|
|
const mod = await import('../jmap-client');
|
|
singleton = mod.jmapClient as unknown as JMAPClient;
|
|
const client = new JMAPClient();
|
|
await client.connect(SERVER!, USER, PASS);
|
|
singleton.restoreSnapshot(client.snapshot());
|
|
boxes = await getMailboxes();
|
|
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
|
|
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
|
|
[identity] = await getIdentities();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
// Not a subject search: Stalwart answers that from a full-text index
|
|
// that lags. Every message since the run began is read and compared.
|
|
const res = await singleton.request([
|
|
['Email/query', { accountId: singleton.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'],
|
|
['Email/get', {
|
|
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'],
|
|
}, '1'],
|
|
]);
|
|
const matching = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }> ?? [])
|
|
.filter((e) => e.subject?.includes(tag)).map((e) => e.id);
|
|
const leftovers = matching;
|
|
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
|
|
}, 60_000);
|
|
|
|
const message = (subject: string) => ({
|
|
from: [{ email: identity.email }],
|
|
to: [{ email: identity.email }],
|
|
subject: `${subject} ${tag}`,
|
|
textBody: 'live',
|
|
messageId: generateMessageId(identity.email),
|
|
});
|
|
|
|
const started = Date.now();
|
|
const lookup = () => ({ mailboxIds: [sent, drafts], since: started });
|
|
|
|
const payloadFor = (outgoing: ReturnType<typeof message>, draftId?: string) => ({
|
|
outgoing, identity, fromEmail: identity.email, sentMailboxId: sent, draftsMailboxId: drafts, draftId,
|
|
});
|
|
|
|
it('finds a message by its Message-ID, and only that one', async () => {
|
|
const outgoing = message('lookup');
|
|
const result = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
|
expect(copies.map((c) => c.id)).toEqual([result.emailId]);
|
|
expect(copies[0].keywords?.$draft).toBeFalsy();
|
|
expect(await findCopiesByMessageId(generateMessageId(identity.email), lookup())).toEqual([]);
|
|
}, 60_000);
|
|
|
|
it('does not send again a message an unanswered attempt already sent', async () => {
|
|
const outgoing = message('sent-once');
|
|
const first = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
|
// The answer to that send was lost: the queued entry is still marked.
|
|
const outcome = await runQueuedSend(newQueuedSend('q1', payloadFor(outgoing), Date.now(), true), boxes);
|
|
expect(outcome.kind).toBe('alreadySent');
|
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
|
expect(copies.map((c) => c.id)).toEqual([first.emailId]);
|
|
}, 60_000);
|
|
|
|
it('takes $draft off a copy sent without a Drafts folder only once it went out', async () => {
|
|
const outgoing = message('no-drafts');
|
|
// Created in Sent with $draft; onSuccessUpdateEmail takes it off.
|
|
const result = await sendEmail(outgoing, identity.id, sent, undefined, {});
|
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
|
expect(copies.map((c) => c.id)).toEqual([result.emailId]);
|
|
expect(copies[0].keywords?.$draft).toBeFalsy();
|
|
expect(copies[0].keywords?.$seen).toBe(true);
|
|
const outcome = await runQueuedSend(newQueuedSend('q3', { ...payloadFor(outgoing), draftsMailboxId: undefined }, Date.now(), true), boxes);
|
|
expect(outcome.kind).toBe('alreadySent');
|
|
}, 60_000);
|
|
|
|
it('sends a marked message whose only copy is its draft, and removes the draft', async () => {
|
|
const outgoing = message('draft-only');
|
|
const draft = await saveDraftVersion(outgoing, drafts);
|
|
const outcome = await runQueuedSend(newQueuedSend('q2', payloadFor(outgoing, draft.id), Date.now(), true), boxes);
|
|
expect(outcome.kind).toBe('sent');
|
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
|
expect(copies).toHaveLength(1);
|
|
expect(copies[0].id).not.toBe(draft.id);
|
|
expect(copies[0].keywords?.$draft).toBeFalsy();
|
|
const res = await singleton.request([['Email/get', { accountId: singleton.accountId, ids: [copies[0].id], properties: ['mailboxIds'] }, '0']]);
|
|
expect(res.methodResponses[0][1].list[0].mailboxIds[sent]).toBe(true);
|
|
}, 60_000);
|
|
|
|
// A "send later" whose answer was lost: the server holds it, and taking it
|
|
// back from the Outbox must cancel it there, not only drop the queue entry.
|
|
const submissionOf = async (emailId: string) => {
|
|
const res = await singleton.request([
|
|
['EmailSubmission/query', { accountId: singleton.accountId, filter: { emailIds: [emailId] } }, '0'],
|
|
['EmailSubmission/get', {
|
|
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' }, properties: ['id', 'undoStatus'],
|
|
}, '1'],
|
|
], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
|
|
return res.methodResponses[1][1].list as Array<{ id: string; undoStatus: string }>;
|
|
};
|
|
|
|
it('finds a held send an unanswered attempt left, cancels it and moves it back to Drafts', async () => {
|
|
const outgoing = message('held');
|
|
const at = new Date(Date.now() + 3600_000).toISOString();
|
|
const first = await sendEmail(outgoing, identity.id, sent, 3600, { draftsMailboxId: drafts });
|
|
expect(first.scheduled).toBe(true);
|
|
const queued = newQueuedSend('q4', { ...payloadFor(outgoing), scheduledAt: at }, Date.now(), true);
|
|
const found = await takeBackQueuedSend(queued, boxes);
|
|
expect(found).toEqual({ kind: 'held', accountId: singleton.accountId, emailId: first.emailId, emailSubmissionId: first.emailSubmissionId });
|
|
if (found.kind !== 'held') return;
|
|
const { inDrafts } = await cancelScheduledToDrafts(found, boxes, singleton.accountId);
|
|
expect(inDrafts).toBe(true);
|
|
// Cancelled, and its record removed.
|
|
expect((await submissionOf(first.emailId!)).filter((x) => x.undoStatus === 'pending')).toEqual([]);
|
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
|
expect(copies.map((c) => c.id)).toEqual([first.emailId]);
|
|
expect(copies[0].keywords?.$draft).toBe(true);
|
|
}, 60_000);
|
|
|
|
it('says released for a send the server let go, and the cancel is refused', async () => {
|
|
const outgoing = message('released');
|
|
const first = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
|
const found = await takeBackQueuedSend(newQueuedSend('q5', payloadFor(outgoing), Date.now(), true), boxes);
|
|
expect(found).toEqual({ kind: 'released' });
|
|
// What the Outbox meets when the server lets a held send go between the lookup and the cancel.
|
|
const subs = await submissionOf(first.emailId!);
|
|
if (subs[0]) await expect(cancelScheduledSend(subs[0].id)).rejects.toMatchObject({ name: 'JMAPSetError' });
|
|
}, 60_000);
|
|
});
|