diff --git a/src/api/__tests__/live-stalwart-compose.test.ts b/src/api/__tests__/live-stalwart-compose.test.ts new file mode 100644 index 00000000..a8ba70d3 --- /dev/null +++ b/src/api/__tests__/live-stalwart-compose.test.ts @@ -0,0 +1,207 @@ +/** + * Live checks of the composer's sending paths against a real Stalwart, for + * the differences between 0.16 and 1.0: reschedule and "Send now" cancel + * the original first (#1308), Cancel send puts the message back in Drafts + * (native #84), Files and mail share blobs (#1179, Save to Files), Blob/copy + * needs the blob capability, and templates live in the Inbox metadata on + * 1.0.0 only. + * + * 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-phone-dark@example.org BULWARK_LIVE_PASS=… \ + * npx vitest run src/api/__tests__/live-stalwart-compose.test.ts + * + * Everything it creates is removed again. Mail goes to the user's own + * address and nothing depends on delivery (SMTP is down on the test VM). + * + * Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0 (2026-10-29). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { + destroyEmails, getFullEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, saveDraftVersion, sendEmail, +} from '../email'; +import { uploadBytes } from '../blob'; +import { deleteFileNodes } from '../files'; +import { getIdentities, resolveSendAccountId } from '../identity'; +import { + getBulwarkMetadata, MetadataTooLargeError, resetBulwarkMetadata, setBulwarkMetadata, supportsBulwarkMetadata, +} from '../bulwark-metadata'; +import { CAPABILITIES } from '../types'; +import { cancelScheduledToDrafts } from '../../lib/compose-scheduled'; +import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files'; +import { servedLoginOf } from '../../lib/draft-login'; +import { useAccountStore } from '../../stores/account-store'; +import { useTemplatesStore } from '../../stores/templates-store'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-phone-dark@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const live = SERVER && PASS ? describe : describe.skip; + +const RUN = Date.now().toString(36); + +let client: JMAPClient; +let singleton: JMAPClient; + +/** Stalwart 1.0 advertises JMAP Metadata, 0.16 does not. */ +const is10 = () => !!client.currentSession?.capabilities?.['urn:ietf:params:jmap:metadata']; + +live('live Stalwart: composer and sending (0.16 and 1.0)', () => { + let drafts = ''; + let sent = ''; + const tag = `bulwark-compose-${RUN}`; + const createdEmails: string[] = []; + const createdNodes: string[] = []; + + beforeAll(async () => { + const mod = await import('../jmap-client'); + singleton = mod.jmapClient as unknown as JMAPClient; + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + singleton.restoreSnapshot(client.snapshot()); + const boxes = await getMailboxes(); + drafts = boxes.find((b) => b.role === 'drafts')?.id ?? ''; + sent = boxes.find((b) => b.role === 'sent')?.id ?? ''; + }, 60_000); + + afterAll(async () => { + const res = await singleton.request([['Email/query', { accountId: singleton.accountId, filter: { subject: tag } }, '0']]); + const leftovers = [...new Set([...(res.methodResponses[0][1].ids as string[] ?? []), ...createdEmails])]; + if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined); + if (createdNodes.length) await deleteFileNodes(createdNodes).catch(() => undefined); + }, 60_000); + + describe('scheduled sends', () => { + it('cancels the original before it schedules the replacement, then sends a cancelled one back to Drafts', async () => { + const [identity] = await getIdentities(); + const result = await sendEmail( + { from: [{ email: USER }], to: [{ email: USER }], subject: `later ${tag}`, textBody: 'live' }, + identity.id, sent, 3600, { draftsMailboxId: drafts }, + ); + createdEmails.push(result.emailId!); + const first = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!; + expect(first).toBeTruthy(); + + const request = vi.spyOn(singleton, 'request'); + const moved = await rescheduleScheduledSend(first, 7200); + // The cancel of the original goes out before the replacement's create. + const sets = request.mock.calls + .map(([calls]) => calls[0]) + .filter(([name]) => name === 'EmailSubmission/set') + .map(([, args]) => (args.update ? 'cancel' : args.create ? 'create' : 'destroy')); + expect(sets.slice(0, 3)).toEqual(['cancel', 'destroy', 'create']); + request.mockRestore(); + expect(moved.emailSubmissionId).toBeTruthy(); + const listed = await listScheduledEmails(); + expect(listed.some((s) => s.emailSubmissionId === first.emailSubmissionId)).toBe(false); + const second = listed.find((s) => s.emailSubmissionId === moved.emailSubmissionId)!; + expect(new Date(second.sendAt).getTime()).toBeGreaterThan(new Date(first.sendAt).getTime() + 3_000_000); + // The replacement filed it in Sent. + const [filed] = await getFullEmails([result.emailId!]); + expect(filed.mailboxIds[sent]).toBe(true); + + // Cancel send: nothing goes out, and the message is a draft again. + const inDrafts = await cancelScheduledToDrafts(second, await getMailboxes(), singleton.accountId); + expect(inDrafts).toBe(true); + expect((await listScheduledEmails()).some((s) => s.emailId === result.emailId)).toBe(false); + const [restored] = await getFullEmails([result.emailId!]); + expect(restored.mailboxIds[drafts]).toBe(true); + expect(restored.mailboxIds[sent]).toBeUndefined(); + expect(restored.keywords.$draft).toBe(true); + }, 90_000); + }); + + describe('Files and mail share blobs', () => { + it('saves an attachment to Files and attaches a file from Files, by blob', async () => { + const upload = await uploadBytes(new TextEncoder().encode(`hello ${RUN}`), 'text/plain'); + // Save to Files: the attachment's blob becomes a file. + const node = await saveAttachmentToFiles({ blobId: upload.blobId, name: `${tag}.txt`, type: 'text/plain' }, null); + createdNodes.push(node.id); + expect(node.id).toBeTruthy(); + + const nodes = await singleton.request( + [['FileNode/get', { accountId: singleton.accountId, ids: [node.id] }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.FILES], + ); + const stored = nodes.methodResponses[0][1].list[0]; + // Attach from Files: the file's blob goes into a draft as it is. + const part = await attachmentFromFileNode({ ...stored, nodeType: 'file', type: 'text/plain' }); + const saved = await saveDraftVersion({ + from: [{ email: USER }], to: [{ email: USER }], subject: `files ${tag}`, textBody: 'see attached', + attachments: [{ blobId: part.blobId, name: 'from-files.txt', type: 'text/plain', disposition: 'attachment' }], + }, drafts); + createdEmails.push(saved.id); + expect(saved.attachments.map((a) => a.name)).toContain('from-files.txt'); + }, 60_000); + + it('copies blobs with Blob/copy only when the blob capability is in `using`', async () => { + const upload = await uploadBytes(new TextEncoder().encode(`copy ${RUN}`), 'text/plain'); + const call = ['Blob/copy', { fromAccountId: singleton.accountId, accountId: singleton.accountId, blobIds: [upload.blobId] }, '0']; + const without = await singleton.request([call as never], [CAPABILITIES.CORE]); + expect(without.methodResponses[0][0]).toBe('error'); + const withBlob = await singleton.request([call as never], [CAPABILITIES.CORE, CAPABILITIES.BLOB]); + expect(withBlob.methodResponses[0][0]).toBe('Blob/copy'); + }, 30_000); + }); + + describe('identities', () => { + it('sends the own address from the own account (this login is in no group)', async () => { + await expect(resolveSendAccountId(USER)).resolves.toBe(singleton.accountId); + expect(singleton.getSubmissionAccountIds()).toEqual([singleton.accountId]); + }, 30_000); + }); + + describe('templates in the Inbox metadata', () => { + it('keeps no metadata on 0.16 and asks for none', async () => { + if (is10()) return; + resetBulwarkMetadata(); + const request = vi.spyOn(singleton, 'request'); + expect(supportsBulwarkMetadata(singleton.accountId)).toBe(false); + await expect(getBulwarkMetadata(singleton.accountId)).resolves.toBeNull(); + expect(request).not.toHaveBeenCalled(); + request.mockRestore(); + }); + + it('writes, reads and removes an entry on 1.0, and refuses one over 8 KB', async () => { + if (!is10()) return; + resetBulwarkMetadata(); + const key = `template.live-${RUN}`; + await setBulwarkMetadata(singleton.accountId, key, { id: `live-${RUN}`, name: 'Live', updatedAt: new Date().toISOString() }); + try { + expect((await getBulwarkMetadata(singleton.accountId))?.[key]).toMatchObject({ name: 'Live' }); + await expect(setBulwarkMetadata(singleton.accountId, `template.big-${RUN}`, { body: 'x'.repeat(9000) })) + .rejects.toBeInstanceOf(MetadataTooLargeError); + } finally { + await setBulwarkMetadata(singleton.accountId, key, null); + } + expect((await getBulwarkMetadata(singleton.accountId))?.[key]).toBeUndefined(); + }, 30_000); + + it('syncs a template made on the phone into the account, and deletes it there', async () => { + if (!is10()) return; + resetBulwarkMetadata(); + const before = await getBulwarkMetadata(singleton.accountId); + const hadMarker = !!before && 'templates' in before; + useAccountStore.setState({ activeAccountId: servedLoginOf(singleton) }); + useTemplatesStore.setState({ hydrated: true, device: [], accounts: {}, deviceListOwner: null, templates: [] }); + try { + await useTemplatesStore.getState().refresh({ force: true }); + const made = useTemplatesStore.getState().addTemplate({ name: `Live ${RUN}`, subject: 's', body: 'b' }); + await useTemplatesStore.getState().refresh({ force: true }); + expect((await getBulwarkMetadata(singleton.accountId))?.[`template.${made.id}`]).toMatchObject({ name: `Live ${RUN}` }); + + useTemplatesStore.getState().deleteTemplate(made.id); + await useTemplatesStore.getState().refresh({ force: true }); + const after = await getBulwarkMetadata(singleton.accountId); + expect(after?.[`template.${made.id}`]).toBeUndefined(); + expect((after?.templates as { deleted?: Record })?.deleted?.[made.id]).toBeTruthy(); + } finally { + if (!hadMarker) await setBulwarkMetadata(singleton.accountId, 'templates', null).catch(() => undefined); + } + }, 60_000); + }); +});