Threading
Mailman resolves every inbound and outbound message into a thread using RFC 5322 headers. The algorithm is deterministic and runs in the worker's inbound loop and the API's send endpoint.
Threading Headers
Message-ID
Unique identifier assigned by the originating MTA. Format: <uuid.timestamp@example.com>. Used as the primary key for message lookups. Must be globally unique and present on all messages.
In-Reply-To
Direct parent message being replied to. Points to the immediate parent's Message-ID. May be missing on forwarded messages.
References
Chain of all ancestor Message-IDs, ordered from oldest to newest. More reliable than In-Reply-To for deep threads because it's maintained across reply chains.
Resolution Algorithm
The algorithm tries four strategies in order, stopping at the first match. Between steps 2 and 3, if References point to messages in multiple existing threads, a merge is triggered (see Automatic Thread Merge below).
Source:
crates/core/src/threading.rs—ThreadResolver::resolve_thread
Step 1: In-Reply-To Lookup
Most reliable method. If the parent message exists in the database, use its thread.
Step 2: References Chain Scan
If In-Reply-To fails (message not in our system), iterate the References header from newest to oldest to find the most recent known ancestor.
Step 3: Subject Fallback
When no RFC headers match, fall back to subject-based threading. This catches forwarded messages (no In-Reply-To), messages from external systems that don't preserve headers, and initial messages to existing conversations.
Constraints:
- Time window: 7 days — only matches threads updated within the last 7 days
- Participant overlap — at least one participant in common between the existing thread and the incoming message
Source:
crates/core/src/threading.rs—ThreadResolver::has_overlapping_participants(also checks CC recipients)
Step 4: Create New Thread
If no existing thread matches, create a new one with the message's normalized subject.
Subject Normalization
The normalizer strips common international reply/forward prefixes recursively.
Source:
crates/core/src/threading.rs—normalize_subject
| Input | Normalized |
|---|---|
Re: Hello | hello |
RE: RE: FWD: Hello | hello |
Fwd: Re: Meeting notes | meeting notes |
AW: Antwort | antwort |
Supported prefixes:
| Prefix | Language |
|---|---|
Re: | English (standard) |
RE: | English (Outlook) |
Fwd: | English forward |
FW: | English forward (Outlook) |
Aw: | German |
Sv: | Swedish/Norwegian |
Antw: | Dutch |
Outbound Threading
With reply_to_message_id
When sending a reply via POST /send with reply_to_message_id, Mailman looks up the parent message, sets In-Reply-To, builds the References chain, and inherits the parent's thread.
Source:
crates/core/src/threading.rs—thread_outbound_reply
Without reply_to_message_id
A new outbound message creates a new thread with the normalized subject.
Automatic Thread Merge
When an inbound message's References header points to messages in multiple existing threads, Mailman automatically merges them:
- After resolving the primary thread (via In-Reply-To or References), scan all References entries
- If they resolve to messages in different threads, select the oldest thread (by
created_at) as the target - Reassign all messages from source threads to the target thread
- Recalculate thread statistics
- Mark source threads as merged (soft-delete with pointer to target)
Source:
crates/core/src/threading.rs—ThreadResolver::resolve_thread(step 2.5) andThreadResolver::find_merge_target
Merge triggers:
- Automatic — inbound message's References header spans multiple existing threads (detected during
resolve_thread) - Manual —
POST /threads/:target_id/mergewith{ "source_thread_id": "..." }(planned)
Winner selection: The thread with the earliest created_at absorbs the others. This is stable and deterministic.
NOTE
Thread merge is implemented in the threading engine but does not currently emit a thread.merged webhook event. Only the 4 message lifecycle events are emitted.
Thread Splitting
Not supported automatically. Would require manual intervention to create a new thread, move a subset of messages, and update References headers.
Edge Cases
Missing Message-ID
Reject the message during ingestion. Message-ID is required per RFC 5322.
Circular References
Detected and broken — a message's own ID cannot appear in its References chain.
Extremely Deep Threads
References header is truncated to the last 10 entries when building outbound messages to avoid header size issues.
Source:
crates/core/src/threading.rs—build_references_chain
Cross-Domain Threading
Threading works across domains as long as Message-IDs are preserved. External systems that rewrite Message-IDs will break threading (subject fallback applies).
Performance Considerations
Indexes
Key indexes for thread resolution:
idx_messages_in_reply_to— partial index onmessages(in_reply_to)for In-Reply-To lookupsidx_message_references_referenced_id— index onmessage_references(referenced_id)for References chain scanning
Source:
crates/adapters-postgres/migrations/001_initial_schema.sql
Subject-based fallback relies on application-level normalization rather than a database-level expression index.
Caching
Thread resolution runs on every message. Consider caching recent Message-ID → Thread-ID mappings and normalized subject → Thread-ID with TTL for high-volume deployments.