How to Parse Email Threads

Reconstruct email conversation threads from webhook payloads using Message-ID, In-Reply-To, and References headers. Build a thread-aware email processing pipeline with JsonHook.

Table of Contents
  1. Overview
  2. Prerequisites
  3. Step-by-Step Instructions
  4. Code Example
  5. Common Pitfalls

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 Key

Step-by-Step Instructions

Implement email thread tracking:

  1. 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);
  2. 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;
  3. Look up or create the thread in your database using the threadRootId.
  4. Store the email as a thread member with its position in the thread (reply depth).
  5. 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.

Frequently Asked Questions

Do all email clients set the References header?

Most modern email clients set References correctly. Older clients, mobile apps, and some automated sending tools may only set In-Reply-To or omit threading headers entirely. Your thread grouping logic should handle all three cases: full References chain, In-Reply-To only, and neither (standalone message).

How do I display a thread in chronological order?

Sort thread messages by email.date for display to users (represents when each message was sent) or by receivedAt for system-internal ordering. For rendering a tree structure (nested replies), use the depth field and parentMessageId to build the tree from the flat list.

Can I thread emails that come from multiple senders?

Yes. Thread grouping is based on Message-ID linkage, not sender identity. Emails from different participants in the same conversation (a back-and-forth between customer and support agent) correctly link into the same thread via their In-Reply-To and References headers as long as each participant's email client sets these headers properly.

What is the maximum depth a thread can have?

Email threads can technically be arbitrarily deep. The References header grows with each reply, adding one Message-ID per hop. In practice, customer support threads rarely exceed 20-30 messages. Design your schema to handle at least 100 messages per thread to cover edge cases.