Skip to content

Commit 25c1485

Browse files
gdolsafourney
andauthored
fix(outlook): read .msg string properties saved in the non-Unicode format (#2295)
* fix: read .msg string properties saved in the non-Unicode format 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. * Read code page info once. --------- Co-authored-by: afourney <adamfo@microsoft.com>
1 parent 83ce26d commit 25c1485

2 files changed

Lines changed: 436 additions & 5 deletions

File tree

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

Lines changed: 172 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import codecs
2+
import struct
13
import sys
2-
from typing import Any, Union, BinaryIO
4+
from typing import Any, Dict, Union, BinaryIO
5+
from charset_normalizer import from_bytes
36
from .._stream_info import StreamInfo
47
from .._base_converter import DocumentConverter, DocumentConverterResult
58
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
@@ -20,6 +23,56 @@
2023

2124
ACCEPTED_FILE_EXTENSIONS = [".msg"]
2225

26+
# Fixed-width MAPI properties (PT_LONG among them) are not stored in a stream of
27+
# their own -- they are packed into the message's property stream, after a header
28+
# that is 32 bytes long for a top-level message, as 16-byte entries of tag, flags
29+
# and value.
30+
PROPERTIES_STREAM = "__properties_version1.0"
31+
PROPERTIES_HEADER_SIZE = 32
32+
PROPERTY_ENTRY_SIZE = 16
33+
PT_LONG = 0x0003
34+
35+
PR_MESSAGE_CODEPAGE = 0x3FFD # PidTagMessageCodepage
36+
PR_INTERNET_CPID = 0x3FDE # PidTagInternetCodepage
37+
38+
# Windows code page identifiers whose Python codec is not simply "cp<CPID>".
39+
# Anything absent from this map is looked up under that name, so the common
40+
# single-byte and East Asian pages (1250-1258, 874, 932, 936, 949, 950, ...)
41+
# need no entry here.
42+
CODEPAGE_CODECS = {
43+
708: "iso8859-6",
44+
20127: "ascii",
45+
20866: "koi8-r",
46+
21866: "koi8-u",
47+
28591: "iso8859-1",
48+
28592: "iso8859-2",
49+
28593: "iso8859-3",
50+
28594: "iso8859-4",
51+
28595: "iso8859-5",
52+
28596: "iso8859-6",
53+
28597: "iso8859-7",
54+
28598: "iso8859-8",
55+
28599: "iso8859-9",
56+
28603: "iso8859-13",
57+
28605: "iso8859-15",
58+
10000: "mac_roman",
59+
10006: "mac_greek",
60+
10007: "mac_cyrillic",
61+
10029: "mac_latin2",
62+
10079: "mac_iceland",
63+
10081: "mac_turkish",
64+
50220: "iso2022_jp",
65+
50221: "iso2022_jp",
66+
50222: "iso2022_jp",
67+
50225: "iso2022_kr",
68+
51932: "euc_jp",
69+
51936: "gb2312",
70+
51949: "euc_kr",
71+
54936: "gb18030",
72+
65000: "utf-7",
73+
65001: "utf-8",
74+
}
75+
2376

2477
class OutlookMsgConverter(DocumentConverter):
2578
"""Converts Outlook .msg files to markdown by extracting email metadata and content.
@@ -95,14 +148,32 @@ def convert(
95148
) # If we made it this far, olefile should be available
96149
msg = olefile.OleFileIO(file_stream)
97150

151+
# The code page that PT_STRING8 properties are written in is declared by
152+
# the message itself, so read it once here rather than guessing at each
153+
# property. PidTagMessageCodepage covers the message's string properties;
154+
# PidTagInternetCodepage, when present, describes the body it arrived in.
155+
long_properties = self._get_long_properties(msg)
156+
message_encoding = self._get_codec_name(
157+
long_properties.get(PR_MESSAGE_CODEPAGE)
158+
)
159+
internet_encoding = self._get_codec_name(long_properties.get(PR_INTERNET_CPID))
160+
header_encoding = message_encoding or internet_encoding
161+
body_encoding = internet_encoding or message_encoding
162+
98163
# Extract email metadata
99164
md_content = "# Email Message\n\n"
100165

101166
# Get headers
102167
headers = {
103-
"From": self._get_stream_data(msg, "__substg1.0_0C1F001F"),
104-
"To": self._get_stream_data(msg, "__substg1.0_0E04001F"),
105-
"Subject": self._get_stream_data(msg, "__substg1.0_0037001F"),
168+
"From": self._get_property_data(
169+
msg, "0C1F", header_encoding
170+
), # PR_SENDER_EMAIL_ADDRESS
171+
"To": self._get_property_data(
172+
msg, "0E04", header_encoding
173+
), # PR_DISPLAY_TO
174+
"Subject": self._get_property_data(
175+
msg, "0037", header_encoding
176+
), # PR_SUBJECT
106177
}
107178

108179
# Add headers to markdown
@@ -113,7 +184,7 @@ def convert(
113184
md_content += "\n## Content\n\n"
114185

115186
# Get email body
116-
body = self._get_stream_data(msg, "__substg1.0_1000001F")
187+
body = self._get_property_data(msg, "1000", body_encoding) # PR_BODY
117188
if body:
118189
md_content += body
119190

@@ -124,6 +195,102 @@ def convert(
124195
title=headers.get("Subject"),
125196
)
126197

198+
def _get_long_properties(self, msg: Any) -> Dict[int, int]:
199+
"""Helper to read the message's PT_LONG properties, keyed by property id.
200+
201+
Fixed-width properties carry no stream of their own: they sit in the
202+
message's property stream, whose entries are 16 bytes of tag, flags and
203+
value, following a 32-byte header for a top-level message.
204+
"""
205+
assert olefile is not None
206+
assert isinstance(msg, olefile.OleFileIO)
207+
208+
properties: Dict[int, int] = {}
209+
try:
210+
if not msg.exists(PROPERTIES_STREAM):
211+
return properties
212+
data = msg.openstream(PROPERTIES_STREAM).read()
213+
except Exception:
214+
return properties
215+
216+
offset = PROPERTIES_HEADER_SIZE
217+
while offset + PROPERTY_ENTRY_SIZE <= len(data):
218+
entry = data[offset : offset + PROPERTY_ENTRY_SIZE]
219+
offset += PROPERTY_ENTRY_SIZE
220+
tag, _flags, value = struct.unpack("<III", entry[:12])
221+
if tag & 0xFFFF == PT_LONG:
222+
properties[tag >> 16] = value
223+
return properties
224+
225+
def _get_codec_name(self, codepage: Union[int, None]) -> Union[str, None]:
226+
"""Helper to map a Windows code page identifier to a Python codec.
227+
228+
Returns None for a code page the message does not declare, or for one
229+
Python cannot decode -- either way the encoding has to be detected.
230+
"""
231+
if not codepage:
232+
return None
233+
234+
name = CODEPAGE_CODECS.get(codepage, "cp%d" % codepage)
235+
try:
236+
return codecs.lookup(name).name
237+
except LookupError:
238+
return None
239+
240+
def _get_property_data(
241+
self, msg: Any, property_tag: str, encoding: Union[str, None] = None
242+
) -> Union[str, None]:
243+
"""Helper to read a MAPI string property, whichever string type is used.
244+
245+
Outlook stores each string property either as PT_UNICODE (stream type
246+
001F, UTF-16LE) or as PT_STRING8 (stream type 001E, the message's 8-bit
247+
code page), depending on whether the message was saved in Unicode or in
248+
the legacy non-Unicode format. A message carries one or the other, so
249+
reading only 001F returns nothing at all for a non-Unicode .msg.
250+
"""
251+
value = self._get_stream_data(msg, "__substg1.0_%s001F" % property_tag)
252+
if value:
253+
return value
254+
return self._get_ansi_stream_data(
255+
msg, "__substg1.0_%s001E" % property_tag, encoding
256+
)
257+
258+
def _get_ansi_stream_data(
259+
self, msg: Any, stream_path: str, encoding: Union[str, None] = None
260+
) -> Union[str, None]:
261+
"""Helper to extract and decode a PT_STRING8 stream from the MSG file.
262+
263+
These streams record no encoding of their own -- they are written in the
264+
code page the message declares, which is what ``encoding`` carries. Only
265+
when that declaration is missing, unsupported, or contradicted by the
266+
bytes is the charset detected instead: a header is a short and highly
267+
ambiguous sample, and detection routinely misreads one (a CP1252
268+
"Résumé" reads as UTF-16BE, a CP1251 "Привет мир" as CP1125).
269+
"""
270+
assert olefile is not None
271+
assert isinstance(msg, olefile.OleFileIO)
272+
273+
try:
274+
if not msg.exists(stream_path):
275+
return None
276+
data = msg.openstream(stream_path).read()
277+
except Exception:
278+
return None
279+
280+
if not data:
281+
return None
282+
283+
if encoding is not None:
284+
try:
285+
return data.decode(encoding).strip()
286+
except (UnicodeDecodeError, LookupError):
287+
pass # The declared code page does not fit; fall back to detection
288+
289+
detected = from_bytes(data).best()
290+
if detected is not None:
291+
return str(detected).strip()
292+
return data.decode("utf-8", errors="ignore").strip()
293+
127294
def _get_stream_data(self, msg: Any, stream_path: str) -> Union[str, None]:
128295
"""Helper to safely extract and decode stream data from the MSG file."""
129296
assert olefile is not None

0 commit comments

Comments
 (0)