Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save look/ebae3434b9eed3a506f45f7b09bc440f to your computer and use it in GitHub Desktop.

Select an option

Save look/ebae3434b9eed3a506f45f7b09bc440f to your computer and use it in GitHub Desktop.
Research: Outlook Conversation Grouping vs. GitHub Email Notifications — Thread-Index proposal

Outlook Conversation Grouping vs. GitHub Email Notifications

Executive Summary

GitHub email notifications frequently fail to group properly in Outlook's "conversation view" for PRs and Issues. This research identifies four root causes by comparing Outlook's proprietary threading algorithm with GitHub's email notification implementation in github/github:

  1. GitHub does not emit Thread-Index or Thread-Topic headers — the primary mechanism Outlook uses for conversation grouping. GitHub relies exclusively on RFC-standard Message-ID, In-Reply-To, and References headers, which Outlook largely ignores for non-Exchange mail.
  2. Simultaneous delivery causes race conditions — when multiple notifications for the same PR arrive at the same second (e.g., PR creation + review request event), they route through different Outlook frontend servers. The second message may be indexed before the first, preventing subject-based matching from finding a conversation to join.
  3. Subject lines change when issue/PR titles are edited — Outlook's fallback grouping mechanism (normalized subject matching) breaks when the subject changes mid-thread.
  4. Different notification types for the same PR/Issue use different Message-ID schemes — making In-Reply-To/References chains fragile. Even if Outlook honored these headers, the chains don't always connect all notification types back to a single root.

The good news: GitHub could significantly improve Outlook threading by adding two simple SMTP headers (Thread-Index and Thread-Topic) to outgoing notification emails. This is a low-risk, high-impact change.

Prior art: This problem has never been filed as an issue in the GitHub org. The closest related work was github/special-projects#605 (2021), which changed subject formats and discovered threading breakage as a side-effect — but attributed it to subject-line changes rather than identifying the missing Microsoft-specific headers. The Thread-Index fix has never been proposed, attempted, or de-prioritized — it's simply been a blind spot.


How Outlook's Conversation Grouping Works

Priority 1: Thread-Index Header (Proprietary — Primary Mechanism)

Outlook's primary conversation grouping mechanism is the Thread-Index header. This is a proprietary Microsoft header defined in the MAPI specification as PidTagConversationIndex (PR_CONVERSATION_INDEX, property tag 0x00710102)12.

Official Microsoft Documentation

Binary Format

The Thread-Index value is a Base64-encoded binary blob with a fixed structure12:

Root header (22 bytes, present in every message):
┌───────────┬─────────────────┬──────────────────────────────────┐
│ Reserved  │   FILETIME      │           GUID                   │
│  1 byte   │   5 bytes       │         16 bytes                 │
│  (0x01)   │ (timestamp)     │  (conversation identifier)       │
└───────────┴─────────────────┴──────────────────────────────────┘

Child block (5 bytes each, appended for each reply/forward):
┌────────┬───────────────────────────┬────────────┐
│  Mode  │     Time delta            │  Random    │
│ 1 bit  │    31 bits                │  8 bits    │
│        │ (ticks since parent)      │            │
└────────┴───────────────────────────┴────────────┘

Component details:

Component Offset Length Description
Reserved 0 1 byte Always 0x01 per MAPI spec
FILETIME 1 5 bytes Most-significant 5 bytes of an 8-byte Windows FILETIME3 (100-nanosecond intervals since January 1, 1601 UTC). Represents when the conversation started.
GUID 6 16 bytes A globally unique identifier for the conversation. All messages in the same conversation must share this GUID. This is the key grouping mechanism — Outlook extracts bytes 6–21 and groups messages with identical GUIDs.
Root total 22 bytes Base64-encoded: 22 bytes → 32 Base64 characters (with = padding)
Child block 22+ 5 bytes each Appended for each reply. Encodes the time difference from the root message and a random component for ordering.

How Outlook Uses Thread-Index for Grouping

Per Microsoft's Tracking Conversations guide:

  1. Group by GUID: Outlook extracts the 16-byte GUID from bytes 6–21 of the decoded Thread-Index. All messages sharing the same GUID belong to the same conversation.
  2. Sort within conversation: Messages are sorted by the full Thread-Index value. Since child blocks are appended (making replies longer), and each child block encodes a time delta, this produces a chronological tree structure.
  3. Root detection: A message with exactly 22 bytes of Thread-Index data is the conversation root. Messages with 27+ bytes are replies.

Concrete Example

For a GitHub PR notification thread, the Thread-Index would look like:

