diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index dfc37c9b..c8f25724 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -51,6 +51,7 @@ import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; import { appendPlainTextSignature } from "@/lib/signature-utils"; +import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -752,6 +753,8 @@ export default function Home() { fromName?: string; identityId?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; + inReplyTo?: string[]; + references?: string[]; }) => { if (!client) return; @@ -759,7 +762,7 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments); + await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references); setShowComposer(false); // Mark the original email with $answered or $forwarded keyword @@ -1451,6 +1454,12 @@ export default function Home() { const originalEmailId = selectedEmail.id; + // RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234). + const threading = computeReplyThreadingHeaders({ + messageId: selectedEmail.messageId, + references: selectedEmail.references, + }); + // Send reply with just the body text await sendEmail( client, @@ -1462,7 +1471,11 @@ export default function Home() { primaryIdentity?.id, primaryIdentity?.email, undefined, - primaryIdentity?.name || undefined + primaryIdentity?.name || undefined, + undefined, + undefined, + threading?.inReplyTo, + threading?.references, ); // Mark the original email as answered @@ -2118,6 +2131,9 @@ export default function Home() { htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, receivedAt: selectedEmail.receivedAt, attachments: selectedEmail.attachments, + messageId: selectedEmail.messageId, + inReplyTo: selectedEmail.inReplyTo, + references: selectedEmail.references, } : undefined)} initialDraftText={composerDraftText} initialData={pendingDraft} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index b8982a40..bacb966a 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -31,6 +31,7 @@ import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { findReplyIdentityId } from "@/lib/reply-identity"; +import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { RichTextEditor } from "@/components/email/rich-text-editor"; /** Strip HTML tags and decode entities to get a plain-text version */ @@ -67,6 +68,8 @@ interface EmailComposerProps { fromName?: string; identityId?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; + inReplyTo?: string[]; + references?: string[]; }) => void | Promise; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; @@ -87,6 +90,11 @@ interface EmailComposerProps { receivedAt?: string; accountId?: string; attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>; + // Threading: parent's Message-ID and References, used to set RFC 5322 + // In-Reply-To and References on outgoing replies. See #234. + messageId?: string; + inReplyTo?: string[]; + references?: string[]; }; } @@ -905,6 +913,11 @@ export function EmailComposer({ return ''; }; + // RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward. + const threadingHeaders = (mode === 'reply' || mode === 'replyAll') + ? computeReplyThreadingHeaders(replyTo) + : null; + // In plain text mode, send text/plain only (no HTML body) const finalBody = plainTextMode ? appendPlainTextSignature(body, currentIdentity) @@ -968,12 +981,22 @@ export function EmailComposer({ } // 4. Build canonical MIME + // mime-builder takes inReplyTo as a single ref-form msg-id (with brackets); + // references stays an array. threadingHeaders contains bare msg-ids. + const mimeInReplyTo = threadingHeaders?.inReplyTo[0] + ? `<${threadingHeaders.inReplyTo[0]}>` + : undefined; + const mimeReferences = threadingHeaders?.references.length + ? threadingHeaders.references.map(id => `<${id}>`) + : undefined; const mimeBytes = buildMimeMessage({ from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, to: toAddresses.map(e => ({ email: e })), cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined, subject, + inReplyTo: mimeInReplyTo, + references: mimeReferences, textBody: finalBody, htmlBody: finalHtmlBody, attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, @@ -986,6 +1009,8 @@ export function EmailComposer({ to: toAddresses.map(e => ({ email: e })), cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, subject, + inReplyTo: mimeInReplyTo, + references: mimeReferences, }; // 5. Sign if enabled @@ -1042,6 +1067,8 @@ export function EmailComposer({ fromName: currentIdentity?.name || undefined, identityId: currentIdentity?.id, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, + inReplyTo: threadingHeaders?.inReplyTo, + references: threadingHeaders?.references, }); } diff --git a/lib/__tests__/email-threading.test.ts b/lib/__tests__/email-threading.test.ts new file mode 100644 index 00000000..307815b1 --- /dev/null +++ b/lib/__tests__/email-threading.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { + computeReplyThreadingHeaders, + stripMessageIdBrackets, +} from '../email-threading'; + +describe('stripMessageIdBrackets', () => { + it('strips surrounding angle brackets', () => { + expect(stripMessageIdBrackets('')).toBe('abc@example.com'); + }); + + it('handles whitespace and missing brackets', () => { + expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com'); + expect(stripMessageIdBrackets('')).toBe('abc@example.com'); + }); +}); + +describe('computeReplyThreadingHeaders', () => { + it('returns null when the parent has no Message-ID', () => { + expect(computeReplyThreadingHeaders(undefined)).toBeNull(); + expect(computeReplyThreadingHeaders({})).toBeNull(); + expect(computeReplyThreadingHeaders({ messageId: '' })).toBeNull(); + expect(computeReplyThreadingHeaders({ messageId: ' ' })).toBeNull(); + }); + + it('sets In-Reply-To to the parent Message-ID and seeds References with it', () => { + const result = computeReplyThreadingHeaders({ + messageId: '', + }); + expect(result).toEqual({ + inReplyTo: ['root@example.com'], + references: ['root@example.com'], + }); + }); + + it('appends the parent to existing References per RFC 5322', () => { + const result = computeReplyThreadingHeaders({ + messageId: '', + references: ['', ''], + }); + expect(result).toEqual({ + inReplyTo: ['msg-2@example.com'], + references: ['msg-0@example.com', 'msg-1@example.com', 'msg-2@example.com'], + }); + }); + + it('de-duplicates if the parent already appears in References', () => { + const result = computeReplyThreadingHeaders({ + messageId: '', + references: ['', ''], + }); + expect(result?.references).toEqual([ + 'msg-0@example.com', + 'msg-1@example.com', + ]); + }); + + it('accepts bare Message-IDs without angle brackets', () => { + const result = computeReplyThreadingHeaders({ + messageId: 'msg-2@example.com', + references: ['msg-1@example.com'], + }); + expect(result).toEqual({ + inReplyTo: ['msg-2@example.com'], + references: ['msg-1@example.com', 'msg-2@example.com'], + }); + }); + + // JMAP RFC 8621 §4.1.2.3 returns messageId as String[]|null. Verify we + // don't crash on that shape even though most call sites pass a string. + it('accepts an array-shaped messageId per JMAP spec', () => { + const result = computeReplyThreadingHeaders({ + messageId: [''], + references: [''], + }); + expect(result).toEqual({ + inReplyTo: ['msg-2@example.com'], + references: ['msg-1@example.com', 'msg-2@example.com'], + }); + }); + + it('returns null for an empty messageId array', () => { + expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull(); + }); +}); diff --git a/lib/__tests__/jmap-send-threading.test.ts b/lib/__tests__/jmap-send-threading.test.ts new file mode 100644 index 00000000..6a90b205 --- /dev/null +++ b/lib/__tests__/jmap-send-threading.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +function createClient(): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { + apiUrl: 'https://jmap.example.com/api', + accountId: 'account-1', + username: 'user@example.com', + }); + return client; +} + +interface JMAPMethodCall { + 0: string; + 1: Record; + 2: string; +} + +interface CapturedRequest { + using?: string[]; + methodCalls: JMAPMethodCall[]; +} + +/** + * Mock fetch to script three sequential JMAP requests sendEmail makes: + * Mailbox/get → Identity/get → Email/set + EmailSubmission/set. + * Returns the captured request bodies for assertions. + */ +function mockSendEmailFlow() { + const captured: CapturedRequest[] = []; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse((init as { body: string }).body) as CapturedRequest; + captured.push(body); + const callIdx = captured.length - 1; + + let payload: unknown; + if (callIdx === 0) { + payload = { + methodResponses: [[ + 'Mailbox/get', + { + list: [ + { id: 'mb-drafts', name: 'Drafts', role: 'drafts' }, + { id: 'mb-sent', name: 'Sent', role: 'sent' }, + ], + }, + '0', + ]], + }; + } else if (callIdx === 1) { + payload = { + methodResponses: [[ + 'Identity/get', + { list: [{ id: 'identity-1', email: 'user@example.com', mayDelete: false }] }, + '0', + ]], + }; + } else { + payload = { + methodResponses: [ + ['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record }).create)[0]]: { id: 'sent-id-1' } } }, '0'], + ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'], + ], + }; + } + + return { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(payload)), + json: () => Promise.resolve(payload), + } as Response; + }); + + return captured; +} + +describe('JMAPClient.sendEmail threading headers', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('writes inReplyTo and references on the Email/set create when supplied', async () => { + const client = createClient(); + const captured = mockSendEmailFlow(); + + await client.sendEmail( + ['recipient@example.com'], + 'Re: testmail', + 'reply body', + undefined, undefined, 'identity-1', 'user@example.com', + undefined, undefined, undefined, undefined, + [''], + ['', ''], + ); + + // Third request is the Email/set + EmailSubmission/set batch. + const setCall = captured[2].methodCalls[0]; + expect(setCall[0]).toBe('Email/set'); + const create = setCall[1].create as Record>; + const draft = Object.values(create)[0]; + + // Bare msg-ids per RFC 8621 — angle brackets stripped. + expect(draft.inReplyTo).toEqual(['parent@example.com']); + expect(draft.references).toEqual(['root@example.com', 'parent@example.com']); + }); + + it('omits threading fields when no parent ids are supplied', async () => { + const client = createClient(); + const captured = mockSendEmailFlow(); + + await client.sendEmail( + ['recipient@example.com'], + 'Fresh thread', + 'body', + undefined, undefined, 'identity-1', 'user@example.com', + ); + + const setCall = captured[2].methodCalls[0]; + const create = setCall[1].create as Record>; + const draft = Object.values(create)[0]; + + expect(draft.inReplyTo).toBeUndefined(); + expect(draft.references).toBeUndefined(); + }); + + it('drops empty / whitespace-only ids rather than sending blank entries', async () => { + const client = createClient(); + const captured = mockSendEmailFlow(); + + await client.sendEmail( + ['recipient@example.com'], + 'Re: testmail', + 'body', + undefined, undefined, 'identity-1', 'user@example.com', + undefined, undefined, undefined, undefined, + ['<>', ' ', ''], + [], + ); + + const setCall = captured[2].methodCalls[0]; + const create = setCall[1].create as Record>; + const draft = Object.values(create)[0]; + + expect(draft.inReplyTo).toEqual(['real@example.com']); + expect(draft.references).toBeUndefined(); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 868196e2..935327b5 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -430,6 +430,8 @@ export class DemoJMAPClient implements IJMAPClient { _fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, + inReplyTo?: string[], + references?: string[], ): Promise { // Remove draft if updating if (draftId) { @@ -455,6 +457,8 @@ export class DemoJMAPClient implements IJMAPClient { bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } }, attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })), messageId: `<${generateDemoId('msg')}@demo.example.com>`, + inReplyTo: inReplyTo?.length ? inReplyTo : undefined, + references: references?.length ? references : undefined, }; this.data.emails.push(email); this.recalcMailboxCounts(); diff --git a/lib/email-threading.ts b/lib/email-threading.ts new file mode 100644 index 00000000..e7eb0be9 --- /dev/null +++ b/lib/email-threading.ts @@ -0,0 +1,51 @@ +/** + * RFC 5322 §3.6.4 reply threading. + * + * Computes the In-Reply-To and References headers an outgoing reply must + * carry so MUAs can stitch the conversation back together. + * + * In-Reply-To = parent.Message-ID + * References = parent.References (if any) + parent.Message-ID + * + * Bare msg-ids only — angle brackets are stripped because JMAP RFC 8621 + * §4.1.2.3 stores Message-IDs without them. + */ + +export interface ParentThreadingInfo { + // JMAP RFC 8621 §4.1.2.3 specifies messageId as String[]|null, but the + // codebase has historically typed it as string. Accept either shape. + messageId?: string | string[]; + references?: string[]; +} + +export interface ReplyThreadingHeaders { + inReplyTo: string[]; + references: string[]; +} + +export function stripMessageIdBrackets(id: string): string { + return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim(); +} + +export function computeReplyThreadingHeaders( + parent: ParentThreadingInfo | undefined, +): ReplyThreadingHeaders | null { + const rawId = Array.isArray(parent?.messageId) ? parent.messageId[0] : parent?.messageId; + const parentId = rawId ? stripMessageIdBrackets(rawId) : ''; + if (!parentId) return null; + + const ancestors = (parent?.references ?? []) + .map(stripMessageIdBrackets) + .filter(Boolean); + + // De-dupe while preserving order; the parent's id closes the chain. + const seen = new Set(); + const references: string[] = []; + for (const id of [...ancestors, parentId]) { + if (seen.has(id)) continue; + seen.add(id); + references.push(id); + } + + return { inReplyTo: [parentId], references }; +} diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 8f37e037..50490c38 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -129,6 +129,8 @@ export interface IJMAPClient { fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, + inReplyTo?: string[], + references?: string[], ): Promise; sendImipReply(opts: { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 776f71f5..86175701 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -294,6 +294,12 @@ function foldIcsLine(line: string): string { return chunks.join('\r\n'); } +// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that +// snuck in (e.g. when echoing values that originated from RFC 5322 headers). +function stripMessageIdBrackets(id: string): string { + return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim(); +} + export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; @@ -2027,7 +2033,9 @@ export class JMAPClient implements IJMAPClient { draftId?: string, fromName?: string, htmlBody?: string, - attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, + inReplyTo?: string[], + references?: string[] ): Promise { const emailId = `send-${Date.now()}`; const mailboxes = await this.getMailboxes(); @@ -2067,6 +2075,11 @@ export class JMAPClient implements IJMAPClient { } } + // Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids + // (no angle brackets). Stalwart may return them either way, so normalize. + const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean); + const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean); + // Always create a new email with the final body content const emailCreate: Record = { from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }], @@ -2075,6 +2088,8 @@ export class JMAPClient implements IJMAPClient { cc: cc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })), subject, + inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined, + references: normalizedReferences?.length ? normalizedReferences : undefined, keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, }; diff --git a/stores/email-store.ts b/stores/email-store.ts index 197273c7..ce7321d6 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -72,7 +72,7 @@ interface EmailStore { loadMoreEmails: (client: IJMAPClient) => Promise; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; - sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>) => Promise; + sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[]) => Promise; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; @@ -507,10 +507,10 @@ export const useEmailStore = create((set, get) => ({ } }, - sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments) => { + sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references) => { set({ isLoading: true, error: null }); try { - await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments); + await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references); // Refresh handled by UI layer for immediate feedback set({ isLoading: false }); } catch (error) {