From c83b7b8e62902aef8ba1078c1eb507fd378981ac Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 15 Jun 2022 23:28:01 -0700 Subject: [PATCH] Version 0.34.3 --- CHANGELOG.md | 10 ++++- README.rst | 4 +- extract_msg/__init__.py | 4 +- extract_msg/__main__.py | 14 +++++-- extract_msg/enums.py | 8 ++++ extract_msg/exceptions.py | 10 +++++ extract_msg/message_base.py | 73 ++++++++++++++++++++++++++++++++----- extract_msg/utils.py | 17 ++++++++- 8 files changed, 119 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07202bd..07626285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ -**0.34.2** +**v0.34.3** +* Fixed issue that may have caused other olefile types to raise the wrong type of error when passed to `openMsg`. +* Fixed issues with changelog format. +* Fixed issue that caused progress to sometimes break the main loop when a file had Unicode characters if the console it was writing to didn't support them. +* Added option to `MessageBase` (and subsequently `openMsg`) that allows you to override the code being used for deencapsulation. See `MessageBase.__init__` for details on how to create an override function. + +**v0.34.2** * [[TeamMsgExtractor #267](https://github.com/TeamMsgExtractor/msg-extractor/issues/267)] Fixed issue that caused signed messages that were .eml files to have their data field *not* be a bytes instance. This field will now *always* be bytes. If a problem making it bytes occurs, an exception will be raised to give you brief details. * Added function `utils.unwrapMultipart` that takes a multipart message and acquires a plain text body, an HTML body, and a list of attachments from it. These attachments are returned as `dict`s that can easily be converted to `SignedAttachment`s. It replaces the logic `mailbits` was being used for, and as such `mailbits` is no longer required. The module may be reintroduced in the future. * Added new property `emailMessage` to `SignedAttachment`, which returns the email `Message` instance used to get data for the attachment. -**0.34.1** +**v0.34.1** * Added convenience function `utils.openMsgBulk` (imported to `extract_msg` namespace) for opening message paths with wildcards. Allows you to open several messages with one function, returning a list of the opened messages. * Added convenience function `utils.unwrapMsg` which recurses through an `MSGFile` and it's attachments, creating a series of linear structures stored in a dictionary. Useful for analyzing, saving, etc. all messages and attachments down through the structure without having to recurse yourself. * Fixed an issue that would cause signed attachments to not properly generate. diff --git a/README.rst b/README.rst index ebd3f4c0..6f935ec6 100644 --- a/README.rst +++ b/README.rst @@ -219,8 +219,8 @@ your access to the newest major version of extract-msg. .. |License: GPL v3| image:: https://img.shields.io/badge/License-GPLv3-blue.svg :target: LICENSE.txt -.. |PyPI3| image:: https://img.shields.io/badge/pypi-0.34.2-blue.svg - :target: https://pypi.org/project/extract-msg/0.34.2/ +.. |PyPI3| image:: https://img.shields.io/badge/pypi-0.34.3-blue.svg + :target: https://pypi.org/project/extract-msg/0.34.3/ .. |PyPI2| image:: https://img.shields.io/badge/python-3.6+-brightgreen.svg :target: https://www.python.org/downloads/release/python-367/ diff --git a/extract_msg/__init__.py b/extract_msg/__init__.py index 4eeb4e3f..f70db8e0 100644 --- a/extract_msg/__init__.py +++ b/extract_msg/__init__.py @@ -27,8 +27,8 @@ # along with this program. If not, see . __author__ = 'Destiny Peterson & Matthew Walker' -__date__ = '2022-06-14' -__version__ = '0.34.2' +__date__ = '2022-06-15' +__version__ = '0.34.3' import logging diff --git a/extract_msg/__main__.py b/extract_msg/__main__.py index 53a2f61e..49eaf8bf 100644 --- a/extract_msg/__main__.py +++ b/extract_msg/__main__.py @@ -70,16 +70,24 @@ def main() -> None: } for x in args.msgs: - try: - if args.progress: + if args.progress: + # This may throw an error sometimes and not othertimes. + # Unclear why, so let's just silence it. + try: print(f'Saving file "{x}"...') + except UnicodeEncodeError: + print(f'Saving file "{repr(x)}" (failed to print without repr)...') + try: with utils.openMsg(x, **openKwargs) as msg: if args.dumpStdout: print(msg.body) else: msg.save(**kwargs) except Exception as e: - print(f'Error with file "{x}": {traceback.format_exc()}') + try: + print(f'Error with file "{x}": {traceback.format_exc()}') + except UnicodeEncodeError: + print(f'Error with file "{repr(x)}": {traceback.format_exc()}') if __name__ == '__main__': diff --git a/extract_msg/enums.py b/extract_msg/enums.py index 222d20de..23074e48 100644 --- a/extract_msg/enums.py +++ b/extract_msg/enums.py @@ -22,6 +22,14 @@ class AttachmentType(enum.Enum): WEB = 2 SIGNED = 3 +class DeencapType(enum.Enum): + """ + Enum to specify to custom deencapsulation functions the type of data being + requested. + """ + PLAIN = 0 + HTML = 1 + class DisplayType(enum.Enum): MAILUSER = 0x0000 DISTLIST = 0x0001 diff --git a/extract_msg/exceptions.py b/extract_msg/exceptions.py index 95237563..36251783 100644 --- a/extract_msg/exceptions.py +++ b/extract_msg/exceptions.py @@ -31,6 +31,16 @@ class DataNotFoundError(Exception): """ pass +class DeencapMalformedData(Exception): + """ + Data to deencapsulate was malformed in some way. + """ + +class DeencapNotEncapsulated(Exception): + """ + Data to deencapsulate did not contain any encapsulated data. + """ + class ExecutableNotFound(Exception): """ Could not find the specified executable. diff --git a/extract_msg/message_base.py b/extract_msg/message_base.py index 1f1bb986..7056ae59 100644 --- a/extract_msg/message_base.py +++ b/extract_msg/message_base.py @@ -10,7 +10,8 @@ import RTFDE from . import constants -from .enums import RecipientType +from .enums import DeencapType, RecipientType +from .exceptions import DeencapMalformedData, DeencapNotEncapsulated from .msg import MSGFile from .recipient import Recipient from .utils import inputToString, prepareFilename @@ -53,11 +54,23 @@ def __init__(self, path, **kwargs): recipients. :param ignoreRtfDeErrors: Optional, specifies that any errors that occur from the usage of RTFDE should be ignored (default: False). + :param deencapsulationFunc: Optional, if specified must be a callable + that will override the way that HTML/text is deencapsulated from the + RTF body. This function must take exactly 2 arguments, the first + being the RTF body from the message and the second being an instance + of the enum DeencapType that will tell the function what type of + body is desired. The function should return a string for plain text + and bytes for HTML. If any problems occur, the function *must* + either return None or raise one of the appropriate functions from + extract_msg.exceptions. All other functions must be handled + internally or they will continue. The original deencapsulation + method will not run if this is set. """ super().__init__(path, **kwargs) recipientSeparator = ';' self.__recipientSeparator = kwargs.get('recipientSeparator', ';') self.__ignoreRtfDeErrors = kwargs.get('ignoreRtfDeErrors', False) + self.__deencap = kwargs.get('deencapsulationFunc') # Initialize properties in the order that is least likely to cause bugs. # TODO have each function check for initialization of needed data so these # lines will be unnecessary. @@ -121,6 +134,49 @@ def _genRecipient(self, recipientType, recipientInt : RecipientType): return value + def deencapsulateBody(self, rtfBody : bytes, bodyType : DeencapType): + """ + A function to deencapsulate the specified body from the rtfBody. Returns + a string for plain text and bytes for HTML. If specified, uses the + deencapsulation override function. Returns None if nothing could be + deencapsulated. + + If you want to change the deencapsulation behaviour in a base class, + simply override this function. + """ + if rtfBody: + bodyType = DeencapType(bodyType) + if bodyType == DeencapType.PLAIN: + if self.__deencap: + try: + return self.__deencap(rtfBody, DeencapType.PLAIN) + except DeencapMalformedData: + logger.exception('Custom deencapsulation function reported encapsulated data was malformed.') + except DeencapNotEncapsulated: + logger.exception('Custom deencapsulation function reported data is not encapsulated.') + else: + if self.deencapsulatedRtf and self.deencapsulatedRtf.content_type == 'text': + return self.deencapsulatedRtf.text + else: + if self.__deencap: + try: + return self.__deencap(rtfBody, DeencapType.HTML) + except DeencapMalformedData: + logger.exception('Custom deencapsulation function reported encapsulated data was malformed.') + except DeencapNotEncapsulated: + logger.exception('Custom deencapsulation function reported data is not encapsulated.') + else: + if self.deencapsulatedRtf and self.deencapsulatedRtf.content_type == 'html': + return self.deencapsulatedRtf.html.encode('utf-8') + + if bodyType == DeencapType.PLAIN: + logger.info('Could not deencapsulate plain text from RTF body.') + else: + logger.info('Could not deencapsulate HTML from RTF body.') + else: + logger.info('No RTF body to deencapsulate from.') + return None + def headerInit(self) -> bool: """ Checks whether the header has been initialized. @@ -151,8 +207,8 @@ def body(self): else: # If the body doesn't exist, see if we can get it from the RTF # body. - if self.deencapsulatedRtf and self.deencapsulatedRtf.content_type == 'text': - self._body = self.deencapsulatedRtf.text + if self.rtfBody: + self._body = self.deencapsulateBody(self.rtfBody, DeencapType.PLAIN) if self._body: self._body = inputToString(self._body, 'utf-8') @@ -232,7 +288,7 @@ def deencapsulatedRtf(self) -> RTFDE.DeEncapsulator: # Because we are going to be actively removing things, # we want to search the entire thing over again. while match: - logger.info(f'Found match to badData starting at location {match.start()}. Replacing with nothing.') + logger.info(f'Found match to bin data starting at location {match.start()}. Replacing with nothing.') length = int(match.group(1)) # Extract the entire binary section and replace it. body = body.replace(body[match.start():match.end() + length], b'', 1) @@ -241,10 +297,10 @@ def deencapsulatedRtf(self) -> RTFDE.DeEncapsulator: self._deencapsultor = RTFDE.DeEncapsulator(body.decode(chardet.detect(body)['encoding'])) self._deencapsultor.deencapsulate() except RTFDE.exceptions.NotEncapsulatedRtf as e: - logger.debug("RTF body is not encapsulated.") + logger.debug('RTF body is not encapsulated.') self._deencapsultor = None except RTFDE.exceptions.MalformedEncapsulatedRtf as _e: - logger.info("RTF body contains malformed encapsulated content.") + logger.info('RTF body contains malformed encapsulated content.') self._deencapsultor = None except Exception: # If we are just ignoring the errors, log it then set to @@ -328,10 +384,7 @@ def htmlBody(self) -> bytes: pass elif self.rtfBody: logger.info('HTML body was not found, attempting to generate from RTF.') - if self.deencapsulatedRtf and self.deencapsulatedRtf.content_type == 'html': - self._htmlBody = self.deencapsulatedRtf.html.encode('utf-8') - else: - logger.info('Could not deencapsulate HTML from RTF body.') + self._htmlBody = self.deencapsulateBody(self.rtfBody, DeencapType.HTML) # This is it's own if statement so we can ensure it will generate # even if there is an rtfBody, in the event it doesn't have HTML. if not self._htmlBody and self.body: diff --git a/extract_msg/utils.py b/extract_msg/utils.py index 4230ba42..d5b45998 100644 --- a/extract_msg/utils.py +++ b/extract_msg/utils.py @@ -29,7 +29,7 @@ from . import constants from .enums import AttachmentType -from .exceptions import BadHtmlError, ConversionError, IncompatibleOptionsError, InvaildPropertyIdError, UnknownCodepageError, UnknownTypeError, UnrecognizedMSGTypeError, UnsupportedMSGTypeError +from .exceptions import BadHtmlError, ConversionError, IncompatibleOptionsError, InvalidFileFormatError, InvaildPropertyIdError, UnknownCodepageError, UnknownTypeError, UnrecognizedMSGTypeError, UnsupportedMSGTypeError logger = logging.getLogger(__name__) @@ -689,7 +689,20 @@ def openMsg(path, **kwargs): from .task import Task msg = MSGFile(path, **kwargs) - # After rechecking the docs, all comparisons should be case-insensitive, not case-sensitive. My reading ability is great. + # After rechecking the docs, all comparisons should be case-insensitive, not + # case-sensitive. My reading ability is great. + # + # Also after consideration, I realized we need to be very careful here, as + # other file types (like doc, ppt, etc.) might open but not return a class + # type. If the stream is not found, classType returns None, which has no + # lower function. So let's make sure we got a good return first. + if not msg.classType: + if kwargs.get('strict', True): + raise InvalidFileFormatError('File was confirmed to be an olefile, but was not an MSG file.') + else: + # If strict mode is off, we'll just return an MSGFile anyways. + logging.critical('Received file that was an olefile but was not an MSG file. Returning MSGFile anyways because strict mode is off.') + return msg classType = msg.classType.lower() if classType.startswith('ipm.contact') or classType.startswith('ipm.distlist'): msg.close()