mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
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
52 lines
1.6 KiB
TypeScript
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(' '),
|
|
};
|
|
}
|