Root message (PR creation):
  Thread-Index: AQLpruZqAAAAAAAAAADNhT2Z...  (22 bytes → ~32 Base64 chars)
                ^^                ^^^^^^^^^^^^^^^^
                |                 |
                Reserved (0x01)  GUID (deterministic from "github/blackbird/pull/13858")

Reply (comment on the PR):
  Thread-Index: AQLpruZqAAAAAAAAAADNhT2Z...AAAAB  (27 bytes → ~36 Base64 chars)
                |<-------- same root -------->|<child>
                                               5-byte child block appended

Why This Is Robust

Unlike subject-based matching, Thread-Index grouping:

  • Survives title changes: The GUID is derived from the issue/PR identity, not the title
  • Survives simultaneous delivery: Both messages carry the same GUID, so Outlook groups them regardless of arrival order or which server processes them first
  • Doesn't require seeing the parent first: Even if the root message is deleted or arrives late, Outlook can still group by shared GUID

Priority 2: Thread-Topic Header (Proprietary — Secondary Mechanism)

The Thread-Topic header (MAPI property PidTagConversationTopic, PR_CONVERSATION_TOPIC) stores the normalized conversation topic4. It is set to the original subject (stripped of "Re:", "Fwd:", etc.) and remains constant even if the visible Subject header changes. Outlook uses Thread-Topic as a grouping key when Thread-Index doesn't provide a unique conversation identifier, or as additional confirmation alongside Thread-Index4.

Per Microsoft's documentation, the recommended approach is to sort messages first by PR_CONVERSATION_TOPIC and then by PR_CONVERSATION_INDEX within each topic group1.

Priority 3: Normalized Subject Matching (Fallback)

When neither Thread-Index nor Thread-Topic is present (as with most non-Exchange, non-Outlook-originated email), Outlook falls back to grouping by normalized subject line — stripping "Re:", "Fwd:", localized prefixes, and comparing case-insensitively56. This is the only mechanism available for GitHub notification emails today, and it's fragile.

What Outlook Does NOT Reliably Use

Outlook is known for largely ignoring standard In-Reply-To and References headers for conversation threading78. These are the primary mechanism used by Gmail, Thunderbird, Apple Mail, and most other email clients (per RFC 52569). This is the fundamental interoperability gap.

┌─────────────────────────────────────────────────────────────┐
│                  Outlook Threading Priority                  │
│                                                              │
│  1. Thread-Index header (proprietary, primary)               │
│     └─ Groups by shared 16-byte GUID in the root header     │
│     └─ Docs: learn.microsoft.com/.../tracking-conversations  │
│                                                              │
│  2. Thread-Topic header (proprietary, secondary)             │
│     └─ Groups by normalized topic string                     │
│     └─ Docs: learn.microsoft.com/.../pidtagconversationtopic │
│                                                              │
│  3. Normalized Subject line (fallback)                       │
│     └─ Strips "Re:", "Fwd:", etc. and compares               │
│                                                              │
│  ✗ In-Reply-To / References (standard, largely IGNORED)      │
│     └─ Used by Gmail, Thunderbird, Apple Mail, etc.          │
│     └─ Defined in RFC 5256 / RFC 2822                        │
└─────────────────────────────────────────────────────────────┘

Reference: Standard vs. Proprietary Headers

Header Standard Used By Purpose
Message-ID RFC 2822 §3.6.4 All clients Unique message identifier
In-Reply-To RFC 2822 §3.6.4 Gmail, Thunderbird, Apple Mail Parent message reference
References RFC 2822 §3.6.4 Gmail, Thunderbird, Apple Mail Full ancestor chain
Thread-Index MS-OXOMSG §2.2.1.3 Outlook only Binary conversation tree
Thread-Topic PidTagConversationTopic Outlook only Stable conversation subject

How GitHub Sends Email Notifications

GitHub's notification email system is implemented in github/github primarily through the Newsies framework (lib/newsies/emails/) and the newer Notifyd system (packages/notifications/).

Header Construction

The base Newsies::Emails::Message class constructs email headers in the headers method10:

# lib/newsies/emails/message.rb (lines ~340-360)
def headers
  @headers ||= begin
    {
      "Message-Id" => message_id,
      "In-Reply-To" => in_reply_to,
      "References" => in_reply_to,   # ← Same value as In-Reply-To!
      "Precedence" => "list",
      "Return-Path" => "<#{GitHub.urls.noreply_address}>",
      "X-GitHub-Sender" => sender&.display_login,
      "X-GitHub-Recipient" => recipient_login,
      "X-GitHub-Reason" => reason,
      "List-ID" => list_id,
      # ... additional headers
    }.delete_if { |_k, v| v.blank? }
  end
end

