extract-msg 0.48.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. extract_msg/__init__.py +71 -0
  2. extract_msg/__main__.py +115 -0
  3. extract_msg/_rtf/__init__.py +21 -0
  4. extract_msg/_rtf/create_doc.py +29 -0
  5. extract_msg/_rtf/inject_rtf.py +174 -0
  6. extract_msg/_rtf/token.py +35 -0
  7. extract_msg/_rtf/tokenize_rtf.py +253 -0
  8. extract_msg/attachments/__init__.py +144 -0
  9. extract_msg/attachments/attachment.py +188 -0
  10. extract_msg/attachments/attachment_base.py +730 -0
  11. extract_msg/attachments/broken_att.py +42 -0
  12. extract_msg/attachments/custom_att.py +173 -0
  13. extract_msg/attachments/custom_att_handler/__init__.py +80 -0
  14. extract_msg/attachments/custom_att_handler/custom_handler.py +134 -0
  15. extract_msg/attachments/custom_att_handler/lnk_obj_att.py +109 -0
  16. extract_msg/attachments/custom_att_handler/outlook_image_dib.py +141 -0
  17. extract_msg/attachments/emb_msg_att.py +134 -0
  18. extract_msg/attachments/signed_att.py +234 -0
  19. extract_msg/attachments/unsupported_att.py +40 -0
  20. extract_msg/attachments/web_att.py +74 -0
  21. extract_msg/constants/__init__.py +294 -0
  22. extract_msg/constants/ps.py +46 -0
  23. extract_msg/constants/re.py +42 -0
  24. extract_msg/constants/st.py +110 -0
  25. extract_msg/data/logging-config/logging-nt.json +64 -0
  26. extract_msg/data/logging-config/logging-posix.json +64 -0
  27. extract_msg/encoding/__init__.py +277 -0
  28. extract_msg/encoding/_dt/__init__.py +4 -0
  29. extract_msg/encoding/_dt/_mac_ce.py +8 -0
  30. extract_msg/encoding/_dt/_mac_cyrillic.py +8 -0
  31. extract_msg/encoding/_dt/_mac_greek.py +8 -0
  32. extract_msg/encoding/_dt/_mac_iceland.py +8 -0
  33. extract_msg/encoding/_dt/_mac_turkish.py +8 -0
  34. extract_msg/encoding/_dt/_win874_dec.py +8 -0
  35. extract_msg/encoding/_dt/_win950_dec.py +6 -0
  36. extract_msg/encoding/utils.py +329 -0
  37. extract_msg/enums.py +2067 -0
  38. extract_msg/exceptions.py +170 -0
  39. extract_msg/msg_classes/__init__.py +49 -0
  40. extract_msg/msg_classes/appointment.py +195 -0
  41. extract_msg/msg_classes/calendar.py +92 -0
  42. extract_msg/msg_classes/calendar_base.py +496 -0
  43. extract_msg/msg_classes/contact.py +1335 -0
  44. extract_msg/msg_classes/journal.py +131 -0
  45. extract_msg/msg_classes/meeting_cancellation.py +108 -0
  46. extract_msg/msg_classes/meeting_exception.py +55 -0
  47. extract_msg/msg_classes/meeting_forward.py +115 -0
  48. extract_msg/msg_classes/meeting_related.py +65 -0
  49. extract_msg/msg_classes/meeting_request.py +189 -0
  50. extract_msg/msg_classes/meeting_response.py +74 -0
  51. extract_msg/msg_classes/message.py +12 -0
  52. extract_msg/msg_classes/message_base.py +1382 -0
  53. extract_msg/msg_classes/message_signed.py +17 -0
  54. extract_msg/msg_classes/message_signed_base.py +168 -0
  55. extract_msg/msg_classes/msg.py +1172 -0
  56. extract_msg/msg_classes/post.py +53 -0
  57. extract_msg/msg_classes/sticky_note.py +67 -0
  58. extract_msg/msg_classes/task.py +363 -0
  59. extract_msg/msg_classes/task_request.py +86 -0
  60. extract_msg/null_date.py +34 -0
  61. extract_msg/ole_writer.py +1086 -0
  62. extract_msg/open_msg.py +204 -0
  63. extract_msg/properties/__init__.py +23 -0
  64. extract_msg/properties/named.py +481 -0
  65. extract_msg/properties/prop.py +506 -0
  66. extract_msg/properties/properties_store.py +476 -0
  67. extract_msg/py.typed +0 -0
  68. extract_msg/recipient.py +362 -0
  69. extract_msg/structures/__init__.py +34 -0
  70. extract_msg/structures/_helpers.py +345 -0
  71. extract_msg/structures/business_card.py +422 -0
  72. extract_msg/structures/cfoas.py +108 -0
  73. extract_msg/structures/contact_link_entry.py +51 -0
  74. extract_msg/structures/dev_mode_a.py +621 -0
  75. extract_msg/structures/dv_target_device.py +168 -0
  76. extract_msg/structures/entry_id.py +697 -0
  77. extract_msg/structures/misc_id.py +222 -0
  78. extract_msg/structures/mon_stream.py +55 -0
  79. extract_msg/structures/odt.py +74 -0
  80. extract_msg/structures/ole_pres.py +302 -0
  81. extract_msg/structures/ole_stream_struct.py +117 -0
  82. extract_msg/structures/recurrence_pattern.py +199 -0
  83. extract_msg/structures/report_tag.py +127 -0
  84. extract_msg/structures/system_time.py +158 -0
  85. extract_msg/structures/time_zone_definition.py +111 -0
  86. extract_msg/structures/time_zone_struct.py +102 -0
  87. extract_msg/structures/toc_entry.py +137 -0
  88. extract_msg/structures/tz_rule.py +148 -0
  89. extract_msg/utils.py +1270 -0
  90. extract_msg-0.48.2.dist-info/LICENSE.txt +674 -0
  91. extract_msg-0.48.2.dist-info/METADATA +321 -0
  92. extract_msg-0.48.2.dist-info/RECORD +95 -0
  93. extract_msg-0.48.2.dist-info/WHEEL +5 -0
  94. extract_msg-0.48.2.dist-info/entry_points.txt +2 -0
  95. extract_msg-0.48.2.dist-info/top_level.txt +1 -0
