From 93761e67ef67c9d5f8c5a0694378a90ea252a26c Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:26:43 +0200 Subject: [PATCH] fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replying put `email.id` — the server-local JMAP handle, e.g. `gos2aabtux` — into In-Reply-To/References. No recipient can match that against any Message-ID, so the conversation splits in two: the original sits alone in its thread while the reply (and everything answering it) starts a fresh one. Proven against a live Stalwart account: the reply carried `In-Reply-To: gos2aabtux` and landed in thread `btuz` while its parent stayed in `btux` — Stalwart hashes the referenced msg-ids at ingest and found no match. Fetch `messageId`/`inReplyTo`/`references` with the full email and build the headers from them (bracketed, ancestors first, parent closing the chain). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb --- src/api/email.ts | 2 + src/api/types.ts | 4 ++ src/lib/__tests__/email-threading.test.ts | 41 ++++++++++++++++++ src/lib/email-threading.ts | 51 +++++++++++++++++++++++ src/screens/EmailThreadScreen.tsx | 5 ++- 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/email-threading.test.ts create mode 100644 src/lib/email-threading.ts diff --git a/src/api/email.ts b/src/api/email.ts index b24be026..fe9dacc2 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -12,6 +12,8 @@ const EMAIL_FULL_PROPERTIES = [ ...EMAIL_LIST_PROPERTIES, 'bodyStructure', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt', + // Needed to thread replies (In-Reply-To/References) — see lib/email-threading. + 'messageId', 'inReplyTo', 'references', ]; export async function getMailboxes(): Promise { diff --git a/src/api/types.ts b/src/api/types.ts index a686639d..81f2b6c0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -84,6 +84,10 @@ export interface Email { attachments?: Attachment[]; blobId?: string; bodyStructure?: BodyPart; + /** RFC 5322 msg-ids, bare (no angle brackets) per RFC 8621 §4.1.2.3. */ + messageId?: string[]; + inReplyTo?: string[]; + references?: string[]; } export interface MailboxRights { diff --git a/src/lib/__tests__/email-threading.test.ts b/src/lib/__tests__/email-threading.test.ts new file mode 100644 index 00000000..fdc12089 --- /dev/null +++ b/src/lib/__tests__/email-threading.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { computeReplyThreadingHeaders } from '../email-threading'; + +describe('computeReplyThreadingHeaders', () => { + it('returns undefined when the parent has no msg-id', () => { + expect(computeReplyThreadingHeaders(undefined)).toBeUndefined(); + expect(computeReplyThreadingHeaders({})).toBeUndefined(); + expect(computeReplyThreadingHeaders({ messageId: [] })).toBeUndefined(); + }); + + it('brackets the parent msg-id', () => { + expect(computeReplyThreadingHeaders({ messageId: ['abc@host'] })).toEqual({ + inReplyTo: '', + references: '', + }); + }); + + it('keeps the ancestor chain and closes it with the parent', () => { + expect( + computeReplyThreadingHeaders({ + messageId: ['c@host'], + references: ['a@host', 'b@host'], + }), + ).toEqual({ + inReplyTo: '', + references: ' ', + }); + }); + + it('de-dupes an ancestor that is already the parent', () => { + expect( + computeReplyThreadingHeaders({ messageId: ['a@host'], references: ['a@host'] }), + ).toEqual({ inReplyTo: '', references: '' }); + }); + + it('tolerates ids that already carry angle brackets', () => { + expect( + computeReplyThreadingHeaders({ messageId: [''], references: [''] }), + ).toEqual({ inReplyTo: '', references: ' ' }); + }); +}); diff --git a/src/lib/email-threading.ts b/src/lib/email-threading.ts new file mode 100644 index 00000000..47239478 --- /dev/null +++ b/src/lib/email-threading.ts @@ -0,0 +1,51 @@ +/** + * RFC 5322 §3.6.4 threading headers for replies. + * + * The JMAP `Email` id (`gos2aabtux`) is a server-local handle — it is NOT the + * RFC msg-id. Putting it in In-Reply-To/References breaks the conversation for + * every downstream client, including the account's own server: Stalwart hashes + * the referenced msg-ids at ingest time, finds nothing that matches, and files + * the reply under a brand-new thread. + * + * `messageId` / `references` come back from JMAP as bare ids (no angle + * brackets, RFC 8621 §4.1.2.3); the wire headers need them bracketed. + */ + +export interface ParentThreadingInfo { + messageId?: string[]; + references?: string[]; +} + +export interface ReplyThreadingHeaders { + /** In-Reply-To header value, e.g. `` */ + inReplyTo: string; + /** References header value: ancestors then parent, space separated */ + references: string; +} + +function stripBrackets(id: string): string { + return id.trim().replace(/^<|>$/g, ''); +} + +export function computeReplyThreadingHeaders( + parent: ParentThreadingInfo | undefined, +): ReplyThreadingHeaders | undefined { + const parentId = parent?.messageId?.map(stripBrackets).find(Boolean); + if (!parentId) return undefined; + + const ancestors = (parent?.references ?? []).map(stripBrackets).filter(Boolean); + + // De-dupe while preserving order; the parent's id closes the chain. + const seen = new Set(); + const chain: string[] = []; + for (const id of [...ancestors, parentId]) { + if (seen.has(id)) continue; + seen.add(id); + chain.push(id); + } + + return { + inReplyTo: `<${parentId}>`, + references: chain.map((id) => `<${id}>`).join(' '), + }; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 14632870..c22cbf7c 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -27,6 +27,7 @@ import { } from '../stores/settings-store'; import { setEmailKeywords } from '../api/email'; import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export'; +import { computeReplyThreadingHeaders } from '../lib/email-threading'; import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; import { useSheetDrag } from '../lib/use-sheet-drag'; import { useLocaleStore } from '../stores/locale-store'; @@ -347,6 +348,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (!email) return; const from = email.from?.[0]; if (!from && mode !== 'forward') return; + const threading = computeReplyThreadingHeaders(email); navigation.navigate('Compose', { mode, replyTo: { @@ -356,7 +358,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) { subject: email.subject ?? '', body: plainTextBody(email), receivedAt: email.receivedAt, - inReplyTo: email.id, + inReplyTo: threading?.inReplyTo, + references: threading?.references, }, }); };