Overview
Email threads (conversations) are linked together through three headers:
- Message-ID: A unique identifier for each email message, set by the sender's mail server
- In-Reply-To: Contains the Message-ID of the email being directly replied to
- References: Contains the full chain of Message-IDs in the thread, space-separated
To group emails into threads, you find the root Message-ID (the first ID in the References chain) and use it as the thread identifier. All emails with the same root ID belong to the same conversation thread.
JsonHook makes thread parsing easy because all headers — including Message-ID, In-Reply-To, and References — are available in the email.headers object of every webhook payload. You just need to implement the thread grouping logic in your handler.
Prerequisites
Thread tracking requirements:
- A database table to store email threads and their member messages
- A JsonHook inbound address receiving the reply emails you want to thread
- Understanding that Message-IDs are often wrapped in angle brackets (
<id@domain>) — normalize them before storing
Build Thread-Aware Email Processing
Every header available in every payload. Group replies into conversations automatically.
Get Free API KeyStep-by-Step Instructions
Implement email thread tracking:
- Extract threading headers:
const messageId = email.messageId?.replace(/^<|>$/g, "") ?? null; const inReplyTo = email.headers["in-reply-to"]?.replace(/^<|>$/g, "") ?? null; const references = (email.headers["references"] ?? "") .split(/s+/) .map(id => id.replace(/^<|>$/g, "")) .filter(Boolean); - Find the thread root. The root is the first ID in the References chain, or the inReplyTo ID, or the messageId itself if it is a new thread:
const threadRootId = references[0] ?? inReplyTo ?? messageId; - Look up or create the thread in your database using the threadRootId.
- Store the email as a thread member with its position in the thread (reply depth).
- Update thread metadata: last activity, participant list, unread count, etc.
Code Example
Thread grouping logic in TypeScript with PostgreSQL:
interface ThreadInfo {
threadId: string;
depth: number;
parentMessageId: string | null;
}
function extractThreadInfo(email: any): ThreadInfo {
const rawMessageId = email.messageId ?? "";
const messageId = rawMessageId.replace(/^<|>$/g, "");
const inReplyTo = (email.headers["in-reply-to"] ?? "")
.replace(/^<|>$/g, "") || null;
const references = (email.headers["references"] ?? "")
.split(/s+/)
.map((id: string) => id.replace(/^<|>$/g, ""))
.filter(Boolean);
const threadRootId = references[0] ?? inReplyTo ?? messageId;
const depth = references.length;
return { threadId: threadRootId, depth, parentMessageId: inReplyTo };
}
async function saveEmailToThread(payload: any, db: Pool) {
const { email, deliveryId } = payload;
const { threadId, depth, parentMessageId } = extractThreadInfo(email);
// Upsert thread
await db.query(
`INSERT INTO email_threads (thread_id, subject, last_activity, participant_count)
VALUES ($1, $2, NOW(), 1)
ON CONFLICT (thread_id) DO UPDATE
SET last_activity = NOW(),
participant_count = email_threads.participant_count + 1`,
[threadId, email.subject?.replace(/^(Re:|Fwd:)s*/i, "").trim()]
);
// Insert message
await db.query(
`INSERT INTO thread_messages
(delivery_id, thread_id, message_id, parent_message_id, depth, from_address, subject, body)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`,
[deliveryId, threadId, email.messageId, parentMessageId, depth,
email.from, email.subject, (email.textBody ?? "").slice(0, 2000)]
);
}Common Pitfalls
Thread parsing pitfalls:
- Angle bracket normalization. Message-ID values almost always arrive wrapped in angle brackets in the raw header (
<[email protected]>). Strip them before storing or comparing — inconsistency in this normalization causes threads to fragment. - Email clients that omit References. Some older or simpler mail clients only set In-Reply-To without References. Fall back to In-Reply-To as the parent reference when References is absent.
- Replies with mismatched subjects. Users sometimes change the subject line when replying, creating what appears to be a new thread. Always use the threading headers (not subject similarity) as the authoritative thread grouping mechanism.
- Emails arriving out of order. A reply may arrive before the original message if processing is asynchronous or if the original came via a different channel. Design your schema to handle orphaned messages that arrive before their parent.
- Cross-account thread merging. Threads that involve multiple JsonHook addresses (e.g., customer replies to support@, which replies back from orders@) may be fragmented. Use Message-IDs globally across all address tables to merge threads correctly.