fix: show replies sent in demo mode in the conversation they answer

This commit is contained in:
Linus Rath
2026-10-31 01:55:46 +01:00
parent 8a09ba5734
commit 6ccd384f35
2 changed files with 77 additions and 2 deletions
@@ -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);
});
});
+20 -2
View File
@@ -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<string> {
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;