diff --git a/lib/__tests__/demo-client-threading.test.ts b/lib/__tests__/demo-client-threading.test.ts new file mode 100644 index 000000000..a30ed4489 --- /dev/null +++ b/lib/__tests__/demo-client-threading.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { DemoJMAPClient } from '@/lib/demo/demo-client'; +import { computeReplyThreadingHeaders } from '@/lib/email-threading'; + +// A server threads a reply by In-Reply-To and References. The demo gave every +// sent message a thread of its own, so a (quick) reply said "sent" but never +// showed up in the conversation it answered. +describe('DemoJMAPClient reply threading', () => { + async function inboxMessage(client: DemoJMAPClient) { + const inbox = (await client.getMailboxes()).find((m) => m.role === 'inbox')!; + const { emails } = await client.getEmails(inbox.id); + return emails.find((e) => e.messageId)!; + } + + it('puts a sent reply into the thread of the message it answers', async () => { + const client = new DemoJMAPClient(); + const original = await inboxMessage(client); + const threading = computeReplyThreadingHeaders(original)!; + + const { emailId } = await client.sendEmail( + [original.from![0].email], `Re: ${original.subject}`, 'Thanks!', + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + threading.inReplyTo, threading.references, + ); + + const sent = await client.getEmail(emailId!); + expect(sent?.threadId).toBe(original.threadId); + expect((await client.getThreadEmails(original.threadId)).map((e) => e.id)).toContain(emailId); + }); + + it('keeps a saved reply draft in the thread too', async () => { + const client = new DemoJMAPClient(); + const original = await inboxMessage(client); + const threading = computeReplyThreadingHeaders(original)!; + + const draftId = await client.createDraft( + [original.from![0].email], `Re: ${original.subject}`, 'Draft', + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + threading.inReplyTo, threading.references, + ); + + const draft = await client.getEmail(draftId); + expect(draft?.threadId).toBe(original.threadId); + expect(draft?.inReplyTo).toEqual(threading.inReplyTo); + }); + + it('starts a new thread for a message that answers nothing', async () => { + const client = new DemoJMAPClient(); + const original = await inboxMessage(client); + + const { emailId } = await client.sendEmail(['a@example.com'], original.subject ?? 'Hello', 'New message'); + + const sent = await client.getEmail(emailId!); + expect(sent?.threadId).toBeTruthy(); + expect(sent?.threadId).not.toBe(original.threadId); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 2d098ed69..9d3139013 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -13,6 +13,7 @@ import { withBasePath } from '@/lib/browser-navigation'; import { toInertBlob } from '@/lib/file-preview'; import type { StorageQuota } from "@/lib/quota"; import { buildDemoEml, demoEmlBlobId, demoEmlEmailId } from './demo-eml'; +import { stripMessageIdBrackets } from '@/lib/email-threading'; // Fixture blobs with real bytes behind them, served from public/demo/ (see // fixtures/assets.ts for the list and the licences). Any other blob id falls @@ -791,13 +792,15 @@ export class DemoJMAPClient implements IJMAPClient { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, _fromName?: string, htmlBody?: string, + inReplyTo?: string[], + references?: string[], ): Promise { const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts'); const id = draftId || generateDemoId('email'); const existing = draftId ? this.data.emails.findIndex(e => e.id === draftId) : -1; const email: Email = { - id, threadId: generateDemoId('thread'), + id, threadId: this.threadFor(inReplyTo, references), mailboxIds: { [draftsMb?.id || this.data.mailboxes[0].id]: true }, keywords: { $seen: true, $draft: true }, size: body.length, @@ -819,6 +822,8 @@ export class DemoJMAPClient implements IJMAPClient { : { '1': { value: body } }, attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })), messageId: `<${id}@demo.example.com>`, + inReplyTo: inReplyTo?.length ? inReplyTo : undefined, + references: references?.length ? references : undefined, }; if (existing >= 0) { @@ -853,7 +858,7 @@ export class DemoJMAPClient implements IJMAPClient { } const sentMb = this.data.mailboxes.find(m => m.role === 'sent'); const email: Email = { - id: generateDemoId('email'), threadId: generateDemoId('thread'), + id: generateDemoId('email'), threadId: this.threadFor(inReplyTo, references), mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || this.data.mailboxes[0].id) : (sentMb?.id || this.data.mailboxes[0].id)]: true }, keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true }, size: body.length + (htmlBody?.length || 0), @@ -893,6 +898,19 @@ export class DemoJMAPClient implements IJMAPClient { : { scheduled: false, emailId: email.id }; } + /** + * The thread a new message joins. A server threads a reply by In-Reply-To + * and References, so the reply shows up in the conversation it answers; + * without a known parent the message starts a thread of its own. + */ + private threadFor(inReplyTo?: string[], references?: string[]): string { + const wanted = new Set([...(inReplyTo ?? []), ...(references ?? [])].map(stripMessageIdBrackets)); + const parent = wanted.size > 0 + ? this.data.emails.find(e => e.messageId && wanted.has(stripMessageIdBrackets(e.messageId))) + : undefined; + return parent?.threadId ?? generateDemoId('thread'); + } + /** The From address of new mail: the chosen identity, else the account's own address. */ private fromAddress(identityId?: string, fromEmail?: string, fromName?: string): EmailAddress { const identity = identityId ? this.data.identities.find(i => i.id === identityId) : undefined;