Encode and decode the Outlook Thread-Index email header
(MAPI PidTagConversationIndex, Microsoft Graph conversationIndex).
A conversation index is a 22-byte header block followed by one 5-byte child
block per reply, base64-encoded when transported in the Thread-Index MIME
header. This library decodes both header variants found in the wild and
encodes replies byte-compatible with what Outlook/Exchange themselves
produce, so threads keep grouping and ordering correctly in Outlook.
Format summary
Header block (22 bytes): 6 time-derived bytes + 16-byte conversation GUID. Two variants exist (MS-OXOMSG 2.2.1.3 and its Appendix A footnote <2>):
:classic— desktop Outlook 2007-2019, Exchange 2007-2010. Bytes 0-5 holdFILETIME >>> 16of the original message time (the leading byte is0x01for any date between 1829 and 2057 and doubles as the documented "reserved byte"). Base64 starts withAc/Ad/Ae.:modern— Exchange 2013+, Exchange Online, OWA, Graph API. Byte 0 is the reserved0x01, bytes 1-5 holdFILETIME >>> 24. Base64 starts withAQ(typicallyAQH).
Child block (5 bytes): 1 bit delta code (DC), 31 bits time delta, 8 bits
random. DC=0 stores delta >>> 18 (26.2 ms units, max ~1.78 years), DC=1
stores delta >>> 23 (0.84 s units, max ~57 years).
The two undocumented behaviors this library accounts for
Deltas are cumulative. Each child's delta is relative to the accumulated time of the previous child block, not to the header (the MS-OXOMSG prose says "header", Microsoft's own code says otherwise).
Deltas chain from the classic read of the header — and wrap. All Microsoft appenders (MAPI
cindex.c, Exchange'sConversationIndex) compute the chain anchor by reading the header time bytes the classic way, even for:modernheaders, where that read lands around year 1831. The resulting ~195-year virtual delta forces DC=1 and the 31-bit field silently truncates mod 2^31 — i.e. wraps modulo ~57 years (cf. footnote <3>: "Exchange 2013/2016/2019 set the Delta Code field to 1 and do not calculate the Time Delta field based on TimeDiff"). Decoding recovers true reply dates by re-running the same chain arithmetic and adding the minimal number of2^(31+shift)-tick wrap windows needed to land at or after the header date.
Child timestamps record when each reply was composed (reply draft created), not when it was sent or delivered, so expect them to precede the displayed message times by the composition duration.
Examples
iex> {:ok, index} =
...> "01CDE90ABFE0D78F0E4280824120B2F1D0E3C07ED0070000CCBA300000114460"
...> |> Base.decode16!()
...> |> Base.encode64()
...> |> ThreadIndex.decode()
iex> {index.format, index.date}
{:classic, ~U[2013-01-02 17:01:04.168550Z]}
iex> Enum.map(index.replies, & &1.date)
[~U[2013-01-02 17:23:58.065254Z], ~U[2013-01-02 17:25:53.932902Z]]References
- MS-OXOMSG 2.2.1.3 PidTagConversationIndex and its Appendix A footnotes <2> and <3>
- MAPI "Tracking Conversations"
- Microsoft MAPI SDK sample
cindex.c; decompiledMicrosoft.Exchange.Data.Storage.ConversationIndex - Meridian Discovery: E-mail Conversation Index Analysis
- Metaspike community: Thread-Index Header Field
Summary
Functions
Decodes a base64 Thread-Index value into a ThreadIndex.t/0.
Same as decode/1 but raises ArgumentError on invalid input.
Decodes a raw (not base64-encoded) conversation index binary, as found in
the MAPI PidTagConversationIndex property.
Appends a reply child block to an existing base64 conversation index, reproducing Microsoft's own arithmetic (see the moduledoc): the delta is computed against the cumulative chain anchored at the classic read of the header bytes, masked to 31 bits.
Encodes a 22-byte root conversation index for a new conversation thread.
Types
@type format() :: :classic | :modern
@type t() :: %ThreadIndex{ date: DateTime.t(), format: format(), guid: <<_::128>>, replies: [ThreadIndex.Reply.t()] }
Functions
Decodes a base64 Thread-Index value into a ThreadIndex.t/0.
Returns {:error, :invalid_base64} if the input is not base64 and
{:error, :invalid_length} if the decoded binary is not 22 + 5n bytes long.
Same as decode/1 but raises ArgumentError on invalid input.
Decodes a raw (not base64-encoded) conversation index binary, as found in
the MAPI PidTagConversationIndex property.
Appends a reply child block to an existing base64 conversation index, reproducing Microsoft's own arithmetic (see the moduledoc): the delta is computed against the cumulative chain anchored at the classic read of the header bytes, masked to 31 bits.
The output is byte-compatible with what Outlook/Exchange would append for the same timestamps, so mixed threads stay consistent.
Options
:time—DateTimeof the reply, defaults toDateTime.utc_now/0:random— the 8-bit uniqueness byte (0..255), defaults to a random value; pass it explicitly for reproducible output
Examples
iex> guid = Base.decode16!("D78F0E4280824120B2F1D0E3C07ED007")
iex> root = ThreadIndex.encode_root(guid: guid, time: ~U[2025-01-01 10:00:00Z])
iex> reply = ThreadIndex.encode_reply(root, time: ~U[2025-01-01 10:30:00Z], random: 0xAB)
iex> [%ThreadIndex.Reply{delta_code: 0, random: 0xAB} = r] = ThreadIndex.decode!(reply).replies
iex> r.date
~U[2025-01-01 10:29:59.983513Z]
Encodes a 22-byte root conversation index for a new conversation thread.
Options
:time—DateTimeof the message, defaults toDateTime.utc_now/0:guid— 16-byte conversation GUID, defaults to a random one:format—:classic(default, desktop Outlook layout) or:modern(Exchange/OWA layout)
Examples
iex> guid = Base.decode16!("D78F0E4280824120B2F1D0E3C07ED007")
iex> root = ThreadIndex.encode_root(guid: guid, time: ~U[2025-01-01 10:00:00Z])
iex> ThreadIndex.decode!(root).date
~U[2025-01-01 09:59:59.997952Z]