extract_msg/utils.py ADDED
@@ -0,0 +1,1270 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ """
5
+ Utility functions of extract_msg.
6
+ """
7
+
8
+
9
+ __all__ = [
10
+ 'addNumToDir',
11
+ 'addNumToZipDir',
12
+ 'bitwiseAdjust',
13
+ 'bitwiseAdjustedAnd',
14
+ 'bytesToGuid',
15
+ 'ceilDiv',
16
+ 'cloneOleFile',
17
+ 'createZipOpen',
18
+ 'decodeRfc2047',
19
+ 'dictGetCasedKey',
20
+ 'divide',
21
+ 'filetimeToDatetime',
22
+ 'filetimeToUtc',
23
+ 'findWk',
24
+ 'fromTimeStamp',
25
+ 'getCommandArgs',
26
+ 'guessEncoding',
27
+ 'htmlSanitize',
28
+ 'inputToBytes',
29
+ 'inputToMsgPath',
30
+ 'inputToString',
31
+ 'isEncapsulatedRtf',
32
+ 'makeWeakRef',
33
+ 'msgPathToString',
34
+ 'parseType',
35
+ 'prepareFilename',
36
+ 'roundUp',
37
+ 'rtfSanitizeHtml',
38
+ 'rtfSanitizePlain',
39
+ 'setupLogging',
40
+ 'tryGetMimetype',
41
+ 'unsignedToSignedInt',
42
+ 'unwrapMsg',
43
+ 'unwrapMultipart',
44
+ 'validateHtml',
45
+ 'verifyPropertyId',
46
+ 'verifyType',
47
+ ]
48
+
49
+
50
+ import argparse
51
+ import collections
52
+ import copy
53
+ import datetime
54
+ import decimal
55
+ import email.header
56
+ import email.message
57
+ import email.policy
58
+ import glob
59
+ import json
60
+ import logging
61
+ import logging.config
62
+ import os
63
+ import pathlib
64
+ import shutil
65
+ import struct
66
+ import sys
67
+ import weakref
68
+ import zipfile
69
+
70
+ import bs4
71
+ import olefile
72
+ import tzlocal
73
+
74
+ from html import escape as htmlEscape
75
+ from typing import (
76
+ Any, AnyStr, Callable, Dict, Iterable, List, Optional, Sequence,
77
+ SupportsBytes, TypeVar, TYPE_CHECKING, Union
78
+ )
79
+
80
+ from . import constants
81
+ from .enums import AttachmentType
82
+ from .exceptions import (
83
+ ConversionError, DependencyError, ExecutableNotFound,
84
+ IncompatibleOptionsError, InvalidPropertyIdError, TZError,
85
+ UnknownTypeError
86
+ )
87
+
88
+
89
+ # Allow for nice type checking.
90
+ if TYPE_CHECKING:
91
+ from .msg_classes.msg import MSGFile
92
+ from .attachments import AttachmentBase
93
+
94
+ logger = logging.getLogger(__name__)
95
+ logger.addHandler(logging.NullHandler())
96
+ logging.addLevelName(5, 'DEVELOPER')
97
+
98
+ _T = TypeVar('_T')
99
+
100
+
101
+ def addNumToDir(dirName: pathlib.Path) -> Optional[pathlib.Path]:
102
+ """
103
+ Attempt to create the directory with a '(n)' appended.
104
+ """
105
+ for i in range(2, 100):
106
+ try:
107
+ newDirName = dirName.with_name(dirName.name + f' ({i})')
108
+ os.makedirs(newDirName)
109
+ return newDirName
110
+ except Exception as e:
111
+ pass
112
+ return None
113
+
114
+
115
+ def addNumToZipDir(dirName: pathlib.Path, _zip) -> Optional[pathlib.Path]:
116
+ """
117
+ Attempt to create the directory with a '(n)' appended.
118
+ """
119
+ for i in range(2, 100):
120
+ newDirName = dirName.with_name(dirName.name + f' ({i})')
121
+ pathCompare = str(newDirName).rstrip('/') + '/'
122
+ if not any(x.startswith(pathCompare) for x in _zip.namelist()):
123
+ return newDirName
124
+ return None
125
+
126
+
127
+ def bitwiseAdjust(inp: int, mask: int) -> int:
128
+ """
129
+ Uses a given mask to adjust the location of bits after an operation like
130
+ bitwise AND.
131
+
132
+ This is useful for things like flags where you are trying to get a small
133
+ portion of a larger number. Say for example, you had the number ``0xED``
134
+ (``0b11101101``) and you needed the adjusted result of the AND operation
135
+ with ``0x70`` (``0b01110000``). The result of the AND operation
136
+ (``0b01100000``) and the mask used to get it (``0x70``) are given to this
137
+ function and the adjustment will be done automatically.
138
+
139
+ :param mask: MUST be greater than 0.
140
+
141
+ :raises ValueError: The mask is not greater than 0.
142
+ """
143
+ if mask < 1:
144
+ raise ValueError('Mask MUST be greater than 0')
145
+ return inp >> bin(mask)[::-1].index('1')
146
+
147
+
148
+ def bitwiseAdjustedAnd(inp: int, mask: int) -> int:
149
+ """
150
+ Preforms the bitwise AND operation between :param inp: and :param mask: and
151
+ adjusts the results based on the rules of :func:`bitwiseAdjust`.
152
+
153
+ :raises ValueError: The mask is not greater than 0.
154
+ """
155
+ if mask < 1:
156
+ raise ValueError('Mask MUST be greater than 0')
157
+ return (inp & mask) >> bin(mask)[::-1].index('1')
158
+
159
+
160
+ def bytesToGuid(bytesInput: bytes) -> str:
161
+ """
162
+ Converts a bytes instance to a GUID.
163
+ """
164
+ guidVals = constants.st.ST_GUID.unpack(bytesInput)
165
+ return f'{{{guidVals[0]:08X}-{guidVals[1]:04X}-{guidVals[2]:04X}-{guidVals[3][:2].hex().upper()}-{guidVals[3][2:].hex().upper()}}}'
166
+
167
+
168
+ def ceilDiv(n: int, d: int) -> int:
169
+ """
170
+ Returns the ``int`` from the ceiling division of n / d.
171
+
172
+ ONLY use ``int``\\s as inputs to this function.
173
+
174
+ For ``int``\\s, this is faster and more accurate for numbers outside the
175
+ precision range of ``float``.
176
+ """
177
+ return -(n // -d)
178
+
179
+
180
+ def cloneOleFile(sourcePath, outputPath) -> None:
181
+ """
182
+ Uses the ``OleWriter`` class to clone the specified OLE file into a new
183
+ location.
184
+
185
+ Mainly designed for testing.
186
+ """
187
+ from .ole_writer import OleWriter
188
+
189
+ with olefile.OleFileIO(sourcePath) as f:
190
+ writer = OleWriter()
191
+ writer.fromOleFile(f)
192
+
193
+ writer.write(outputPath)
194
+
195
+
196
+ def createZipOpen(func) -> Callable:
197
+ """
198
+ Creates a wrapper for the open function of a ZipFile that will automatically
199
+ set the current date as the modified time to the current time.
200
+ """
201
+ def _open(name, mode = 'r', *args, **kwargs):
202
+ if mode == 'w':
203
+ name = zipfile.ZipInfo(name, datetime.datetime.now().timetuple()[:6])
204
+
205
+ return func(name, mode, *args, **kwargs)
206
+
207
+ return _open
208
+
209
+
210
+ def decodeRfc2047(encoded: str) -> str:
211
+ """
212
+ Decodes text encoded using the method specified in RFC 2047.
213
+ """
214
+ # Fix an issue with folded header fields.
215
+ encoded = encoded.replace('\r\n', '')
216
+
217
+ # This returns a list of tuples containing the bytes and the encoding they
218
+ # are using, so we decode each one and join them together.
219
+ #
220
+ # decode_header header will return a string instead of bytes for the first
221
+ # object if the input is not encoded, something that is frustrating.
222
+ return ''.join(
223
+ x[0].decode(x[1] or 'raw-unicode-escape') if isinstance(x[0], bytes) else x[0]
224
+ for x in email.header.decode_header(encoded)
225
+ )
226
+
227
+
228
+ def dictGetCasedKey(_dict: Dict[str, Any], key: str) -> str:
229
+ """
230
+ Retrieves the key from the dictionary with the proper casing using a
231
+ caseless key.
232
+ """
233
+ try:
234
+ return next((x for x in _dict.keys() if x.lower() == key.lower()))
235
+ except StopIteration:
236
+ # If we couldn't find the key, raise a KeyError.
237
+ raise KeyError(key)
238
+
239
+
240
+ def divide(string: AnyStr, length: int) -> List[AnyStr]:
241
+ """
242
+ Divides a string into multiple substrings of equal length.
243
+
244
+ If there is not enough for the last substring to be equal, it will simply
245
+ use the rest of the string. Can also be used for things like lists and
246
+ tuples.
247
+
248
+ :param string: The string to be divided.
249
+ :param length: The length of each division.
250
+ :returns: list containing the divided strings.
251
+
252
+ Example:
253
+
254
+ .. code-block:: python
255
+
256
+ >>> a = divide('Hello World!', 2)
257
+ >>> print(a)
258
+ ['He', 'll', 'o ', 'Wo', 'rl', 'd!']
259
+ >>> a = divide('Hello World!', 5)
260
+ >>> print(a)
261
+ ['Hello', ' Worl', 'd!']
262
+ """
263
+ return [string[length * x:length * (x + 1)] for x in range(ceilDiv(len(string), length))]
264
+
265
+
266
+ def filetimeToDatetime(rawTime: int) -> datetime.datetime:
267
+ """
268
+ Converts a filetime into a ``datetime``.
269
+
270
+ Some values have specialized meanings, listed below:
271
+
272
+ * ``915151392000000000``: December 31, 4500, representing a null time.
273
+ Returns an instance of extract_msg.null_date.NullDate.
274
+ * ``915046235400000000``: 23:59 on August 31, 4500, representing a null
275
+ time. Returns extract_msg.constants.NULL_DATE.
276
+ """
277
+ try:
278
+ if rawTime < 116444736000000000:
279
+ # We can't properly parse this with our current setup, so
280
+ # we will rely on olefile to handle this one.
281
+ return olefile.olefile.filetime2datetime(rawTime)
282
+ elif rawTime == 915151392000000000:
283
+ # So this is actually a different null date, specifically
284
+ # supposed to be December 31, 4500, but it's weird that the same
285
+ # spec has 2 different ones. It's "the last valid date." Checking
286
+ # the value of this though, it looks like it's actually one minute
287
+ # further in the future, according to the datetime module.
288
+ from .null_date import NullDate
289
+ date = NullDate(4500, 12, 31, 23, 59)
290
+ date.filetime = rawTime
291
+ return date
292
+ elif rawTime == 915046235400000000:
293
+ return constants.NULL_DATE
294
+ else:
295
+ return fromTimeStamp(filetimeToUtc(rawTime))
296
+ except TZError:
297
+ # For TZError we just raise it again. It is a fatal error.
298
+ raise
299
+ except Exception:
300
+ raise ValueError(f'Timestamp value of {filetimeToUtc(rawTime)} caused an exception. This was probably caused by the time stamp being too far in the future.')
301
+
302
+
303
+ def filetimeToUtc(inp: int) -> float:
304
+ """
305
+ Converts a FILETIME into a unix timestamp.
306
+ """
307
+ return (inp - 116444736000000000) / 10000000.0
308
+
309
+
310
+ def findWk(path = None):
311
+ """
312
+ Attempt to find the path of the wkhtmltopdf executable.
313
+
314
+ :param path: If provided, the function will verify that it is executable
315
+ and returns the path if it is.
316
+
317
+ :raises ExecutableNotFound: A valid executable could not be found.
318
+ """
319
+ if path:
320
+ if os.path.isfile(path):
321
+ # Check if executable.
322
+ if os.access(path, os.X_OK):
323
+ return path
324
+ else:
325
+ raise ExecutableNotFound('Path provided was not a valid executable (execution bit not set).')
326
+ else:
327
+ raise ExecutableNotFound('Path provided was not a valid executable (not a file).')
328
+
329
+ candidate = shutil.which('wkhtmltopdf')
330
+ if candidate:
331
+ return candidate
332
+
333
+ raise ExecutableNotFound('Could not find wkhtmltopdf.')
334
+
335
+
336
+ def fromTimeStamp(stamp: float) -> datetime.datetime:
337
+ """
338
+ Returns a ``datetime`` from the UTC timestamp given the current timezone.
339
+ """
340
+ try:
341
+ tz = tzlocal.get_localzone()
342
+ except Exception:
343
+ # I know "generalized exception catching is bad" but if *any* exception
344
+ # happens here that is a subclass of Exception then something has gone
345
+ # wrong with tzlocal.
346
+ raise TZError(f'Error occured using tzlocal. If you are seeing this, this is likely a problem with your installation ot tzlocal or tzdata.')
347
+ return datetime.datetime.fromtimestamp(stamp, tz)
348
+
349
+
350
+ def getCommandArgs(args: Sequence[str]) -> argparse.Namespace:
351
+ """
352
+ Parse command-line arguments.
353
+
354
+ :raises IncompatibleOptionsError: Some options were provided that are
355
+ incompatible.
356
+ :raises ValueError: Something about the options was invalid. This could mean
357
+ an option was specified that requires another option or it could mean
358
+ that an option was looking for data that was not found.
359
+ """
360
+ parser = argparse.ArgumentParser(description = constants.MAINDOC, prog = 'extract_msg')
361
+ outFormat = parser.add_mutually_exclusive_group()
362
+ inputFormat = parser.add_mutually_exclusive_group()
363
+ inputType = parser.add_mutually_exclusive_group(required = True)
364
+ # --use-content-id, --cid
365
+ parser.add_argument('--use-content-id', '--cid', dest='cid', action='store_true',
366
+ help='Save attachments by their Content ID, if they have one. Useful when working with the HTML body.')
367
+ # --json
368
+ outFormat.add_argument('--json', dest='json', action='store_true',
369
+ help='Changes to write output files as json.')
370
+ # --file-logging
371
+ parser.add_argument('--file-logging', dest='fileLogging', action='store_true',
372
+ help='Enables file logging. Implies --verbose level 1.')
373
+ # -v, --verbose
374
+ parser.add_argument('-v', '--verbose', dest='verbose', action='count', default=0,
375
+ help='Turns on console logging. Specify more than once for higher verbosity.')
376
+ # --log PATH
377
+ parser.add_argument('--log', dest='log',
378
+ help='Set the path to write the file log to.')
379
+ # --config PATH
380
+ parser.add_argument('--config', dest='configPath',
381
+ help='Set the path to load the logging config from.')
382
+ # --out PATH
383
+ parser.add_argument('--out', dest='outPath',
384
+ help='Set the folder to use for the program output. (Default: Current directory)')
385
+ # --use-filename
386
+ parser.add_argument('--use-filename', dest='useFilename', action='store_true',
387
+ help='Sets whether the name of each output is based on the MSG filename.')
388
+ # --dump-stdout
389
+ parser.add_argument('--dump-stdout', dest='dumpStdout', action='store_true',
390
+ help='Tells the program to dump the message body (plain text) to stdout. Overrides saving arguments.')
391
+ # --html
392
+ outFormat.add_argument('--html', dest='html', action='store_true',
393
+ help='Sets whether the output should be HTML. If this is not possible, will error.')
394
+ # --pdf
395
+ outFormat.add_argument('--pdf', dest='pdf', action='store_true',
396
+ help='Saves the body as a PDF. If this is not possible, will error.')
397
+ # --wk-path PATH
398
+ parser.add_argument('--wk-path', dest='wkPath',
399
+ help='Overrides the path for finding wkhtmltopdf.')
400
+ # --wk-options OPTIONS
401
+ parser.add_argument('--wk-options', dest='wkOptions', nargs='*',
402
+ help='Sets additional options to be used in wkhtmltopdf. Should be a series of options and values, replacing the - or -- in the beginning with + or ++, respectively. For example: --wk-options "+O Landscape"')
403
+ # --prepared-html
404
+ parser.add_argument('--prepared-html', dest='preparedHtml', action='store_true',
405
+ help='When used in conjunction with --html, sets whether the HTML output should be prepared for embedded attachments.')
406
+ # --charset
407
+ parser.add_argument('--charset', dest='charset', default='utf-8',
408
+ help='Character set to use for the prepared HTML in the added tag. (Default: utf-8)')
409
+ # --raw
410
+ outFormat.add_argument('--raw', dest='raw', action='store_true',
411
+ help='Sets whether the output should be raw. If this is not possible, will error.')
412
+ # --rtf
413
+ outFormat.add_argument('--rtf', dest='rtf', action='store_true',
414
+ help='Sets whether the output should be RTF. If this is not possible, will error.')
415
+ # --allow-fallback
416
+ parser.add_argument('--allow-fallback', dest='allowFallback', action='store_true',
417
+ help='Tells the program to fallback to a different save type if the selected one is not possible.')
418
+ # --skip-body-not-found
419
+ parser.add_argument('--skip-body-not-found', dest='skipBodyNotFound', action='store_true',
420
+ help='Skips saving the body if the body cannot be found, rather than throwing an error.')
421
+ # --zip
422
+ parser.add_argument('--zip', dest='zip',
423
+ help='Path to use for saving to a zip file.')
424
+ # --save-header
425
+ parser.add_argument('--save-header', dest='saveHeader', action='store_true',
426
+ help='Store the header in a separate file.')
427
+ # --attachments-only
428
+ outFormat.add_argument('--attachments-only', dest='attachmentsOnly', action='store_true',
429
+ help='Specify to only save attachments from an MSG file.')
430
+ # --skip-hidden
431
+ parser.add_argument('--skip-hidden', dest='skipHidden', action='store_true',
432
+ help='Skips any attachment marked as hidden (usually ones embedded in the body).')
433
+ # --no-folders
434
+ parser.add_argument('--no-folders', dest='noFolders', action='store_true',
435
+ help='Stores everything in the location specified by --out. Requires --attachments-only and is incompatible with --out-name.')
436
+ # --skip-embedded
437
+ parser.add_argument('--skip-embedded', dest='skipEmbedded', action='store_true',
438
+ help='Skips all embedded MSG files when saving attachments.')
439
+ # --extract-embedded
440
+ parser.add_argument('--extract-embedded', dest='extractEmbedded', action='store_true',
441
+ help='Extracts the embedded MSG files as MSG files instead of running their save functions.')
442
+ # --overwrite-existing
443
+ parser.add_argument('--overwrite-existing', dest='overwriteExisting', action='store_true',
444
+ help='Disables filename conflict resolution code for attachments when saving a file, causing files to be overwriten if two attachments with the same filename are on an MSG file.')
445
+ # --skip-not-implemented
446
+ parser.add_argument('--skip-not-implemented', '--skip-ni', dest='skipNotImplemented', action='store_true',
447
+ help='Skips any attachments that are not implemented, allowing saving of the rest of the message.')
448
+ # --out-name NAME
449
+ inputFormat.add_argument('--out-name', dest='outName',
450
+ help='Name to be used with saving the file output. Cannot be used if you are saving more than one file.')
451
+ # --glob
452
+ inputFormat.add_argument('--glob', '--wildcard', dest='glob', action='store_true',
453
+ help='Interpret all paths as having wildcards. Incompatible with --out-name.')
454
+ # --ignore-rtfde
455
+ parser.add_argument('--ignore-rtfde', dest='ignoreRtfDeErrors', action='store_true',
456
+ help='Ignores all errors thrown from RTFDE when trying to save. Useful for allowing fallback to continue when an exception happens.')
457
+ # --progress
458
+ parser.add_argument('--progress', dest='progress', action='store_true',
459
+ help='Shows what file the program is currently working on during it\'s progress.')
460
+ # -s, --stdout
461
+ inputType.add_argument('-s', '--stdin', dest='stdin', action='store_true',
462
+ help='Read file from stdin (only works with one file at a time).')
463
+ # [MSG files]
464
+ inputType.add_argument('msgs', metavar='msg', nargs='*', default=[],
465
+ help='An MSG file to be parsed.')
466
+
467
+ options = parser.parse_args(args)
468
+
469
+ if options.stdin:
470
+ # Read the MSG file from stdin and shove it into the msgs list.
471
+ options.msgs.append(sys.stdin.buffer.read())
472
+
473
+ if options.outName and options.noFolders:
474
+ raise IncompatibleOptionsError('--out-name is not compatible with --no-folders.')
475
+
476
+ if options.fileLogging:
477
+ options.verbose = options.verbose or 1
478
+
479
+ # Handle the wkOptions if they exist.
480
+ if options.wkOptions:
481
+ wkOptions = []
482
+ for option in options.wkOptions:
483
+ if option.startswith('++'):
484
+ option = '--' + option[2:]
485
+ elif option.startswith('+'):
486
+ option = '-' + option[1:]
487
+
488
+ # Now that we have corrected to the correct start, split the argument if
489
+ # necessary.
490
+ split = option.split(' ')
491
+ if len(split) == 1:
492
+ # No spaces means we just pass that directly.
493
+ wkOptions.append(option)
494
+ else:
495
+ wkOptions.append(split[0])
496
+ wkOptions.append(' '.join(split[1:]))
497
+
498
+ options.wkOptions = wkOptions
499
+
500
+ # If dump_stdout is True, we need to unset all arguments used in files.
501
+ # Technically we actually only *need* to unset `out_path`, but that may
502
+ # change in the future, so let's be thorough.
503
+ if options.dumpStdout:
504
+ options.outPath = None
505
+ options.json = False
506
+ options.rtf = False
507
+ options.html = False
508
+ options.useFilename = False
509
+ options.cid = False
510
+
511
+ if options.glob:
512
+ if options.outName:
513
+ raise IncompatibleOptionsError('--out-name is not supported when using wildcards.')
514
+ if options.stdin:
515
+ raise IncompatibleOptionsError('--stdin is not supported with using wildcards.')
516
+ fileLists = []
517
+ for path in options.msgs:
518
+ fileLists += glob.glob(path)
519
+
520
+ if len(fileLists) == 0:
521
+ raise ValueError('Could not find any MSG files using the specified wildcards.')
522
+ options.msgs = fileLists
523
+
524
+ # Make it so outName can only be used on single files.
525
+ if options.outName and len(options.msgs) > 1:
526
+ raise IncompatibleOptionsError('--out-name is not supported when saving multiple MSG files.')
527
+
528
+ # Handle the verbosity level.
529
+ if options.verbose == 0:
530
+ options.logLevel = logging.ERROR
531
+ elif options.verbose == 1:
532
+ options.logLevel = logging.WARNING
533
+ elif options.verbose == 2:
534
+ options.logLevel = logging.INFO
535
+ else:
536
+ options.logLevel = 5
537
+
538
+ # If --no-folders is turned on but --attachments-only is not, error.
539
+ if options.noFolders and not options.attachmentsOnly:
540
+ raise ValueError('--no-folders requires the --attachments-only option.')
541
+
542
+ return options
543
+
544
+
545
+ def guessEncoding(msg: MSGFile) -> Optional[str]:
546
+ """
547
+ Analyzes the strings on an MSG file and attempts to form a consensus about the encoding based on the top-level strings.
548
+
549
+ Returns ``None`` if no consensus could be formed.
550
+
551
+ :raises DependencyError: ``chardet`` is not installed or could not be used
552
+ properly.
553
+ """
554
+ try:
555
+ import chardet
556
+ except ImportError:
557
+ raise DependencyError('Cannot guess the encoding of an MSG file if chardet is not installed.')
558
+
559
+ data = b''
560
+ for name in (x[0] for x in msg.listDir(True, False, False) if len(x) == 1):
561
+ if name.lower().endswith('001f'):
562
+ # This is a guarentee.
563
+ return 'utf-16-le'
564
+ elif name.lower().endswith('001e'):
565
+ data += msg.getStream(name) + b'\n'
566
+
567
+ try:
568
+ if not data or (result := chardet.detect(data))['confidence'] < 0.5:
569
+ return None
570
+
571
+ return result['encoding']
572
+ except Exception as e:
573
+ raise DependencyError(f'Failed to detect encoding: {e}')
574
+
575
+
576
+ def htmlSanitize(inp: str) -> str:
577
+ """
578
+ Santizes the input for injection into an HTML string.
579
+
580
+ Converts charactersinto forms that will not be misinterpreted, if
581
+ necessary.
582
+ """
583
+ # First step, do a basic escape of the HTML.
584
+ inp = htmlEscape(inp)
585
+
586
+ # Change newlines to <br/> to they won't be ignored.
587
+ inp = inp.replace('\r\n', '\n').replace('\n', '<br/>')
588
+
589
+ # Escape long sections of spaces to ensure they won't be ignored.
590
+ inp = constants.re.HTML_SAN_SPACE.sub((lambda spaces: '&nbsp;' * len(spaces.group(0))),inp)
591
+
592
+ return inp
593
+
594
+
595
+ def inputToBytes(obj: Union[bytes, None, str, SupportsBytes], encoding: str) -> bytes:
596
+ """
597
+ Converts the input into bytes.
598
+
599
+ :raises ConversionError: The input cannot be converted.
600
+ :raises UnicodeEncodeError: The input was a str but the encoding was not
601
+ valid.
602
+ :raises TypeError: The input has a __bytes__ method, but it failed.
603
+ :raises ValueError: Same as above.
604
+ """
605
+ if isinstance(obj, bytes):
606
+ return obj
607
+ if isinstance(obj, str):
608
+ return obj.encode(encoding)
609
+ if obj is None:
610
+ return b''
611
+ if hasattr(obj, '__bytes__'):
612
+ return bytes(obj)
613
+
614
+ raise ConversionError('Cannot convert to bytes.')
615
+
616
+
617
+ def inputToMsgPath(inp: constants.MSG_PATH) -> List[str]:
618
+ """
619
+ Converts the input into an msg path.
620
+
621
+ :raises ValueError: The path contains an illegal character.
622
+ """
623
+ if isinstance(inp, (list, tuple)):
624
+ inp = '/'.join(inp)
625
+
626
+ inp = inputToString(inp, 'utf-8')
627
+
628
+ # Validate the path is okay. Normally we would check for '/' and '\', but
629
+ # we are expecting a string or similar which will use those as path
630
+ # separators, so we will ignore that for now.
631
+ if ':' in inp or '!' in inp:
632
+ raise ValueError('Illegal character ("!" or ":") found in MSG path.')
633
+
634
+ ret = [x for x in inp.replace('\\', '/').split('/') if x]
635
+
636
+ # One last thing to check: all path segments can be, at most, 31 characters
637
+ # (32 if you include the null character), so we should verify that.
638
+ if any(len(x) > 31 for x in ret):
639
+ raise ValueError('Path segments must not be greater than 31 characters.')
640
+ return ret
641
+
642
+
643
+ def inputToString(bytesInputVar: Optional[Union[str, bytes]], encoding: str) -> str:
644
+ """
645
+ Converts the input into a string.
646
+
647
+ :raises ConversionError: The input cannot be converted.
648
+ """
649
+ if isinstance(bytesInputVar, str):
650
+ return bytesInputVar
651
+ elif isinstance(bytesInputVar, bytes):
652
+ return bytesInputVar.decode(encoding)
653
+ elif bytesInputVar is None:
654
+ return ''
655
+ else:
656
+ raise ConversionError('Cannot convert to str type.')
657
+
658
+
659
+ def isEncapsulatedRtf(inp: bytes) -> bool:
660
+ """
661
+ Checks if the RTF data has encapsulated HTML.
662
+
663
+ Currently the detection is made to be *extremly* basic, but this will work
664
+ for now. In the future this will be fixed so that literal text in the body
665
+ of a message won't cause false detection.
666
+ """
667
+ return b'\\fromhtml' in inp
668
+
669
+
670
+ def makeWeakRef(obj: Optional[_T]) -> Optional[weakref.ReferenceType[_T]]:
671
+ """
672
+ Attempts to return a weak reference to the object, returning None if not
673
+ possible.
674
+ """
675
+ if obj is None:
676
+ return None
677
+ else:
678
+ return weakref.ref(obj)
679
+
680
+
681
+ def minutesToDurationStr(minutes: int) -> str:
682
+ """
683
+ Converts the number of minutes into a duration string.
684
+ """
685
+ if minutes == 0:
686
+ return '0 hours'
687
+ elif minutes == 1:
688
+ return '1 minute'
689
+ elif minutes < 60:
690
+ return f'{minutes} minutes'
691
+ elif minutes % 60 == 0:
692
+ return f'{minutes // 60} hours'
693
+ else:
694
+ return f'{minutes // 60} hours {minutes % 60} minutes'
695
+
696
+
697
+ def msgPathToString(inp: Union[str, Iterable[str]]) -> str:
698
+ """
699
+ Converts an MSG path (one of the internal paths inside an MSG file) into a
700
+ string.
701
+ """
702
+ if not isinstance(inp, str):
703
+ inp = '/'.join(inp)
704
+ inp.replace('\\', '/')
705
+ return inp
706
+
707
+
708
+ def parseType(_type: int, stream: Union[int, bytes], encoding: str, extras: Sequence[bytes]):
709
+ """
710
+ Converts the data in :param stream: to a much more accurate type, specified
711
+ by :param _type:.
712
+
713
+ :param _type: The data's type.
714
+ :param stream: The data to be converted.
715
+ :param encoding: The encoding to be used for regular strings.
716
+ :param extras: Used in the case of types like PtypMultipleString. For that
717
+ example, extras should be a list of the bytes from rest of the streams.
718
+
719
+ :raises NotImplementedError: The type has no current support. Most of these
720
+ types have no documentation in [MS-OXMSG].
721
+ """
722
+ # WARNING Not done. Do not try to implement anywhere where it is not already implemented.
723
+ value = stream
724
+ lengthExtras = len(extras)
725
+ if _type == 0x0000: # PtypUnspecified
726
+ pass
727
+ elif _type == 0x0001: # PtypNull
728
+ if value != b'\x00\x00\x00\x00\x00\x00\x00\x00':
729
+ # DEBUG
730
+ logger.warning('Property type is PtypNull, but is not equal to 0.')
731
+ return None
732
+ elif _type == 0x0002: # PtypInteger16
733
+ return constants.st.ST_LE_UI16.unpack(value[:2])[0]
734
+ elif _type == 0x0003: # PtypInteger32
735
+ return constants.st.ST_LE_UI32.unpack(value[:4])[0]
736
+ elif _type == 0x0004: # PtypFloating32
737
+ return constants.st.ST_LE_F32.unpack(value[:4])[0]
738
+ elif _type == 0x0005: # PtypFloating64
739
+ return constants.st.ST_LE_F64.unpack(value)[0]
740
+ elif _type == 0x0006: # PtypCurrency
741
+ return decimal.Decimal((constants.st.ST_LE_I64.unpack(value))[0]) / 10000
742
+ elif _type == 0x0007: # PtypFloatingTime
743
+ value = constants.st.ST_LE_F64.unpack(value)[0]
744
+ return constants.PYTPFLOATINGTIME_START + datetime.timedelta(days = value)
745
+ elif _type == 0x000A: # PtypErrorCode
746
+ from .enums import ErrorCode, ErrorCodeType
747
+ value = constants.st.ST_LE_UI32.unpack(value[:4])[0]
748
+ try:
749
+ value = ErrorCodeType(value)
750
+ except ValueError:
751
+ logger.warning(f'Error type found that was not from Additional Error Codes. Value was {value}. You should report this to the developers.')
752
+ # So here, the value should be from Additional Error Codes, but it
753
+ # wasn't. So we are just returning the int. However, we want to see
754
+ # if it is a normal error code.
755
+ try:
756
+ logger.warning(f'REPORT TO DEVELOPERS: Error type of {ErrorCode(value)} was found.')
757
+ except ValueError:
758
+ pass
759
+ return value
760
+ elif _type == 0x000B: # PtypBoolean
761
+ return constants.st.ST_LE_UI16.unpack(value[:2])[0] != 0
762
+ elif _type == 0x000D: # PtypObject/PtypEmbeddedTable
763
+ # TODO parsing for this.
764
+ # Wait, that's the extension for an attachment folder, so parsing this
765
+ # might not be as easy as we would hope. The function may be released
766
+ # without support for this.
767
+ raise NotImplementedError('Current version of extract-msg does not support the parsing of PtypObject/PtypEmbeddedTable in this function.')
768
+ elif _type == 0x0014: # PtypInteger64
769
+ return constants.st.ST_LE_UI64.unpack(value)[0]
770
+ elif _type == 0x001E: # PtypString8
771
+ return value.decode(encoding)
772
+ elif _type == 0x001F: # PtypString
773
+ return value.decode('utf-16-le')
774
+ elif _type == 0x0040: # PtypTime
775
+ rawTime = constants.st.ST_LE_UI64.unpack(value)[0]
776
+ return filetimeToDatetime(rawTime)
777
+ elif _type == 0x0048: # PtypGuid
778
+ return bytesToGuid(value)
779
+ elif _type == 0x00FB: # PtypServerId
780
+ count = constants.st.ST_LE_UI16.unpack(value[:2])[0]
781
+ # If the first byte is a 1 then it uses the ServerID structure.
782
+ if value[3] == 1:
783
+ from .structures.misc_id import ServerID
784
+ return ServerID(value)
785
+ else:
786
+ return (count, value[2:count + 2])
787
+ elif _type == 0x00FD: # PtypRestriction
788
+ # TODO parsing for this.
789
+ raise NotImplementedError('Parsing for type 0x00FD (PtypRestriction) has not yet been implmented. If you need this type, please create a new issue labeled "NotImplementedError: parseType 0x00FD PtypRestriction".')
790
+ elif _type == 0x00FE: # PtypRuleAction
791
+ # TODO parsing for this.
792
+ raise NotImplementedError('Parsing for type 0x00FE (PtypRuleAction) has not yet been implmented. If you need this type, please create a new issue labeled "NotImplementedError: parseType 0x00FE PtypRuleAction".')
793
+ elif _type == 0x0102: # PtypBinary
794
+ return value
795
+ elif _type & 0x1000 == 0x1000: # PtypMultiple
796
+ # TODO parsing for remaining "multiple" types.
797
+ if _type in (0x101F, 0x101E): # PtypMultipleString/PtypMultipleString8
798
+ ret = [x.decode(encoding)[:-1] for x in extras]
799
+ lengths = struct.unpack(f'<{len(ret)}i', stream)
800
+ lengthLengths = len(lengths)
801
+ if lengthLengths > lengthExtras:
802
+ logger.warning(f'Error while parsing multiple type. Expected {lengthLengths} stream{"s" if lengthLengths != 1 else ""}, got {lengthExtras}. Ignoring.')
803
+ for x, y in enumerate(extras):
804
+ if lengths[x] != len(y):
805
+ logger.warning(f'Error while parsing multiple type. Expected length {lengths[x]}, got {len(y)}. Ignoring.')
806
+ return ret
807
+ elif _type == 0x1102: # PtypMultipleBinary
808
+ ret = copy.deepcopy(extras)
809
+ lengths = tuple(constants.st.ST_LE_UI32.unpack(stream[pos*8:pos*8+4])[0] for pos in range(len(stream) // 8))
810
+ lengthLengths = len(lengths)
811
+ if lengthLengths > lengthExtras:
812
+ logger.warning(f'Error while parsing multiple type. Expected {lengthLengths} stream{"s" if lengthLengths != 1 else ""}, got {lengthExtras}. Ignoring.')
813
+ for x, y in enumerate(extras):
814
+ if lengths[x] != len(y):
815
+ logger.warning(f'Error while parsing multiple type. Expected length {lengths[x]}, got {len(y)}. Ignoring.')
816
+ return ret
817
+ elif _type in (0x1002, 0x1003, 0x1004, 0x1005, 0x1007, 0x1014, 0x1040, 0x1048):
818
+ if stream != len(extras):
819
+ logger.warning(f'Error while parsing multiple type. Expected {stream} entr{"y" if stream == 1 else "ies"}, got {len(extras)}. Ignoring.')
820
+ if _type == 0x1002: # PtypMultipleInteger16
821
+ return tuple(constants.st.ST_LE_UI16.unpack(x)[0] for x in extras)
822
+ if _type == 0x1003: # PtypMultipleInteger32
823
+ return tuple(constants.st.ST_LE_UI32.unpack(x)[0] for x in extras)
824
+ if _type == 0x1004: # PtypMultipleFloating32
825
+ return tuple(constants.st.ST_LE_F32.unpack(x)[0] for x in extras)
826
+ if _type == 0x1005: # PtypMultipleFloating64
827
+ return tuple(constants.st.ST_LE_F64.unpack(x)[0] for x in extras)
828
+ if _type == 0x1007: # PtypMultipleFloatingTime
829
+ values = (constants.st.ST_LE_F64.unpack(x)[0] for x in extras)
830
+ return tuple(constants.PYTPFLOATINGTIME_START + datetime.timedelta(days = amount) for amount in values)
831
+ if _type == 0x1014: # PtypMultipleInteger64
832
+ return tuple(constants.st.ST_LE_UI64.unpack(x)[0] for x in extras)
833
+ if _type == 0x1040: # PtypMultipleTime
834
+ return tuple(filetimeToUtc(constants.st.ST_LE_UI64.unpack(x)[0]) for x in extras)
835
+ if _type == 0x1048: # PtypMultipleGuid
836
+ return tuple(bytesToGuid(x) for x in extras)
837
+ else:
838
+ raise NotImplementedError(f'Parsing for type {_type} has not yet been implmented. If you need this type, please create a new issue labeled "NotImplementedError: parseType {_type}".')
839
+ return value
840
+
841
+
842
+ def prepareFilename(filename: str) -> str:
843
+ """
844
+ Adjusts :param filename: so that it can succesfully be used as an actual
845
+ file name.
846
+ """
847
+ # I would use re here, but it tested to be slightly slower than this.
848
+ return ''.join(i for i in filename if i not in r'\/:*?"<>|' + '\x00').strip()
849
+
850
+
851
+ def roundUp(inp: int, mult: int) -> int:
852
+ """
853
+ Rounds :param inp: up to the nearest multiple of :param mult:.
854
+ """
855
+ return inp + (mult - inp) % mult
856
+
857
+
858
+ def rtfSanitizeHtml(inp: str) -> str:
859
+ """
860
+ Sanitizes input to an RTF stream that has encapsulated HTML.
861
+ """
862
+ if not inp:
863
+ return ''
864
+ output = ''
865
+ for char in inp:
866
+ # Check if it is in the right range to be printed directly.
867
+ if 32 <= ord(char) < 128:
868
+ # Quick check for handling the HTML escapes. Will eventually
869
+ # upgrade this code to actually handle all the HTML escapes
870
+ # but this will do for now.
871
+ if char == '<':
872
+ output += r'{\*\htmltag84 &lt;}\htmlrtf <\htmlrtf0 '
873
+ elif char == '>':
874
+ output += r'{\*\htmltag84 &gt;}\htmlrtf >\htmlrtf0'
875
+ else:
876
+ if char in ('\\', '{', '}'):
877
+ output += '\\'
878
+ output += char
879
+ elif ord(char) < 32 or 128 <= ord(char) <= 255:
880
+ # Otherwise, see if it is just a small escape.
881
+ output += f"\\'{ord(char):02X}"
882
+ else:
883
+ # Handle Unicode characters.
884
+ enc = char.encode('utf-16-le')
885
+ output += ''.join(f'\\u{x}?' for x in struct.unpack(f'<{len(enc) // 2}h', enc))
886
+
887
+ return output
888
+
889
+
890
+ def rtfSanitizePlain(inp: str) -> str:
891
+ """
892
+ Sanitizes input to a plain RTF stream.
893
+ """
894
+ if not inp:
895
+ return ''
896
+ output = ''
897
+ for char in inp:
898
+ # Check if it is in the right range to be printed directly.
899
+ if 32 <= ord(char) < 128:
900
+ if char in ('\\', '{', '}'):
901
+ output += '\\'
902
+ output += char
903
+ elif ord(char) < 32 or 128 <= ord(char) <= 255:
904
+ # Otherwise, see if it is just a small escape.
905
+ output += f"\\'{ord(char):02X}"
906
+ else:
907
+ # Handle Unicode characters.
908
+ enc = char.encode('utf-16-le')
909
+ output += ''.join(f'\\u{x}?' for x in struct.unpack(f'<{len(enc) // 2}h', enc))
910
+
911
+ return output
912
+
913
+
914
+ def setupLogging(defaultPath = None, defaultLevel = logging.WARN, logfile = None, enableFileLogging: bool = False,
915
+ env_key = 'EXTRACT_MSG_LOG_CFG') -> bool:
916
+ """
917
+ Setup logging configuration
918
+
919
+ :param defaultPath: Default path to use for the logging configuration file.
920
+ :param defaultLevel: Default logging level.
921
+ :param env_key: Environment variable name to search for, for setting logfile
922
+ path.
923
+ :param enableFileLogging: Whether to use a file to log or not.
924
+
925
+ :returns: ``True`` if the configuration file was found and applied,
926
+ ``False`` otherwise
927
+ """
928
+ shippedConfig = pathlib.Path(__file__).parent / 'data' / 'logging-config'
929
+ if os.name == 'nt':
930
+ null = 'NUL'
931
+ shippedConfig /= 'logging-nt.json'
932
+ elif os.name == 'posix':
933
+ null = '/dev/null'
934
+ shippedConfig /= 'logging-posix.json'
935
+ # Find logging.json if not provided
936
+ defaultPath = pathlib.Path(defaultPath) if defaultPath else shippedConfig
937
+
938
+ paths = [
939
+ defaultPath,
940
+ pathlib.Path('logging.json'),
941
+ pathlib.Path('../logging.json'),
942
+ pathlib.Path('../../logging.json'),
943
+ shippedConfig,
944
+ ]
945
+
946
+ path = None
947
+
948
+ for configPath in paths:
949
+ if configPath.exists():
950
+ path = configPath
951
+ break
952
+
953
+ value = os.getenv(env_key, None)
954
+ if value and os.path.exists(value) and os.path.isfile(value):
955
+ path = pathlib.Path(value)
956
+
957
+ if not path:
958
+ print('Unable to find logging.json configuration file')
959
+ print('Make sure a valid logging configuration file is referenced in the defaultPath'
960
+ ' argument, is inside the extract_msg install location, or is available at one '
961
+ 'of the following file-paths:')
962
+ print(str(paths[1:]))
963
+ logging.basicConfig(level = defaultLevel)
964
+ logging.warning('The extract_msg logging configuration was not found - using a basic configuration.'
965
+ f'Please check the extract_msg installation directory for "logging-{os.name}.json".')
966
+ return False
967
+
968
+ with open(path, 'rt') as f:
969
+ config = json.load(f)
970
+
971
+ for x in config['handlers']:
972
+ if 'filename' in config['handlers'][x]:
973
+ if enableFileLogging:
974
+ config['handlers'][x]['filename'] = tmp = os.path.expanduser(
975
+ os.path.expandvars(logfile if logfile else config['handlers'][x]['filename']))
976
+ tmp = pathlib.Path(tmp).parent
977
+ if not tmp.exists:
978
+ os.makedirs(tmp)
979
+ else:
980
+ config['handlers'][x]['filename'] = null
981
+
982
+ try:
983
+ logging.config.dictConfig(config)
984
+ except ValueError as e:
985
+ print('Failed to configure the logger. Did your installation get messed up?')
986
+ print(e)
987
+
988
+ logging.getLogger().setLevel(defaultLevel)
989
+ return True
990
+
991
+
992
+ def tryGetMimetype(att: AttachmentBase, mimetype: Union[str, None]) -> Union[str, None]:
993
+ """
994
+ Uses an optional dependency to try and get the mimetype of an attachment.
995
+
996
+ If the mimetype has already been found, the optional dependency does not
997
+ exist, or an error occurs in the optional dependency, then the provided
998
+ mimetype is returned.
999
+
1000
+ :param att: The attachment to use for getting the mimetype.
1001
+ :param mimetype: The mimetype acquired directly from an attachment stream.
1002
+ If this value evaluates to ``False``, the function will try to
1003
+ determine it.
1004
+ """
1005
+ if mimetype:
1006
+ return mimetype
1007
+
1008
+ # We only try anything if the data is bytes.
1009
+ if att.dataType:
1010
+ # Try to import our dependency module to use it.
1011
+ try:
1012
+ import magic # pyright: ignore
1013
+
1014
+ if isinstance(att.data, (str, bytes)):
1015
+ return magic.from_buffer(att.data, mime = True)
1016
+ except ImportError:
1017
+ logger.info('Mimetype not found on attachment, and `mime` dependency not installed. Won\'t try to generate.')
1018
+
1019
+ except Exception:
1020
+ logger.exception('Error occured while using python-magic. This error will be ignored.')
1021
+
1022
+ return mimetype
1023
+
1024
+
1025
+ def unsignedToSignedInt(uInt: int) -> int:
1026
+ """
1027
+ Convert the bits of an unsigned int (32-bit) to a signed int.
1028
+
1029
+ :raises ValueError: The number was not valid.
1030
+ """
1031
+ if uInt > 0xFFFFFFFF:
1032
+ raise ValueError('Value is too large.')
1033
+ if uInt < 0:
1034
+ raise ValueError('Value is already signed.')
1035
+ return constants.st.ST_SBO_I32.unpack(constants.st.ST_SBO_UI32.pack(uInt))[0]
1036
+
1037
+
1038
+ def unwrapMsg(msg: MSGFile) -> Dict[str, List]:
1039
+ """
1040
+ Takes a recursive message-attachment structure and unwraps it into a linear
1041
+ dictionary for easy iteration.
1042
+
1043
+ Dictionary contains 4 keys: "attachments" for main message attachments, not
1044
+ including embedded MSG files, "embedded" for attachments representing
1045
+ embedded MSG files, "msg" for all MSG files (including the original in the
1046
+ first index), and "raw_attachments" for raw attachments from signed
1047
+ messages.
1048
+ """
1049
+ from .msg_classes import MessageSignedBase
1050
+
1051
+ # Here is where we store main attachments.
1052
+ attachments = []
1053
+ # Here is where we are going to store embedded MSG files.
1054
+ msgFiles = [msg]
1055
+ # Here is where we store embedded attachments.
1056
+ embedded = []
1057
+ # Here is where we store raw attachments from signed messages.
1058
+ raw = []
1059
+
1060
+ # Normally we would need a recursive function to unwrap a recursive
1061
+ # structure like the message-attachment structure. Essentially, a function
1062
+ # that calls itself. Here, I have designed code capable of circumventing
1063
+ # this to do it in a single function, which is a lot more efficient and
1064
+ # safer. That is why we store the `toProcess` and use a while loop
1065
+ # surrounding a for loop. The for loop would be the main body of the
1066
+ # function, while the append to toProcess would be the recursive call.
1067
+ toProcess = collections.deque((msg,))
1068
+
1069
+ while len(toProcess) > 0:
1070
+ # Remove the last item from the list of things to process, and store it
1071
+ # in `currentItem`. We will be processing it in the for loop.
1072
+ currentItem = toProcess.popleft()
1073
+ # iterate through the attachments and
1074
+ for att in currentItem.attachments:
1075
+ # If it is a regular attachment, add it to the list. Otherwise, add
1076
+ # it to be processed
1077
+ if att.type not in (AttachmentType.MSG, AttachmentType.SIGNED_EMBEDDED):
1078
+ attachments.append(att)
1079
+ else:
1080
+ # Here we do two things. The first is we store it to the output
1081
+ # so we can return it. The second is we add it to the processing
1082
+ # list. The reason this is two steps is because we need to be
1083
+ # able to remove items from the processing list, but can't
1084
+ # do that from the output.
1085
+ embedded.append(att)
1086
+ msgFiles.append(att.data)
1087
+ toProcess.append(att.data)
1088
+ if isinstance(currentItem, MessageSignedBase):
1089
+ raw += currentItem._rawAttachments
1090
+
1091
+ return {
1092
+ 'attachments': attachments,
1093
+ 'embedded': embedded,
1094
+ 'msg': msgFiles,
1095
+ 'raw_attachments': raw,
1096
+ }
1097
+
1098
+
1099
+ def unwrapMultipart(mp: Union[bytes, str, email.message.Message]) -> Dict:
1100
+ """
1101
+ Unwraps a recursive multipart structure into a dictionary of linear lists.
1102
+
1103
+ Similar to unwrapMsg, but for multipart. The dictionary contains 3 keys:
1104
+ "attachments" which contains a list of ``dict``\\s containing processed
1105
+ attachment data as well as the Message instance associated with it,
1106
+ "plain_body" which contains the plain text body, and "html_body" which
1107
+ contains the HTML body.
1108
+
1109
+ For clarification, each instance of processed attachment data is a ``dict``
1110
+ with keys identical to the args used for the ``SignedAttachment``
1111
+ constructor. This makes it easy to expand for use in constructing a
1112
+ ``SignedAttachment``. The only argument missing is "msg" to ensure this function will not require one.
1113
+
1114
+ :param mp: The bytes that make up a multipart, the string that makes up a
1115
+ multipart, or a ``Message`` instance from the ``email`` module created
1116
+ from the multipart data to unwrap. If providing a ``Message`` instance,
1117
+ prefer it to be an instance of ``EmailMessage``. If you are doing so,
1118
+ make sure it's policy is default.
1119
+ """
1120
+ # In the event we are generating it, these are the kwargs to use.
1121
+ genKwargs = {
1122
+ '_class': email.message.EmailMessage,
1123
+ 'policy': email.policy.default,
1124
+ }
1125
+ # Convert our input into something usable.
1126
+ if isinstance(mp, email.message.EmailMessage):
1127
+ if mp.policy == email.policy.default:
1128
+ mpMessage = mp
1129
+ else:
1130
+ mpMessage = email.message_from_bytes(mp.as_bytes(), **genKwargs)
1131
+ elif isinstance(mp, email.message.Message):
1132
+ mpMessage = email.message_from_bytes(mp.as_bytes(), **genKwargs)
1133
+ elif isinstance(mp, bytes):
1134
+ mpMessage = email.message_from_bytes(mp, **genKwargs)
1135
+ elif isinstance(mp, str):
1136
+ mpMessage = email.message_from_string(mp, **genKwargs)
1137
+ else:
1138
+ raise TypeError(f'Unsupported type "{type(mp)}" provided to unwrapMultipart.')
1139
+
1140
+ # Okay, now that we have it in a useable form, let's do the most basic
1141
+ # unwrapping possible. Once the most basic unwrapping is done, we can
1142
+ # actually process the data. For this, we only care if the section is
1143
+ # multipart or not. If it is, it get's unwrapped too.
1144
+ #
1145
+ # In case you are curious, this is effectively doing a breadth first
1146
+ # traversal of the tree.
1147
+ dataNodes = []
1148
+
1149
+ toProcess = collections.deque((mpMessage,))
1150
+ # I do know about the walk method, but it might *also* walk embedded
1151
+ # messages which we very much don't want.
1152
+ while len(toProcess) > 0:
1153
+ currentItem = toProcess.popleft()
1154
+ # 'multipart' indicates that it shouldn't contain any data itself, just
1155
+ # other nodes to go through.
1156
+ if currentItem.get_content_maintype() == 'multipart':
1157
+ payload = currentItem.get_payload()
1158
+ # For multipart, the payload should be a list, but handle it not
1159
+ # being one.
1160
+ if isinstance(payload, list):
1161
+ toProcess.extend(payload)
1162
+ else:
1163
+ logging.warning('Found multipart node that did not return a list. Appending as a data node.')
1164
+ dataNodes.append(currentItem)
1165
+ else:
1166
+ # The opposite is *not* true. If it's not multipart, always add as a
1167
+ # data node.
1168
+ dataNodes.append(currentItem)
1169
+
1170
+ # At this point, all of our nodes should have processed and we should now
1171
+ # have data nodes. Now let's process them. For anything that was parsed as
1172
+ # a message, we actually want to get it's raw bytes back so it can be saved.
1173
+ # If they user wants to process that message in some way, they can do it
1174
+ # themself.
1175
+ attachments = []
1176
+ plainBody = None
1177
+ htmlBody = None
1178
+
1179
+ for node in dataNodes:
1180
+ # Let's setup our attachment we are going to use.
1181
+ attachment = {
1182
+ 'data': None,
1183
+ 'name': node.get_filename(),
1184
+ 'mimetype': node.get_content_type(),
1185
+ 'node': node,
1186
+ }
1187
+
1188
+ # Finally, we need to get the data. As we need to ensure it is bytes,
1189
+ # we may have to do some special processing.
1190
+ data = node.get_content()
1191
+ if isinstance(data, bytes):
1192
+ # If the data is bytes, we are perfectly good.
1193
+ pass
1194
+ elif isinstance(data, email.message.Message):
1195
+ # If it is a message, get it's bytes directly.
1196
+ data = data.as_bytes()
1197
+ elif isinstance(data, str):
1198
+ # If it is a string, let's reverse encode it where possible.
1199
+ # First thing we want to check is if we can find the encoding type.
1200
+ # If we can, use that to reverse the process. Otherwise use utf-8.
1201
+ data = data.encode(node.get_content_charset('utf-8'))
1202
+ else:
1203
+ # We throw an exception to describe the problem if we can't reverse
1204
+ # the problem.
1205
+ raise TypeError(f'Attempted to get bytes for attachment, but could not convert {type(data)} to bytes.')
1206
+
1207
+ attachment['data'] = data
1208
+
1209
+ # Now for the fun part, figuring out if we actually have an attachment.
1210
+ if attachment['name']:
1211
+ attachments.append(attachment)
1212
+ elif attachment['mimetype'] == 'text/plain':
1213
+ if plainBody:
1214
+ logger.warning('Found multiple candidates for plain text body.')
1215
+ plainBody = data
1216
+ elif attachment['mimetype'] == 'text/html':
1217
+ if htmlBody:
1218
+ logger.warning('Found multiple candidates for HTML body.')
1219
+ htmlBody = data
1220
+
1221
+ return {
1222
+ 'attachments': attachments,
1223
+ 'plain_body': plainBody,
1224
+ 'html_body': htmlBody,
1225
+ }
1226
+
1227
+
1228
+ def validateHtml(html: bytes) -> bool:
1229
+ """
1230
+ Checks whether the HTML is considered valid.
1231
+
1232
+ To be valid, the HTML must, at minimum, contain an ``<html>`` tag, a
1233
+ ``<body>`` tag, and closing tags for each.
1234
+ """
1235
+ bs = bs4.BeautifulSoup(html, 'html.parser')
1236
+ if not bs.find('html') or not bs.find('body'):
1237
+ return False
1238
+ return True
1239
+
1240
+
1241
+ def verifyPropertyId(id: str) -> None:
1242
+ """
1243
+ Determines whether a property ID is valid for certain functions.
1244
+
1245
+ Property IDs MUST be a 4 digit hexadecimal string. Property is valid if no
1246
+ exception is raised.
1247
+
1248
+ :raises InvalidPropertyIdError: The ID is not a 4 digit hexadecimal number.
1249
+ """
1250
+ if not isinstance(id, str):
1251
+ raise InvalidPropertyIdError('ID was not a 4 digit hexadecimal string')
1252
+ if len(id) != 4:
1253
+ raise InvalidPropertyIdError('ID was not a 4 digit hexadecimal string')
1254
+ try:
1255
+ int(id, 16)
1256
+ except ValueError:
1257
+ raise InvalidPropertyIdError('ID was not a 4 digit hexadecimal string')
1258
+
1259
+
1260
+ def verifyType(_type: Optional[str]) -> None:
1261
+ """
1262
+ Verifies that the type is valid.
1263
+
1264
+ Raises an exception if it is not.
1265
+
1266
+ :raises UnknownTypeError: The type is not recognized.
1267
+ """
1268
+ if _type is not None:
1269
+ if (_type not in constants.VARIABLE_LENGTH_PROPS_STRING) and (_type not in constants.FIXED_LENGTH_PROPS_STRING):
1270
+ raise UnknownTypeError(f'Unknown type {_type}.')