Key observation: There is NO Thread-Index or Thread-Topic header anywhere in the codebase.11 A search for Thread-Index, Thread-Topic, thread_index, or thread_topic across org:github returns zero results in Ruby code.

The Notifyd system's EmailHeaders class follows the identical pattern12:

# packages/notifications/app/models/notifyd/email_headers.rb (lines ~75-95)
def build
  headers = {
    "Message-ID": message_id,
    "Reply-To": reply_to,
    "In-Reply-To": in_reply_to,
    "References": in_reply_to,    # ← Again, same as In-Reply-To
    "Precedence": "list",
    # ... no Thread-Index, no Thread-Topic
  }
  headers.compact
end

Message-ID Formats

Different notification types generate different Message-ID patterns, all scoped per-entity:

Notification Type Message-ID Format Source
Issue (created) <owner/repo/issues/NUMBER/ID@github.com> Issue#message_id13
Issue Comment <owner/repo/issues/NUMBER/COMMENT_ID@github.com> IssueComment#message_id14
Issue Event (close, assign, etc.) <owner/repo/issue/NUMBER/issue_event/ID@github.com> IssueEventNotification#message_id15
Pull Request (created) <owner/repo/pull/NUMBER@github.com> PullRequest#message_id16
PR Comment <owner/repo/pull/NUMBER/cCOMMENT_ID@github.com> PullRequestComment#message_id17
PR Review <owner/repo/pull/NUMBER/review/REVIEW_ID@github.com> PullRequestReview#message_id18
PR Review Comment comment.message_id (delegates) PullRequestReviewComment#message_id19

In-Reply-To Chains

The in_reply_to method creates parent references for threading:

Email Type in_reply_to points to Source
Issue (created) nil (root message) base class10
IssueComment issue.message_id IssueComment#in_reply_to20
IssueEventNotification issue.message_id or pull_request.message_id IssueEventNotification#in_reply_to21
PullRequest (created) nil (root, inherits from Issue) inherits from Issue22
PullRequestComment pull_request.message_id PullRequestComment#in_reply_to17
PullRequestReview pull_request.message_id PullRequestReview#in_reply_to18
PullRequestReviewComment parent's in_reply_to via PullRequestComment inherits19

Critical note: References is set to the same value as In-Reply-To10, meaning only one ancestor is ever listed. Per RFC 2822, References should ideally contain the full chain of ancestor Message-IDs23. This limits the ability of any client to reconstruct the full thread tree.

Subject Line Format

Subject lines follow this pattern2425:

Type Subject Format
Issue (created) [owner/repo] Issue Title (Issue #123)
Issue comment/event Re: [owner/repo] Issue Title (Issue #123)
PR (created) [owner/repo] PR Title (PR #123)
PR comment/review/event Re: [owner/repo] PR Title (PR #123)
Legacy (pre-Oct 2021) [owner/repo] Title (#123) — no Issue/PR prefix

The issue_subject_suffix method determines the suffix format based on the issue creation date (before/after Oct 17, 2021)26. This change was made specifically to "avoid broken notification email threads" per the code comments.

Critical issue: When a PR/Issue title is edited, the subject line changes. Since Outlook's fallback is subject-matching, this breaks conversation grouping immediately.


Why Outlook Fails to Group GitHub Notifications

Root Cause Analysis

GitHub Notification Email:
┌──────────────────────────────────────────────────────────┐
│ Message-ID: <owner/repo/pull/42@github.com>              │ ← Unique per notification
│ In-Reply-To: <owner/repo/pull/42@github.com>             │ ← Points to root
│ References: <owner/repo/pull/42@github.com>              │ ← Same as In-Reply-To
│ Subject: Re: [owner/repo] Fix the bug (PR #42)           │ ← Changes if title edited
│                                                           │
│ ✗ Thread-Index: (MISSING)                                │ ← Outlook's primary mechanism
│ ✗ Thread-Topic: (MISSING)                                │ ← Outlook's secondary mechanism
└──────────────────────────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────┐
│                    Outlook Processing                     │
│                                                           │
│ 1. Check Thread-Index → NOT PRESENT → skip               │
│ 2. Check Thread-Topic → NOT PRESENT → skip               │
│ 3. Fallback to subject matching:                         │
│    Normalize "Re: [owner/repo] Fix the bug (PR #42)"    │
│    → "[owner/repo] Fix the bug (PR #42)"                │
│    Compare to other emails with same normalized subject  │
│                                                           │
│ ⚠ If title was edited: subject no longer matches!        │
│ ⚠ Different PR actions may have slightly different       │
│   subjects in edge cases                                 │
│ ⚠ In-Reply-To / References are IGNORED by Outlook       │
└──────────────────────────────────────────────────────────┘

Specific Failure Scenarios

  1. Title edits break subject matching: User renames PR from "Fix the bug" to "Fix the critical bug" → Outlook sees two different normalized subjects → splits into separate conversations.

  2. Outlook ignores In-Reply-To/References: Even when these headers correctly chain back to the root Message-ID, Outlook doesn't use them for conversation grouping in non-Exchange contexts78.

  3. References header is not a true chain: GitHub sets References to the same single value as In-Reply-To rather than accumulating the full ancestor chain10. This limits threading even in clients that do respect References.

  4. Multiple From addresses for same thread: GitHub sends each email "from" the user who performed the action (e.g., @commenter via notifications@github.com)27. Some Outlook configurations may factor sender identity into conversation grouping.


Real-World Evidence: PR #13858 Headers Analysis

Analysis of actual email headers from blackbird PR #13858 confirms the research and reveals a previously undocumented failure mode.

The Three Messages

# Type Message-ID In-Reply-To Subject Arrived Threaded?
1 PR creation <github/blackbird/pull/13858@github.com> (none) [github/blackbird] Don't index .svn repos (PR #13858) 08:25:30 ✅ Root
2 Push notification <github/blackbird/pull/13858/before/c4effa5a.../after/28ac163f...@github.com> <github/blackbird/pull/13858@github.com> Re: [github/blackbird] Don't index .svn repos (PR #13858) 08:37:12 ✅ Yes
3 Review request event <github/blackbird/pull/13858/issue_event/23400242712@github.com> <github/blackbird/pull/13858@github.com> Re: [github/blackbird] Don't index .svn repos (PR #13858) 08:25:30 No

Key Observations

  1. Outlook ignores In-Reply-To/References: Messages 2 and 3 have identical In-Reply-To and References headers pointing to the same root. Message 2 was threaded; message 3 was not. This proves Outlook does not reliably use these RFC-standard headers.

  2. No Thread-Index or Thread-Topic present: None of the three messages contain Microsoft-specific threading headers. Outlook is forced to use subject-matching as its only grouping mechanism.

  3. Simultaneous delivery race condition: Messages 1 and 3 arrived at the exact same second (08:25:30 PDT) but were routed through different Outlook frontend servers:

    • Message 1: CY4PEPF0000EE3CSA1PR21MB4327SA1PR21MB6368
    • Message 3: SJ1PEPF00002318CH8PR21MB4740SA1PR21MB6368

    Message 3 was likely indexed before message 1 was fully processed, so Outlook had no existing conversation to match its subject against. It became a separate conversation root.

  4. Message 2 succeeded because of timing: Arriving 12 minutes later (08:37:12), message 2 found message 1 already indexed and matched via normalized subject.

Why Thread-Index Would Fix This

With a Thread-Index header, all three messages would share the same root GUID (derived deterministically from the PR identifier github/blackbird/pull/13858). Outlook would group them regardless of arrival order, timing, or routing path. The race condition becomes irrelevant.


Prior Art & History in the GitHub Org

Has This Been Discussed Before?

No. A thorough search of issues across github/github, github/notifications, github/notifyd, github/special-projects, and github/community found zero issues mentioning Thread-Index, Thread-Topic, or Outlook conversation grouping for notifications.

Related Work

  1. github/special-projects#605 (closed, 2021) — Changed email subjects from (#123) to (Issue #123) / (PR #123). During staff-ship, they discovered thread breakage because changing the subject mid-conversation splits threads in subject-matching clients28. The fix was SWITCH_TO_NEW_SUBJECT_FROM (Oct 17, 2021) — only new issues/PRs use the new format26. @willsmythe explicitly warned: "How confident are we that changing the title won't break threading? From past work in this area, there is a lot of inconsistency between email clients"29 — but the root cause (missing Thread-Index) was never identified.

  2. github/github#125969 (closed, 2019) — A DKIM issue, but its attached headers reveal that when GitHub notifications are delivered directly to @microsoft.com addresses, Exchange adds Thread-Topic and Thread-Index on the receiving side30. This means Microsoft's own infrastructure compensates for the missing headers — but only for recipients whose MX points directly to Exchange, not for users forwarding from Gmail/Google Workspace.

  3. github/notifications#6384 (open, 2026) — Assesses impact of GitHub's MX migration to Outlook. Focuses on deliverability/spam, not threading. No mention of conversation grouping31.

Why It's Never Been Addressed

The Thread-Index fix has never been proposed, attempted, or de-prioritized. It's simply been a blind spot:

  • GitHub's notification team historically optimized for Gmail (the largest email provider among developers), which uses subject-matching and References headers
  • Microsoft users with @microsoft.com addresses never experienced the problem because Exchange adds Thread-Index on ingest
  • The 2021 subject-change work (special-projects#605) identified threading breakage but attributed it to subject format changes, not to missing Microsoft-specific headers

Proposed Code Changes

Overview

Two files need changes, plus a new shared utility module:

File Change Purpose
lib/newsies/emails/message.rb Add thread_index and thread_topic methods; include in headers Newsies path (current)
packages/notifications/app/models/notifyd/email_headers.rb Add Thread-Index and Thread-Topic to build Notifyd path (newer)
New: lib/github/email/thread_index.rb Shared utility for generating Thread-Index values Reusable by both paths

1. Thread-Index Generator (lib/github/email/thread_index.rb)

The Thread-Index header is a Base64-encoded binary value. The root message has a 22-byte header; replies append 5-byte child blocks.

# lib/github/email/thread_index.rb
module GitHub
  module Email
    module ThreadIndex
      # Generate a Thread-Index header value for an email notification.
      #
      # The Thread-Index is a Base64-encoded binary value used by Outlook
      # to group emails into conversations. It consists of:
      #   - A 22-byte root header (1 reserved + 5 FILETIME + 16 GUID)
      #   - Optional 5-byte child blocks for replies
      #
      # thread_key  - A stable string identifying the conversation
      #               (e.g., "github/blackbird/pull/13858")
      # created_at  - The Time the conversation was created
      # is_reply    - Whether this is a reply (appends a child block)
      # reply_time  - The Time of the reply (defaults to Time.now)
      #
      # Returns a Base64-encoded String suitable for the Thread-Index header.
      def self.generate(thread_key:, created_at:, is_reply: false, reply_time: nil)
        # Generate a deterministic GUID from the thread key using UUID v5
        # This ensures all notifications for the same thread share the same root
        guid = Digest::UUID.uuid_v5(Digest::UUID::DNS_NAMESPACE, thread_key)
        guid_bytes = [guid.delete("-")].pack("H*")

        # Convert created_at to Windows FILETIME (100ns intervals since 1601-01-01)
        # Ruby epoch is 1970-01-01; Windows FILETIME epoch is 1601-01-01
        # Difference: 11644473600 seconds
        filetime = ((created_at.to_i + 11_644_473_600) * 10_000_000)
        filetime_bytes = [filetime].pack("Q>") # big-endian 8 bytes

        # Root header: 1 reserved byte + first 5 bytes of FILETIME + 16-byte GUID
        header = [0x01].pack("C") + filetime_bytes[0, 5] + guid_bytes

        if is_reply
          reply_time ||= Time.now
          delta = ((reply_time.to_i - created_at.to_i) * 10_000_000)
          # Child block: 1 bit mode + 31 bits time delta + 8 bits random
          child = [(delta >> 18) & 0x7FFFFFFF].pack("N")
          child += [rand(256)].pack("C")
          header += child
        end

        Base64.strict_encode64(header)
      end
    end
  end
end

2. Changes to lib/newsies/emails/message.rb

Add two new methods and include them in the headers hash. The feature is gated behind a Vexi feature flag for safe rollout.

# In the headers method, add these two lines to the hash:
def headers
  @headers ||= begin
    {
      "Message-Id" => message_id,
      "In-Reply-To" => in_reply_to,
      "References" => in_reply_to,
      # ... existing headers ...
    }.delete_if { |_k, v| v.blank? }.tap do |h|
      # ... existing tap block ...

      # Add Outlook threading headers behind feature flag
      if FeatureFlag.vexi.enabled?(:newsies_outlook_thread_headers, default: false)
        h["Thread-Index"] = thread_index
        h["Thread-Topic"] = thread_topic
      end
    end
  end
end

# New method: stable conversation topic that doesn't change with title edits
def thread_topic
  iss = self.issue
  return nil unless iss

  prefix = iss.pull_request? ? "PR" : "Issue"
  "[#{repository.name_with_display_owner}] #{prefix} ##{iss.number}"
end

# New method: Thread-Index for Outlook conversation grouping
def thread_index
  iss = self.issue
  return nil unless iss

  thread_key = "#{repository.name_with_display_owner}/#{iss.pull_request? ? 'pull' : 'issues'}/#{iss.number}"

  GitHub::Email::ThreadIndex.generate(
    thread_key: thread_key,
    created_at: iss.created_at,
    is_reply: in_reply_to.present?,
    reply_time: comment.try(:created_at) || Time.now,
  )
end

3. Changes to packages/notifications/app/models/notifyd/email_headers.rb

The Notifyd EmailHeaders builder needs parallel support. Add builder methods and include in the build output:

# Add builder method:
def with_thread_index(thread_index)
  @thread_index = thread_index
  self
end

def with_thread_topic(thread_topic)
  @thread_topic = thread_topic
  self
end

# In the build method, add:
def build
  headers = {
    # ... existing headers ...
    "Thread-Index": @thread_index,
    "Thread-Topic": @thread_topic,
  }
  headers.compact
end

The callers that construct EmailHeaders (the Notifyd adapters for issues, PRs, etc.) would need to pass the computed thread_index and thread_topic values using the new builder methods.

4. What About Non-Issue/PR Notifications?

The thread_topic and thread_index methods in the base Message class return nil when there's no associated issue, which means these headers are only added for Issue/PR-related notifications. This is the right behavior — commit comments, releases, security advisories, etc., don't have a natural "conversation" to group into.

Subclasses that don't relate to issues/PRs (e.g., Newsies::Emails::Release, Newsies::Emails::CommitComment) inherit the base class's nil-returning methods, and delete_if { |_k, v| v.blank? } strips them from the headers hash.


Rollout Strategy

Feature Flag

Use a Vexi feature flag (:newsies_outlook_thread_headers) following the existing pattern in message.rb (see :newsies_add_platform_email_header for precedent)10.

Phased Rollout

Phase Scope Duration Purpose
1 Staff-ship (GitHub employees) 1–2 weeks Verify headers are correctly formed; monitor for any delivery issues
2 10% of users 1 week Check for unintended side-effects at scale (spam scoring, filter breakage)
3 50% → 100% 1–2 weeks Full rollout
4 Clean up feature flag After stable Remove conditional; make headers unconditional

Risk Assessment

Risk Likelihood Impact Mitigation
Spam filters score Thread-Index negatively Very Low Medium Thread-Index is a well-known Microsoft header; spam filters expect it
Non-Outlook clients confused by Thread-Index Very Low Low Non-Outlook clients silently ignore unknown headers
Thread-Index GUID collision Essentially Zero Low UUID v5 from unique thread key; collision probability is negligible
Existing user email filter rules break Very Low Low Thread-Index/Thread-Topic are additive; they don't change Subject, From, or any existing header
Incorrect grouping (wrong messages grouped) Low Medium Deterministic GUID from owner/repo/issue/N ensures correct grouping

Why This Is Safe

  1. Additive only: No existing headers are modified. Message-Id, In-Reply-To, References, Subject — all remain identical.
  2. Non-Outlook clients ignore it: Thread-Index and Thread-Topic are silently discarded by Gmail, Thunderbird, Apple Mail, etc.
  3. Precedent exists: The newsies_add_platform_email_header feature flag (adding X-GitHub-Notify-Platform) followed exactly this pattern — adding a new header behind a flag10.
  4. No database changes: The implementation derives Thread-Index from existing data (issue.created_at, repository.name_with_display_owner, issue.number).
  5. Lessons from special-projects#605: Unlike the 2021 subject-line change, this doesn't modify any user-visible content. The threading breakage discovered then was caused by changing the Subject header — this change adds entirely new headers that existing email clients don't process.

Recommendation 1: Add Thread-Index Header (High Impact, Medium Effort)

Generate a Thread-Index header for every outgoing notification email. This is the single most impactful change for Outlook users.

Implementation approach:

# Pseudocode for lib/newsies/emails/message.rb
def thread_index
  # For root messages (issue/PR creation): generate 22-byte header
  # For replies: copy root's Thread-Index and append 5-byte child block
  
  if root_message?
    # 1 reserved byte + 5 bytes FILETIME + 16 bytes GUID
    filetime = windows_filetime(entity.created_at)
    guid = deterministic_guid(entity)  # e.g., UUID v5 from "owner/repo/issue/123"
    header = [0x01].pack("C") + filetime[0..4] + guid
  else
    # Append 5-byte child block to root's Thread-Index
    header = root_thread_index + child_block(Time.now, entity.created_at)
  end
  
  Base64.strict_encode64(header)
end

Key design decisions:

  • Use a deterministic GUID derived from the issue/PR identifier (e.g., UUID v5 namespace) so that all notifications for the same thread share the same root GUID, even if they're generated independently.
  • The GUID must be stable — it should not change when the title is edited.
  • Child blocks should be added for reply-type notifications (comments, reviews, events).

Recommendation 2: Add Thread-Topic Header (High Impact, Low Effort)

Add a Thread-Topic header set to a stable, normalized identifier that doesn't change when the title is edited:

# In headers method:
"Thread-Topic" => "[#{repository.name_with_display_owner}] #{conversation_type} ##{issue.number}"

This would produce values like:

  • [owner/repo] Issue #123
  • [owner/repo] Pull Request #42

Why this works: Even if the PR title changes and the Subject header changes, Thread-Topic remains constant. Outlook will use Thread-Topic for grouping when it's present, regardless of subject changes4.

Recommendation 3: Improve References Header Chain (Medium Impact, Low Effort)

Currently, References is set to the same single value as In-Reply-To10. Per RFC 2822, it should accumulate the chain:

# Current:
"References" => in_reply_to

# Improved:
"References" => [root_message_id, in_reply_to].compact.uniq.join(" ")

For example, a PR review comment's References should be:

<owner/repo/pull/42@github.com> <owner/repo/pull/42/review/789@github.com>

This wouldn't help Outlook (which ignores References), but it would improve threading in Gmail, Thunderbird, Apple Mail, and other RFC-compliant clients.

Recommendation 4: Consider a Stable Subject Line (Low Impact for Outlook, High Impact for Subject-Based Clients)

Consider making the subject line stable across title edits by using the issue/PR number as the primary identifier:

Re: [owner/repo] PR #42: Fix the critical bug

If the title changes, only the descriptive portion after the number changes. The (PR #42) suffix is already present at the end, but subject normalization in Outlook strips from the beginning, so placing the number earlier could help with substring-based matching in some clients.

However, this is lower priority than adding Thread-Index/Thread-Topic, which solves the problem more completely.

Impact Assessment

Recommendation Outlook Impact Other Clients Effort Risk
Add Thread-Index High — enables native conversation grouping None (ignored) Medium Low — additive header only
Add Thread-Topic High — stable grouping key Some clients use it Low Low — additive header only
Fix References chain None (Outlook ignores) Medium — better threading Low Low — additive change
Stable subject Medium Low Medium Medium — user-visible change

Why This Isn't Just "Outlook Being Broken"

While Outlook's decision to deprioritize RFC-standard In-Reply-To/References headers is debatable, the Thread-Index/Thread-Topic mechanism has real advantages:

  1. Deterministic grouping: Thread-Index uses a GUID, making grouping unambiguous even across subject changes.
  2. Hierarchy preservation: The child-block structure encodes the actual tree structure of replies, not just a flat chain.
  3. Resilience to gaps: If a user deletes a message in the middle of a chain, Thread-Index still groups correctly (because it's based on a shared GUID, not a parent chain).

Other major email senders (Jira, ServiceNow, many corporate systems) include Thread-Index in their outgoing email to ensure proper Outlook grouping. GitHub is an outlier in not doing so.


Confidence Assessment

Finding Confidence Basis
GitHub does not emit Thread-Index/Thread-Topic High Code search across org:github returned zero results11
Outlook primarily uses Thread-Index for grouping High Microsoft official documentation124
Outlook largely ignores In-Reply-To/References High Confirmed by PR #13858 headers — messages 2 & 3 have identical In-Reply-To but different threading results
Simultaneous delivery causes race conditions High Confirmed by PR #13858 — messages 1 & 3 arrived at same second, different Outlook servers, only one threaded
Adding Thread-Index would fix the grouping issue High Well-established pattern used by Jira, ServiceNow, etc.
Subject changes break Outlook's fallback grouping High Confirmed by code inspection, user reports, and special-projects#605 staff-ship feedback263228
References header is not a full chain High Direct code inspection — set to same value as In-Reply-To10
Exchange adds Thread-Index for @microsoft.com recipients High Confirmed by headers in github/github#12596930
This problem has never been filed as an issue High Exhaustive search across github/github, notifications, notifyd, special-projects, community
Proposed code changes are safe and low-risk High Additive-only headers; feature-flagged; precedent exists (newsies_add_platform_email_header)
New Outlook may have improved References support Low No documentation found confirming or denying this

Footnotes

Footnotes

  1. Tracking Conversations — Microsoft Learn — Microsoft's official documentation on PidTagConversationIndex and conversation tracking in MAPI. 2 3 4

  2. PidTagConversationIndex Canonical Property — Microsoft Learn — Defines the binary format of the conversation index: 22-byte header (1 reserved + 5 FILETIME + 16 GUID) + 5-byte child blocks. 2 3

  3. Windows FILETIME is a 64-bit value representing 100-nanosecond intervals since January 1, 1601 UTC. Only the most-significant 5 bytes (40 bits) are stored in Thread-Index, giving ~34-year resolution at ~1-minute granularity. See FILETIME structure — Microsoft Learn.

  4. PidTagConversationTopic Canonical Property — Microsoft Learn — Defines Thread-Topic as the normalized subject that remains constant across replies. 2 3 4

  5. View email messages by conversation in Outlook — Microsoft Support — User-facing documentation on Outlook's conversation view feature.

  6. Outlook Conversation view: group emails in threads — Ablebits — Detailed explanation of how Outlook's conversation view works in practice.

  7. What does Outlook need to recognize a thread? — Super User — Community discussion confirming Outlook's preference for Thread-Index over standard headers. 2

  8. Outlook doesn't show the mails as conversation — git-multimail #192 — Real-world report of Outlook failing to thread emails that use only standard headers. 2

  9. RFC 5256 — IMAP SORT and THREAD Extensions — Defines the standard REFERENCES threading algorithm using Message-ID, References, In-Reply-To, and subject fallback.

  10. lib/newsies/emails/message.rb in github/githubheaders method (around line 340) sets Message-Id, In-Reply-To, and References (same value). No Thread-Index or Thread-Topic. 2 3 4 5 6 7 8

  11. GitHub code search "Thread-Index" OR "Thread-Topic" OR "thread_index" OR "thread_topic" org:github language:ruby returns zero results, confirming these headers are not generated anywhere. 2

  12. packages/notifications/app/models/notifyd/email_headers.rb in github/github — The Notifyd email header builder similarly emits Message-ID, In-Reply-To, References with no Microsoft-specific threading headers.

  13. packages/issues/app/models/issue.rb in github/githubmessage_id generates <owner/repo/issues/NUMBER/ID@github.com> (Issue uses its database ID, making each issue's root Message-ID unique).

  14. packages/issues/app/models/issue_comment.rb:417-418 in github/githubmessage_id generates <owner/repo/issues/NUMBER/COMMENT_ID@github.com>.

  15. packages/issues/app/models/issue_event_notification.rb in github/githubmessage_id generates <owner/repo/pull|issue/NUMBER/issue_event/ID@github.com>.

  16. packages/pull_requests/app/models/pull_request.rb:935-937 in github/githubmessage_id generates <owner/repo/pull/NUMBER@github.com>.

  17. lib/newsies/emails/pull_request_comment.rb in github/githubmessage_id format: <owner/repo/pull/NUMBER/cCOMMENT_ID@host>, in_reply_to returns pull_request.message_id. 2

  18. lib/newsies/emails/pull_request_review.rb in github/githubmessage_id format: <owner/repo/pull/NUMBER/review/REVIEW_ID@host>, in_reply_to returns pull_request.message_id. 2

  19. lib/newsies/emails/pull_request_review_comment.rb in github/github — Inherits from PullRequestComment, overrides message_id to delegate to the model, sets subject with Re: prefix. 2

  20. lib/newsies/emails/issue_comment.rb in github/githubin_reply_to returns issue.message_id.

  21. lib/newsies/emails/issue_event_notification.rb in github/githubin_reply_to branches on issue.pull_request? to return either issue.pull_request.message_id or issue.message_id.

  22. lib/newsies/emails/pull_request.rb in github/github — Inherits from Newsies::Emails::Issue, which inherits from Message. The base Message#in_reply_to returns nil.

  23. RFC 2822, Section 3.6.4 — Identification Fields — Defines References as containing the entire chain of parent Message-IDs, not just the immediate parent.

  24. lib/newsies/emails/issue.rb in github/github — Subject format: [owner/repo] Title (Issue #N) or [owner/repo] Title (PR #N).

  25. lib/newsies/emails/pull_request_review.rb in github/github — Subject format: Re: [owner/repo] Title (PR #N).

  26. lib/newsies/emails/message.rb in github/githubissue_subject_suffix method (around line 605) and SWITCH_TO_NEW_SUBJECT_FROM constant showing the Oct 2021 subject format change was made to "avoid broken notification email threads." 2 3

  27. Threaded Email Notifications — GitHub Blog (2011) — Describes changes to show individual users as senders and use topic-focused subjects.

  28. github/special-projects#605, comment #931371144 — Feedback from staff-ship: "the cause of that was that the title changed, and therefore it looks like a different issue" — threading broke when the subject format changed. 2

  29. github/special-projects#605, comment #926034959 — @willsmythe warning: "How confident are we that changing the title won't break threading? From past work in this area, there is a lot of inconsistency between email clients and they don't all handle in-reply-to, etc as one might hope!"

  30. github/github#125969 — DKIM issue report containing headers showing Exchange adding Thread-Topic and Thread-Index to GitHub notification emails delivered to @microsoft.com addresses. 2

  31. github/notifications#6384 — Open issue assessing impact of GitHub's MX migration to Outlook. Focuses on deliverability, not threading.

  32. Conversation broken if subject line is edited — Microsoft Q&A — Confirms that subject line changes break Outlook conversation grouping.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment