1+ import codecs
2+ import struct
13import sys
2- from typing import Any , Union , BinaryIO
4+ from typing import Any , Dict , Union , BinaryIO
5+ from charset_normalizer import from_bytes
36from .._stream_info import StreamInfo
47from .._base_converter import DocumentConverter , DocumentConverterResult
58from .._exceptions import MissingDependencyException , MISSING_DEPENDENCY_MESSAGE
2023
2124ACCEPTED_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
2477class 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