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:
- GitHub does not emit
Thread-IndexorThread-Topicheaders — the primary mechanism Outlook uses for conversation grouping. GitHub relies exclusively on RFC-standardMessage-ID,In-Reply-To, andReferencesheaders, which Outlook largely ignores for non-Exchange mail. - 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.
- Subject lines change when issue/PR titles are edited — Outlook's fallback grouping mechanism (normalized subject matching) breaks when the subject changes mid-thread.
- Different notification types for the same PR/Issue use different
Message-IDschemes — makingIn-Reply-To/Referenceschains 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.
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.
- Tracking Conversations — Microsoft's canonical developer guide explaining how Outlook groups messages by sorting on
PR_CONVERSATION_TOPICand thenPR_CONVERSATION_INDEX. - PidTagConversationIndex Canonical Property — Defines the binary structure of the conversation index header and child blocks.
- PidTagConversationTopic Canonical Property — Defines
Thread-Topicas the normalized subject used for conversation grouping. - MS-OXOMSG §2.2.1.3: PidTagConversationIndex — The Exchange Server protocol specification for this property.
- ScCreateConversationIndex — MAPI function reference for creating and extending conversation index values.
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. |
Per Microsoft's Tracking Conversations guide:
- 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.
- 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.
- Root detection: A message with exactly 22 bytes of Thread-Index data is the conversation root. Messages with 27+ bytes are replies.
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
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
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.
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.
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 │
└─────────────────────────────────────────────────────────────┘
| 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 |
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/).
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
endKey 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
endDifferent 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 |
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 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.
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 │
└──────────────────────────────────────────────────────────┘
-
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.
-
Outlook ignores
In-Reply-To/References: Even when these headers correctly chain back to the rootMessage-ID, Outlook doesn't use them for conversation grouping in non-Exchange contexts78. -
Referencesheader is not a true chain: GitHub setsReferencesto the same single value asIn-Reply-Torather than accumulating the full ancestor chain10. This limits threading even in clients that do respectReferences. -
Multiple
Fromaddresses 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.
Analysis of actual email headers from blackbird PR #13858 confirms the research and reveals a previously undocumented failure mode.
| # | 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 |
-
Outlook ignores
In-Reply-To/References: Messages 2 and 3 have identicalIn-Reply-ToandReferencesheaders pointing to the same root. Message 2 was threaded; message 3 was not. This proves Outlook does not reliably use these RFC-standard headers. -
No
Thread-IndexorThread-Topicpresent: None of the three messages contain Microsoft-specific threading headers. Outlook is forced to use subject-matching as its only grouping mechanism. -
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:
CY4PEPF0000EE3C→SA1PR21MB4327→SA1PR21MB6368 - Message 3:
SJ1PEPF00002318→CH8PR21MB4740→SA1PR21MB6368
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.
- Message 1:
-
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.
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.
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.
-
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 wasSWITCH_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 (missingThread-Index) was never identified. -
github/github#125969 (closed, 2019) — A DKIM issue, but its attached headers reveal that when GitHub notifications are delivered directly to
@microsoft.comaddresses, Exchange addsThread-TopicandThread-Indexon 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. -
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.
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
Referencesheaders - Microsoft users with
@microsoft.comaddresses never experienced the problem because Exchange addsThread-Indexon 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
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 |
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
endAdd 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,
)
endThe 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
endThe 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.
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.
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.
| 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 | 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 |
- Additive only: No existing headers are modified.
Message-Id,In-Reply-To,References,Subject— all remain identical. - Non-Outlook clients ignore it:
Thread-IndexandThread-Topicare silently discarded by Gmail, Thunderbird, Apple Mail, etc. - Precedent exists: The
newsies_add_platform_email_headerfeature flag (addingX-GitHub-Notify-Platform) followed exactly this pattern — adding a new header behind a flag10. - No database changes: The implementation derives Thread-Index from existing data (
issue.created_at,repository.name_with_display_owner,issue.number). - 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
Subjectheader — this change adds entirely new headers that existing email clients don't process.
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)
endKey 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).
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.
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.
| 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 |
While Outlook's decision to deprioritize RFC-standard In-Reply-To/References headers is debatable, the Thread-Index/Thread-Topic mechanism has real advantages:
- Deterministic grouping: Thread-Index uses a GUID, making grouping unambiguous even across subject changes.
- Hierarchy preservation: The child-block structure encodes the actual tree structure of replies, not just a flat chain.
- Resilience to gaps: If a user deletes a message in the middle of a chain,
Thread-Indexstill 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.
| 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
-
Tracking Conversations — Microsoft Learn — Microsoft's official documentation on
PidTagConversationIndexand conversation tracking in MAPI. ↩ ↩2 ↩3 ↩4 -
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
-
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. ↩
-
PidTagConversationTopic Canonical Property — Microsoft Learn — Defines Thread-Topic as the normalized subject that remains constant across replies. ↩ ↩2 ↩3 ↩4
-
View email messages by conversation in Outlook — Microsoft Support — User-facing documentation on Outlook's conversation view feature. ↩
-
Outlook Conversation view: group emails in threads — Ablebits — Detailed explanation of how Outlook's conversation view works in practice. ↩
-
What does Outlook need to recognize a thread? — Super User — Community discussion confirming Outlook's preference for Thread-Index over standard headers. ↩ ↩2
-
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
-
RFC 5256 — IMAP SORT and THREAD Extensions — Defines the standard REFERENCES threading algorithm using Message-ID, References, In-Reply-To, and subject fallback. ↩
-
lib/newsies/emails/message.rbin github/github —headersmethod (around line 340) setsMessage-Id,In-Reply-To, andReferences(same value). NoThread-IndexorThread-Topic. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
GitHub code search
"Thread-Index" OR "Thread-Topic" OR "thread_index" OR "thread_topic" org:github language:rubyreturns zero results, confirming these headers are not generated anywhere. ↩ ↩2 -
packages/notifications/app/models/notifyd/email_headers.rbin github/github — The Notifyd email header builder similarly emitsMessage-ID,In-Reply-To,Referenceswith no Microsoft-specific threading headers. ↩ -
packages/issues/app/models/issue.rbin github/github —message_idgenerates<owner/repo/issues/NUMBER/ID@github.com>(Issue uses its database ID, making each issue's root Message-ID unique). ↩ -
packages/issues/app/models/issue_comment.rb:417-418in github/github —message_idgenerates<owner/repo/issues/NUMBER/COMMENT_ID@github.com>. ↩ -
packages/issues/app/models/issue_event_notification.rbin github/github —message_idgenerates<owner/repo/pull|issue/NUMBER/issue_event/ID@github.com>. ↩ -
packages/pull_requests/app/models/pull_request.rb:935-937in github/github —message_idgenerates<owner/repo/pull/NUMBER@github.com>. ↩ -
lib/newsies/emails/pull_request_comment.rbin github/github —message_idformat:<owner/repo/pull/NUMBER/cCOMMENT_ID@host>,in_reply_toreturnspull_request.message_id. ↩ ↩2 -
lib/newsies/emails/pull_request_review.rbin github/github —message_idformat:<owner/repo/pull/NUMBER/review/REVIEW_ID@host>,in_reply_toreturnspull_request.message_id. ↩ ↩2 -
lib/newsies/emails/pull_request_review_comment.rbin github/github — Inherits fromPullRequestComment, overridesmessage_idto delegate to the model, sets subject withRe:prefix. ↩ ↩2 -
lib/newsies/emails/issue_comment.rbin github/github —in_reply_toreturnsissue.message_id. ↩ -
lib/newsies/emails/issue_event_notification.rbin github/github —in_reply_tobranches onissue.pull_request?to return eitherissue.pull_request.message_idorissue.message_id. ↩ -
lib/newsies/emails/pull_request.rbin github/github — Inherits fromNewsies::Emails::Issue, which inherits fromMessage. The baseMessage#in_reply_toreturnsnil. ↩ -
RFC 2822, Section 3.6.4 — Identification Fields — Defines
Referencesas containing the entire chain of parent Message-IDs, not just the immediate parent. ↩ -
lib/newsies/emails/issue.rbin github/github — Subject format:[owner/repo] Title (Issue #N)or[owner/repo] Title (PR #N). ↩ -
lib/newsies/emails/pull_request_review.rbin github/github — Subject format:Re: [owner/repo] Title (PR #N). ↩ -
lib/newsies/emails/message.rbin github/github —issue_subject_suffixmethod (around line 605) andSWITCH_TO_NEW_SUBJECT_FROMconstant showing the Oct 2021 subject format change was made to "avoid broken notification email threads." ↩ ↩2 ↩3 -
Threaded Email Notifications — GitHub Blog (2011) — Describes changes to show individual users as senders and use topic-focused subjects. ↩
-
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
-
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!" ↩
-
github/github#125969 — DKIM issue report containing headers showing Exchange adding
Thread-TopicandThread-Indexto GitHub notification emails delivered to@microsoft.comaddresses. ↩ ↩2 -
github/notifications#6384 — Open issue assessing impact of GitHub's MX migration to Outlook. Focuses on deliverability, not threading. ↩
-
Conversation broken if subject line is edited — Microsoft Q&A — Confirms that subject line changes break Outlook conversation grouping. ↩