Skip to content

fix(outlook): read .msg string properties saved in the non-Unicode format - #2295

Merged
afourney merged 3 commits into
microsoft:mainfrom
gdols:fix/outlook-msg-ansi
Sep 2, 2026
Merged

fix(outlook): read .msg string properties saved in the non-Unicode format#2295
afourney merged 3 commits into
microsoft:mainfrom
gdols:fix/outlook-msg-ansi

Conversation

@gdols

Copy link
Copy Markdown
Contributor

A .msg saved in the legacy non-Unicode format converts to nothing but scaffolding — # Email Message followed by ## Content, with From, To, Subject and the body all missing. No exception is raised, so the loss is silent.

Why

Every string property in a .msg is stored under a stream whose name ends in the property's MAPI type:

type meaning encoding
001F PT_UNICODE UTF-16LE
001E PT_STRING8 the message's 8-bit code page

Outlook writes one or the other for a given message, never both. The converter addressed only the 001F names:

"From": self._get_stream_data(msg, "__substg1.0_0C1F001F"),
"To": self._get_stream_data(msg, "__substg1.0_0E04001F"),
"Subject": self._get_stream_data(msg, "__substg1.0_0037001F"),
...
body = self._get_stream_data(msg, "__substg1.0_1000001F")

so for a non-Unicode message every lookup misses, _get_stream_data returns None for each, and the headers are skipped by the if value: guard while the body is skipped by if body:.

Note this is not reachable by fixing the decode: _get_stream_data never sees any bytes, because the streams it names do not exist in the file.

The change

Each property is now looked up by its tag, trying 001F first and falling back to 001E:

"From": self._get_property_data(msg, "0C1F"),  # PR_SENDER_EMAIL_ADDRESS

PT_STRING8 streams record no encoding of their own, so the charset is detected with charset_normalizer, consistent with how other 8-bit sources are handled in the codebase. Reading PR_INTERNET_CPID out of __properties_version1.0 would give the declared code page instead, but that means parsing the fixed-property stream by hand, and detection is what the rest of the converters already rely on. Happy to switch if you would rather have the declared value.

The Unicode path is untouched — it is still tried first, and still decoded exactly as before.

Tests

packages/markitdown/tests/test_outlook_msg_ansi.py. A .msg is an OLE2 compound file and nothing in the dependency set can write that container, so the streams are served through a stand-in for olefile.OleFileIO rather than a binary fixture:

  • a non-Unicode message keeps its headers and body, accents included;
  • the same message is not reduced to empty scaffolding;
  • a Unicode message converts identically, through the unchanged path;
  • the checked-in test_outlook_msg.msg still converts, read through real olefile.

The two non-Unicode tests fail on main and all four pass with this change. The rest of the suite is unaffected and black reports no changes.

Note

This does not overlap #2245, which resolves the Exchange sender DN and the HTML-only body. That PR reads the same 001F stream names, so a non-Unicode message stays empty there.

Guillermo Dols (gdols) and others added 2 commits August 12, 2026 10:10
Every string property in a .msg lives under a stream whose name ends in its
MAPI type: 001F for PT_UNICODE (UTF-16LE) or 001E for PT_STRING8, written in
the message's code page. Outlook writes one or the other for a given message,
never both, so a message saved in the legacy non-Unicode format carries no
001F streams at all.

The converter addressed only the 001F names. Such a message therefore came
out as bare scaffolding -- "# Email Message" followed by "## Content" -- with
From, To, Subject and the body all silently dropped, and no error raised.

Each property is now read from the 001F stream and, failing that, from its
001E counterpart. PT_STRING8 streams record no encoding of their own, so the
charset is detected with charset_normalizer, as is already done for other
8-bit sources in the codebase. The Unicode path is unchanged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

ANSI streams must use the message’s declared code page to avoid corrupting valid non-Western text.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds support for legacy non-Unicode Outlook .msg string properties.

Changes:

  • Falls back from Unicode (001F) to ANSI (001E) property streams.
  • Adds ANSI and Unicode regression tests.
File summaries
File Description
_outlook_msg_converter.py Reads and decodes ANSI MAPI properties.
test_outlook_msg_ansi.py Tests ANSI and Unicode message conversion.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@afourney
afourney requested a balanced review from Copilot September 2, 2026 17:53
@afourney
afourney merged commit 25c1485 into microsoft:main Sep 2, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

ANSI terminators and several code-page paths can currently corrupt converted text.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

packages/markitdown/src/markitdown/converters/_outlook_msg_converter.py:160

  • PR_INTERNET_CPID only declares the encoding of PR_BODY/PR_BODY_HTML, so using it when PR_MESSAGE_CODEPAGE is absent can silently decode ANSI From/To/Subject bytes with the body's unrelated codec (single-byte codecs usually will not raise). Leave header encoding unset in that case so the existing detector handles the undeclared header encoding.
        header_encoding = message_encoding or internet_encoding
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment on lines +65 to +66
50221: "iso2022_jp",
50222: "iso2022_jp",
Comment on lines +280 to +292
if not data:
return None

if encoding is not None:
try:
return data.decode(encoding).strip()
except (UnicodeDecodeError, LookupError):
pass # The declared code page does not fit; fall back to detection

detected = from_bytes(data).best()
if detected is not None:
return str(detected).strip()
return data.decode("utf-8", errors="ignore").strip()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants