Files
native/src/lib/email-threading.ts
T
lucletoffeandClaude Opus 5 93761e67ef fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00

52 lines
1.6 KiB
TypeScript

/**
* 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. `<abc@host>` */
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<string>();
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(' '),
};
}