Skip to content

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.rsThreadResolver::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.rsThreadResolver::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.rsnormalize_subject

InputNormalized
Re: Hellohello
RE: RE: FWD: Hellohello
Fwd: Re: Meeting notesmeeting notes
AW: Antwortantwort

Supported prefixes:

PrefixLanguage
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.rsthread_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:

  1. After resolving the primary thread (via In-Reply-To or References), scan all References entries
  2. If they resolve to messages in different threads, select the oldest thread (by created_at) as the target
  3. Reassign all messages from source threads to the target thread
  4. Recalculate thread statistics
  5. Mark source threads as merged (soft-delete with pointer to target)

Source: crates/core/src/threading.rsThreadResolver::resolve_thread (step 2.5) and ThreadResolver::find_merge_target

Merge triggers:

  • Automatic — inbound message's References header spans multiple existing threads (detected during resolve_thread)
  • ManualPOST /threads/:target_id/merge with { "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.rsbuild_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 on messages(in_reply_to) for In-Reply-To lookups
  • idx_message_references_referenced_id — index on message_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.