Conversation
…sender address Previously _thread_context was keyed by sender_addr, so all emails from the same person shared one thread context regardless of subject. This caused replies to reference the wrong In-Reply-To/References headers when a sender had multiple active threads. Changes: - Add _extract_thread_id() to derive the canonical thread root from the References chain, falling back to In-Reply-To then Message-ID, with a local UUID for malformed emails missing all three headers - Key _thread_context by thread_id (root Message-ID) instead of sender_addr - Accumulate the full References chain in thread context so multi-hop replies carry the complete header - Thread thread_id through send(), send_document(), send_image() via metadata - Simplify get_chat_info() — drop the stale ctx lookup, return subject: ""
There was a problem hiding this comment.
Pull request overview
This PR fixes incorrect email threading by keying the EmailAdapter’s internal thread context by the canonical thread root (root Message-ID) rather than by sender address, preventing unrelated emails from the same sender from clobbering each other’s reply headers.
Changes:
- Added
_extract_thread_id()to compute a stable thread root fromReferences→In-Reply-To→Message-ID→ local UUID fallback. - Updated inbound email parsing/dispatch to compute and propagate
thread_id, store per-thread context, and accumulateReferences. - Updated outbound send paths (
send,send_image,send_document) to acceptmetadata.thread_idand use it for correct subject + threading headers; updated/added tests accordingly.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
gateway/platforms/email.py |
Implements thread_id extraction/propagation and switches reply context to per-thread storage keyed by root Message-ID. |
tests/gateway/test_email.py |
Adds/updates unit tests to validate thread ID derivation, context keying, References accumulation, and metadata-based thread routing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| caption: Optional[str] = None, | ||
| file_name: Optional[str] = None, | ||
| reply_to: Optional[str] = None, | ||
| metadata: Optional[Dict[str, Any]] = None, | ||
| ) -> SendResult: |
There was a problem hiding this comment.
send_document() accepts reply_to but never uses it (it isn't forwarded to _send_email_with_attachment()), so attachment replies can't set In-Reply-To/References based on an explicit reply_to like send() does. Consider threading reply_to through to _send_email_with_attachment (and using it to set original_msg_id), so document sends behave consistently with plain-text sends.
There was a problem hiding this comment.
Pre-existing gap - _send_email_with_attachment never accepted reply_to_msg_id before this PR either. Out of scope here; tracked as a follow-up.
| # Map thread_id (root Message-ID) -> {subject, message_id, references} | ||
| # Each email reply-chain gets its own entry, enabling per-thread sessions. | ||
| self._thread_context: Dict[str, Dict[str, str]] = {} |
There was a problem hiding this comment.
Now that _thread_context is keyed by per-thread Message-ID roots, it can grow unbounded as new threads arrive (previously it effectively bounded to sender addresses). To avoid long-lived adapters accumulating memory, consider adding an eviction policy (LRU/TTL/max-size) similar to other platform caches, or periodically trimming old thread entries.
There was a problem hiding this comment.
Valid. The old sender-keyed dict was implicitly bounded by unique senders; Message-ID keying does grow faster. Other adapters handle this with inline eviction (no shared utility exists). Will address in a follow-up — not blocking this fix.
|
Thanks for isolating a real email-threading problem. Current main still keys Problems
Suggested changes
Automated hermes-sweeper review. |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Three PRs address the same-sender email-thread overwrite: #11185 introduces canonical root Message-ID context and References-chain propagation but targets the retired gateway adapter, #11418 stores multiple contexts without plumbing identity into outbound sends and also targets the retired adapter, while #73189 updates the live plugin and propagates explicit reply targets through text and attachment paths.
Related pull requests
- #11185
related— (+208/-26) — superseded implementation direction: The canonical thread-root key and accumulated References chain directly address context clobbering, but the diff modifies the retired gateway/platforms/email.py surface and omits the live plugin's native multi-image path. Despite the keep_open review on #11185, #73189 is the better consolidation base because its diff changes the currently registered adapter; #11185's root-ID and References-chain design should be carried over before closure. - #11418
related— (+174/-13) — duplicate with an incomplete fix: The tuple-keyed store preserves concurrent contexts, but outbound methods still call lookup without a thread identifier and therefore select the latest message from the sender, reproducing the reported mis-threading. Despite the keep_open review on #11418, the diff neither updates the live plugin nor proves distinct outbound headers, so it should not remain the merge candidate. - #73189
related— (+85/-13) — preferred live-surface base, requiring regression coverage: The diff updates plugins/platforms/email/adapter.py, preserves multiple Message-ID contexts per sender, and forwards reply_to through text, document, and native multi-image attachment sends, directly covering the active clobber paths. It should additionally test two same-sender replies end to end and adopt #11185's stable root-ID/full References-chain handling so deeper replies remain associated with one conversation.
Duplicates
#11185, #11418, and #73189 substantially overlap on replacing the single context per sender; #11418 is the weakest duplicate because its outbound fallback still cannot identify the intended conversation, while #11185 contains useful root-thread semantics for incorporation into #73189.
Suggested consolidation
Merge #73189 after adding a two-thread same-sender outbound regression and incorporating #11185's canonical root Message-ID plus full References-chain propagation. Then close #11185 as superseded by the live-plugin implementation and #11418 as a duplicate whose latest-per-sender outbound lookup does not resolve the root cause.
Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 43 kB of PR diffs, 7 kB of issue/PR text, 4 kB of discussion (4 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
What changed and why
_thread_contextwas keyed bysender_addr, so all emails from the sameperson shared a single thread entry. If Alice sent two unrelated emails,
the second would clobber the first's context — replies would use the wrong
subject,
In-Reply-To, andReferencesheaders, breaking email threadingin most clients.
Introduce
_extract_thread_id()to derive the canonical thread root perRFC 2822:
ReferenceschainIn-Reply-Toif no ReferencesMessage-IDfor new threads_thread_contextis now keyed by this thread root. The fullReferenceschain is accumulated on each inbound message so outbound replies carry the
complete header.
thread_idis propagated throughsend(),send_document(), andsend_image()viametadata.get_chat_info()no longer performs a context lookup — thechat_id → thread_idmapping is one-to-many andget_chat_infohas noexternal callers, so it now returns a static response with
subject: "".How to test
Subject,In-Reply-To,and
Referencesheaders matching its own thread (not the other)Referencesaccumulates allMessage-IDs in order
Unit tests:
pytest tests/gateway/test_email.py -v(79 tests, all passing)Platforms tested
Linux