ThreadIndex (ThreadIndex v0.1.0)

Copy Markdown View Source

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 hold FILETIME >>> 16 of the original message time (the leading byte is 0x01 for any date between 1829 and 2057 and doubles as the documented "reserved byte"). Base64 starts with Ac/Ad/Ae.

  • :modern — Exchange 2013+, Exchange Online, OWA, Graph API. Byte 0 is the reserved 0x01, bytes 1-5 hold FILETIME >>> 24. Base64 starts with AQ (typically AQH).

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

  1. 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).

  2. Deltas chain from the classic read of the header — and wrap. All Microsoft appenders (MAPI cindex.c, Exchange's ConversationIndex) compute the chain anchor by reading the header time bytes the classic way, even for :modern headers, 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 of 2^(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

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

format()

@type format() :: :classic | :modern

t()

@type t() :: %ThreadIndex{
  date: DateTime.t(),
  format: format(),
  guid: <<_::128>>,
  replies: [ThreadIndex.Reply.t()]
}

Functions

decode(base64)

@spec decode(String.t()) :: {:ok, t()} | {:error, :invalid_base64 | :invalid_length}

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.

decode!(base64)

@spec decode!(String.t()) :: t()

Same as decode/1 but raises ArgumentError on invalid input.

decode_binary(raw)

@spec decode_binary(binary()) :: {:ok, t()} | {:error, :invalid_length}

Decodes a raw (not base64-encoded) conversation index binary, as found in the MAPI PidTagConversationIndex property.

encode_reply(parent_base64, opts \\ [])

@spec encode_reply(
  String.t(),
  keyword()
) :: String.t()

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

  • :timeDateTime of the reply, defaults to DateTime.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]

encode_root(opts \\ [])

@spec encode_root(keyword()) :: String.t()

Encodes a 22-byte root conversation index for a new conversation thread.

Options

  • :timeDateTime of the message, defaults to DateTime.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]