diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e125ab..c5dc17a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +**v0.33.0** +* [[TeamMsgExtractor #212](https://github.com/TeamMsgExtractor/msg-extractor/issues/212)] Added support for Task objects. +* Added additional parameter `overrideClass` to all `_ensureSetX` type functions. This parameter is a class to initialize using the data, if provided. The data will be provided as the first argument to the `__init__` function of the class *if* the data is not `None`. If the data is done, the value set and returned from `_ensureSetX` will be `None`. This can be overriden using the second added parameter, which defaults to this behavior. Simply set `preserveNone` to `False` to force the data to be passed to the class. Keep in mind that this means the class will receive `None` as it's first parameter. +* Updated `Named` class to make it more like the dictionary it is build on top of. Specifically, added `keys`, `values`, and `items` as functions. +* Fixed issue in `Named` where old code wasn't deleted, causing some named properties to be completely omitted. +* Improved efficiency of `Named` by making it so `get` no longer copied the entire dictionary repeatedly while looking for the property. +* Fixed typo in `Attachment` that caused embedded msg files to still regenerate the `Named` instance even though they should have been using the parent's. +* Updated fields in `MSGFile` to use enums. +* Added additional properties to `MSGFile`. +* Significant cleanup. + **v0.32.0** * [[TeamMsgExtractor #217](https://github.com/TeamMsgExtractor/msg-extractor/issues/217)] Redesigned named properties to be much more efficient. All in all, load times for MSG files are significantly improved all around. * Named properties load dynamically. diff --git a/README.rst b/README.rst index 226130f4..d53c2b0a 100644 --- a/README.rst +++ b/README.rst @@ -209,11 +209,20 @@ Credits And thank you to everyone who has opened an issue and helped us track down those pesky bugs. +Extra +----- + +Check out the new project `msg-explorer`_ that allows you to open MSG files and +explore their contents in a GUI. It is usually updated within a few days of a +major release to ensure continued support. Because of this, it is recommended to +install it to a separate environment (like a vitural env) to not interfere with +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.32.0-blue.svg - :target: https://pypi.org/project/extract-msg/0.32.0/ +.. |PyPI3| image:: https://img.shields.io/badge/pypi-0.33.0-blue.svg + :target: https://pypi.org/project/extract-msg/0.33.0/ .. |PyPI2| image:: https://img.shields.io/badge/python-3.6+-brightgreen.svg :target: https://www.python.org/downloads/release/python-367/ @@ -227,3 +236,4 @@ And thank you to everyone who has opened an issue and helped us track down those .. _Seamus Tuohy: https://github.com/seamustuohy .. _Discord: https://discord.com/invite/B77McRmzdc .. _Buy Me a Coffee: https://www.buymeacoffee.com/DestructionE +.. _msg-explorer: https://pypi.org/project/msg-explorer/ diff --git a/extract_msg/__init__.py b/extract_msg/__init__.py index bde7ff89..1df63cc8 100644 --- a/extract_msg/__init__.py +++ b/extract_msg/__init__.py @@ -9,7 +9,7 @@ https://github.com/TeamMsgExtractor/msg-extractor """ -# --- LICENSE.txt ----------------------------------------------------------------- +# --- LICENSE.txt -------------------------------------------------------------- # # Copyright 2013-2022 Matthew Walker and Destiny Peterson # @@ -27,8 +27,8 @@ # along with this program. If not, see . __author__ = 'Destiny Peterson & Matthew Walker' -__date__ = '2022-06-07' -__version__ = '0.32.0' +__date__ = '2022-06-08' +__version__ = '0.33.0' import logging @@ -45,4 +45,5 @@ from .prop import createProp from .properties import Properties from .recipient import Recipient +from .task import Task from .utils import openMsg, properHex diff --git a/extract_msg/__main__.py b/extract_msg/__main__.py index 15687e06..d9632638 100644 --- a/extract_msg/__main__.py +++ b/extract_msg/__main__.py @@ -4,7 +4,6 @@ import traceback from extract_msg import __doc__, utils -from extract_msg.message import Message def main() -> None: @@ -14,7 +13,7 @@ def main() -> None: level = logging.INFO if args.verbose else logging.WARNING # Determine where to save the files to. - currentDir = os.getcwd() # Store this incase the path changes. + currentDir = os.getcwd() # Store this in case the path changes. if not args.zip: if args.out_path: if not os.path.exists(args.out_path): diff --git a/extract_msg/appointment.py b/extract_msg/appointment.py index f5d4e207..1adac53b 100644 --- a/extract_msg/appointment.py +++ b/extract_msg/appointment.py @@ -1,5 +1,3 @@ -from . import constants -from .attachment import Attachment from .message_base import MessageBase @@ -33,8 +31,7 @@ def location(self): try: return self.__location except AttributeError: - self.__location = self.named.getNamedValue('8208') - self.__location = self.named.getNamedValue('0002') if self.__location is None else self.__location + self.__location = self.named.getNamedValue('8208') or self.named.getNamedValue('0002') return self.__location @property diff --git a/extract_msg/attachment.py b/extract_msg/attachment.py index e470114c..25787d61 100644 --- a/extract_msg/attachment.py +++ b/extract_msg/attachment.py @@ -8,9 +8,8 @@ from . import constants from .attachment_base import AttachmentBase from .enums import AttachmentType -from .prop import FixedLengthProp, VariableLengthProp -from .properties import Properties -from .utils import createZipOpen, inputToString, openMsg, prepareFilename, verifyPropertyId, verifyType +from .utils import createZipOpen, inputToString, openMsg, prepareFilename + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -44,7 +43,7 @@ def __init__(self, msg, dir_): else: self.__prefix = msg.prefixList + [dir_, '__substg1.0_3701000D'] self.__type = AttachmentType.MSG - self.__data = openMsg(self.msg.path, prefix = self.__prefix, parent = self.msg, **self.msg.kwargs) + self.__data = openMsg(self.msg.path, prefix = self.__prefix, parentMsg = self.msg, **self.msg.kwargs) elif (self.props['37050003'].value & 0x7) == 0x7: # TODO Handling for special attacment type 0x7 self.__type = AttachmentType.WEB diff --git a/extract_msg/attachment_base.py b/extract_msg/attachment_base.py index 1c90772c..f15ecb68 100644 --- a/extract_msg/attachment_base.py +++ b/extract_msg/attachment_base.py @@ -1,12 +1,12 @@ import logging -from . import constants from .enums import PropertiesType from .named import NamedProperties from .prop import FixedLengthProp from .properties import Properties from .utils import verifyPropertyId, verifyType + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -30,13 +30,21 @@ def __init__(self, msg, dir_): self.__namedProperties = NamedProperties(msg.named, self) - def _ensureSet(self, variable, streamID, stringStream = True): + def _ensureSet(self, variable, streamID, stringStream = True, **kwargs): """ Ensures that the variable exists, otherwise will set it using the specified stream. After that, return said variable. If the specified stream is not a string stream, make sure to set :param stringStream: to False. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) @@ -45,25 +53,51 @@ def _ensureSet(self, variable, streamID, stringStream = True): value = self._getStringStream(streamID) else: value = self._getStream(streamID) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetNamed(self, variable, propertyName): + def _ensureSetNamed(self, variable, propertyName, **kwargs): """ Ensures that the variable exists, otherwise will set it using the named property. After that, return said variable. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) except AttributeError: value = self.namedProperties.get(propertyName) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetProperty(self, variable, propertyName): + def _ensureSetProperty(self, variable, propertyName, **kwargs): """ Ensures that the variable exists, otherwise will set it using the property. After that, return said variable. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) @@ -72,19 +106,37 @@ def _ensureSetProperty(self, variable, propertyName): value = self.props[propertyName].value except (KeyError, AttributeError): value = None + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetTyped(self, variable, _id): + def _ensureSetTyped(self, variable, _id, **kwargs): """ Like the other ensure set functions, but designed for when something could be multiple types (where only one will be present). This way you have no need to set the type, it will be handled for you. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) except AttributeError: value = self._getTypedData(_id) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value diff --git a/extract_msg/contact.py b/extract_msg/contact.py index 474697e7..d7e5d582 100644 --- a/extract_msg/contact.py +++ b/extract_msg/contact.py @@ -1,5 +1,3 @@ -from . import constants -from .attachment import Attachment from .msg import MSGFile @@ -113,7 +111,7 @@ def country(self): @property def departmentName(self): """ - The name of the dapartment the contact works in. + The name of the department the contact works in. """ return self._ensureSet('_departmentName', '__substg1.0_3A18') diff --git a/extract_msg/data.py b/extract_msg/data.py index e4f87b6d..b26a7c4b 100644 --- a/extract_msg/data.py +++ b/extract_msg/data.py @@ -17,7 +17,7 @@ def __init__(self, data : bytes): @property def globalCounter(self) -> int: """ - An unsigned ineger identifying the folder within its Store object. + An unsigned integer identifying the folder within its Store object. """ return self.__globalCounter @@ -42,7 +42,7 @@ def __init__(self, data): @property def globalCounter(self) -> int: """ - An unsigned ineger identifying the folder within its Store object. + An unsigned integer identifying the folder within its Store object. """ return self.__globalCounter diff --git a/extract_msg/dev.py b/extract_msg/dev.py index 448c35fd..7f137098 100644 --- a/extract_msg/dev.py +++ b/extract_msg/dev.py @@ -21,7 +21,7 @@ logger.addHandler(logging.NullHandler()) -def setupDevLogger(defaultPath=None, logfile = None, envKey='EXTRACT_MSG_LOG_CFG'): +def setupDevLogger(defaultPath = None, logfile = None, envKey = 'EXTRACT_MSG_LOG_CFG'): utils.setupLogging(defaultPath, 5, logfile, True, envKey) @@ -64,4 +64,4 @@ def main(args, argv): logpath = x.baseFilename except AttributeError: pass; - print(g'Logging complete. Log has been saved to {logpath}') + print(f'Logging complete. Log has been saved to {logpath}') diff --git a/extract_msg/dev_classes/attachment.py b/extract_msg/dev_classes/attachment.py index b5619773..e0f1157f 100644 --- a/extract_msg/dev_classes/attachment.py +++ b/extract_msg/dev_classes/attachment.py @@ -1,6 +1,5 @@ import logging -from .. import constants from ..enums import PropertiesType from ..properties import Properties from ..utils import properHex diff --git a/extract_msg/dev_classes/message.py b/extract_msg/dev_classes/message.py index ae71ef71..ea003d23 100644 --- a/extract_msg/dev_classes/message.py +++ b/extract_msg/dev_classes/message.py @@ -2,7 +2,6 @@ import logging import olefile -from .. import constants from ..enums import PropertiesType from ..dev_classes.attachment import Attachment from ..properties import Properties @@ -19,7 +18,7 @@ class Message(olefile.OleFileIO): Useful for malformed msg files. """ - def __init__(self, path, prefix='', filename=None): + def __init__(self, path, prefix = '', filename = None): """ :param path: path to the msg file in the system or is the raw msg file. :param prefix: used for extracting embedded msg files @@ -49,12 +48,12 @@ def __init__(self, path, prefix='', filename=None): self.__prefixList = prefixl if tmp_condition: - filename = self._getStringStream(prefixl[:-1] + ['__substg1.0_3001'], prefix=False) + filename = self._getStringStream(prefixl[:-1] + ['__substg1.0_3001'], prefix = False) if filename is not None: self.filename = filename else: - logger.log(5, f':param path: has __len__ attribute?: {has_len(path)}') - if has_len(path): + logger.log(5, f':param path: has __len__ attribute?: {hasLen(path)}') + if hasLen(path): if len(path) < 1536: self.filename = path logger.log(5, f':param path: length is {len(path)}; Using :param path: as file path') @@ -68,7 +67,7 @@ def __init__(self, path, prefix='', filename=None): recipientDirs = [] for dir_ in self.listDir(): - if dir_[len(self.__prefixList)].startswith('__recip') and\ + if dir_[len(self.__prefixList)].startswith('__recip') and \ dir_[len(self.__prefixList)] not in recipientDirs: recipientDirs.append(dir_[len(self.__prefixList)]) @@ -76,7 +75,7 @@ def __init__(self, path, prefix='', filename=None): self.attachments self.date - def _getStream(self, filename, prefix=True): + def _getStream(self, filename, prefix = True): filename = self.fix_path(filename, prefix) if self.exists(filename): stream = self.openstream(filename) @@ -85,13 +84,13 @@ def _getStream(self, filename, prefix=True): logger.info(f'Stream "{filename}" was requested but could not be found. Returning `None`.') return None - def _getStringStream(self, filename, prefer='unicode', prefix=True): + def _getStringStream(self, filename, prefix = True): """ Gets a string representation of the requested filename. This should ALWAYS return a string. """ - filename = self.fix_path(filename, prefix) + filename = self.fixPath(filename, prefix) if self.areStringsUnicode: return windowsUnicode(self._getStream(filename + '001F', prefix = False)) else: @@ -102,14 +101,14 @@ def exists(self, filename): """ Checks if :param filename: exists in the msg file. """ - filename = self.fix_path(filename) + filename = self.fixPath(filename) return self.exists(filename) def sExists(self, filename): """ Checks if string stream :param filename: exists in the msg file. """ - filename = self.fix_path(filename) + filename = self.fixPath(filename) return self.exists(filename + '001F') or self.exists(filename + '001E') def fixPath(self, filename, prefix=True): @@ -124,7 +123,7 @@ def fixPath(self, filename, prefix=True): filename = self.__prefix + filename return filename - def listDir(self, streams=True, storages=False): + def listDir(self, streams = True, storages = False): """ Replacement for OleFileIO.listdir that runs at the current prefix directory. """ @@ -142,15 +141,15 @@ def listDir(self, streams=True, storages=False): for pathEntry in entries: good = True # If the entry we are looking at is not longer then the prefix, it's not good. - if len(x) <= len(prefix): + if len(pathEntry) <= len(prefix): continue for index, entry in enumerate(prefix): - if x[y] != entry: + if pathEntry[index] != entry: good = False if good: - out.append(x) + out.append(pathEntry) return out @@ -181,7 +180,7 @@ def attachments(self): attachmentDirs = [] for dir_ in self.listDir(): - if dir_[len(self.__prefixList)].startswith('__attach') and\ + if dir_[len(self.__prefixList)].startswith('__attach') and \ dir_[len(self.__prefixList)] not in attachmentDirs: attachmentDirs.append(dir_[len(self.__prefixList)]) @@ -252,7 +251,7 @@ def recipients(self): recipientDirs = [] for dir_ in self.listDir(): - if dir_[len(self.__prefixList)].startswith('__recip') and\ + if dir_[len(self.__prefixList)].startswith('__recip') and \ dir_[len(self.__prefixList)] not in recipientDirs: recipientDirs.append(dir_[len(self.__prefixList)]) diff --git a/extract_msg/enums.py b/extract_msg/enums.py index 8dcd0f95..222d20de 100644 --- a/extract_msg/enums.py +++ b/extract_msg/enums.py @@ -118,3 +118,72 @@ class Sensitivity(enum.Enum): PERSONAL = 1 PRIVATE = 2 CONFIDENTIAL = 3 + +class TaskAcceptance(enum.Enum): + """ + The acceptance state of the task. + """ + NOT_ASSIGNED = 0x00000000 + UNKNOWN = 0x00000001 + ACCEPTED = 0x00000002 + REJECTED = 0x00000003 + +class TaskHistory(enum.Enum): + """ + The type of the last change to the Task object. + """ + NONE = 0x00000000 + ACCEPTED = 0x00000001 + REJECTED = 0x00000002 + OTHER = 0x00000003 + DUE_DATE_CHANGED = 0x00000004 + ASSIGNED = 0x00000005 + +class TaskMode(enum.Enum): + """ + The mode of the Task object used in task communication (PidLidTaskMode). + + UNASSIGNED: The Task object is not assigned. + EMBEDDED_REQUEST: The Task object is embedded in a task request. + ACCEPTED: The Task object has been accepted by the task assignee. + REJECTED: The Task object was rejected by the task assignee. + EMBEDDED_UPDATE: The Task object is embedded in a task update. + SELF_ASSIGNED: The Task object was assigned to the task assigner + (self-delegation). + """ + UNASSIGNED = 0 + EMBEDDED_REQUEST = 1 + ACCEPTED = 2 + REJECTED = 3 + EMBEDDED_UPDATE = 4 + SELF_ASSIGNED = 5 + +class TaskOwnership(enum.Enum): + """ + The role of the current user relative to the Task object. + + NOT_ASSIGNED: The Task object is not assigned. + ASSIGNERS_COPY: The Task object is the task assigner's copy of the Task + object. + ASSIGNEES_COPY: The Task object is the task assignee's copy of the Task + object. + """ + NOT_ASSIGNED = 0x00000000 + ASSIGNERS_COPY = 0x00000001 + ASSIGNEES_COPY = 0x00000002 + +class TaskStatus(enum.Enum): + """ + The status of a task object (PidLidTaskStatus). + + NOT_STARTED: The user has not started the task. + IN_PROGRESS: The users's work on the Task object is in progress. + COMPLETE: The user's work on the Task object is complete. + WAITING_ON_OTHER: The user is waiting on somebody else. + DEFERRED: The user has deffered work on the Task object. + """ + NOT_STARTED = 0x00000000 + IN_PROGRESS = 0x00000001 + COMPLETE = 0x00000002 + WAITING_ON_OTHER = 0x00000003 + DEFERRED = 0x00000004 diff --git a/extract_msg/message.py b/extract_msg/message.py index 825035f2..57d2b5f0 100644 --- a/extract_msg/message.py +++ b/extract_msg/message.py @@ -9,7 +9,6 @@ from imapclient.imapclient import decode_utf7 from . import constants -from .attachment import Attachment from .exceptions import DataNotFoundError, IncompatibleOptionsError from .message_base import MessageBase from .utils import addNumToDir, addNumToZipDir, createZipOpen, injectHtmlHeader, injectRtfHeader, inputToBytes, inputToString, prepareFilename @@ -18,10 +17,12 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) + class Message(MessageBase): """ Parser for Microsoft Outlook message files. """ + def __init__(self, path, **kwargs): super().__init__(path, **kwargs) @@ -103,7 +104,7 @@ def save(self, **kwargs): something parsing the html can properly determine the encoding (as not having this tag can cause errors in some programs). Set this to `None` or an empty string to not insert the tag (Default: 'utf-8'). - :param kwargs: Used to allow kwags expansion in the save function. + :param kwargs: Used to allow kwargs expansion in the save function. :param preparedHtml: When set, prepares the HTML body for standalone usage, doing things like adding tags, injecting attachments, etc. This is useful for things like trying to convert the HTML body @@ -232,17 +233,17 @@ def save(self, **kwargs): useRtf = False if html: if self.htmlBody: - useHtml = True - fext = 'html' + useHtml = True + fext = 'html' elif not allowFallback: - raise DataNotFoundError('Could not find the htmlBody') + raise DataNotFoundError('Could not find the htmlBody') if rtf or (html and not useHtml): if self.rtfBody: - useRtf = True - fext = 'rtf' + useRtf = True + fext = 'rtf' elif not allowFallback: - raise DataNotFoundError('Could not find the rtfBody') + raise DataNotFoundError('Could not find the rtfBody') if not skipAttachments: # Save the attachments. @@ -312,7 +313,7 @@ def getSaveHtmlBody(self, preparedHtml : bool = False, charset : str = 'utf-8', something parsing the html can properly determine the encoding (as not having this tag can cause errors in some programs). Set this to `None` or an empty string to not insert the tag (Default: 'utf-8'). - :param **kwargs: Used to allow kwargs expansion in the save function. + :param kwargs: Used to allow kwargs expansion in the save function. Arguments absorbed by this are simply ignored. :raises BadHtmlError: if :param preparedHtml: is False and the HTML @@ -355,7 +356,7 @@ def getSaveRtfBody(self, **kwargs) -> bytes: """ Returns the RTF body that will be used in saving based on the arguments. - :param **kwargs: Used to allow kwargs expansion in the save function. + :param kwargs: Used to allow kwargs expansion in the save function. Arguments absorbed by this are simply ignored. """ # Inject the header into the data. diff --git a/extract_msg/message_base.py b/extract_msg/message_base.py index be509e87..59509909 100644 --- a/extract_msg/message_base.py +++ b/extract_msg/message_base.py @@ -1,22 +1,19 @@ import base64 import email.utils +import html import logging -import os import re import bs4 import compressed_rtf import RTFDE -from . import constants -from .attachment import Attachment, BrokenAttachment, UnsupportedAttachment from .enums import RecipientType -from .exceptions import UnrecognizedMSGTypeError from .msg import MSGFile from .recipient import Recipient -from .utils import addNumToDir, inputToBytes, inputToString, prepareFilename +from .utils import inputToString, prepareFilename from email.parser import Parser as EmailParser -from imapclient.imapclient import decode_utf7 + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -26,6 +23,7 @@ class MessageBase(MSGFile): """ Base class for Message like msg files. """ + def __init__(self, path, **kwargs): """ :param path: path to the msg file in the system or is the raw msg file. @@ -295,8 +293,8 @@ def htmlBody(self) -> bytes: elif self.body: # Convert the plain text body to html. logger.info('HTML body was not found, attempting to generate from plain text body.') - correctedBody = self.body.encode('utf-8').replace('\r', '').replace('\n', '
') - self._htmlBody = f'{correctedBody}' + correctedBody = html.escpae(self.body).replace('\r', '').replace('\n', '
') + self._htmlBody = f'{correctedBody}'.encode('utf-8') else: logger.info('HTML body could not be found nor generated.') diff --git a/extract_msg/message_signed.py b/extract_msg/message_signed.py index fc91e55c..141d0879 100644 --- a/extract_msg/message_signed.py +++ b/extract_msg/message_signed.py @@ -9,7 +9,6 @@ from imapclient.imapclient import decode_utf7 from . import constants -from .attachment import Attachment from .exceptions import DataNotFoundError, IncompatibleOptionsError from .message_signed_base import MessageSignedBase from .utils import addNumToDir, addNumToZipDir, createZipOpen, injectHtmlHeader, injectRtfHeader, inputToBytes, inputToString, prepareFilename @@ -18,10 +17,12 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) + class MessageSigned(MessageSignedBase): """ Parser for Signed Microsoft Outlook message files. """ + def __init__(self, path, **kwargs): super().__init__(path, **kwargs) @@ -230,17 +231,17 @@ def save(self, **kwargs): useRtf = False if html: if self.htmlBody: - useHtml = True - fext = 'html' + useHtml = True + fext = 'html' elif not allowFallback: - raise DataNotFoundError('Could not find the htmlBody') + raise DataNotFoundError('Could not find the htmlBody') if rtf or (html and not useHtml): if self.rtfBody: - useRtf = True - fext = 'rtf' + useRtf = True + fext = 'rtf' elif not allowFallback: - raise DataNotFoundError('Could not find the rtfBody') + raise DataNotFoundError('Could not find the rtfBody') # Save the attachments. attachmentNames = [attachment.save(**kwargs) for attachment in self.attachments] @@ -308,7 +309,7 @@ def getSaveHtmlBody(self, preparedHtml : bool = False, charset : str = 'utf-8', something parsing the html can properly determine the encoding (as not having this tag can cause errors in some programs). Set this to `None` or an empty string to not insert the tag (Default: 'utf-8'). - :param **kwargs: Used to allow kwargs expansion in the save function. + :param kwargs: Used to allow kwargs expansion in the save function. Arguments absorbed by this are simply ignored. :raises BadHtmlError: if :param preparedHtml: is False and the HTML @@ -351,7 +352,7 @@ def getSaveRtfBody(self, **kwargs) -> bytes: """ Returns the RTF body that will be used in saving based on the arguments. - :param **kwargs: Used to allow kwargs expansion in the save function. + :param kwargs: Used to allow kwargs expansion in the save function. Arguments absorbed by this are simply ignored. """ # Inject the header into the data. diff --git a/extract_msg/message_signed_base.py b/extract_msg/message_signed_base.py index 7a63ba26..11950890 100644 --- a/extract_msg/message_signed_base.py +++ b/extract_msg/message_signed_base.py @@ -1,24 +1,14 @@ -import base64 import email.utils +import html import logging -import os import re -import bs4 -import compressed_rtf import mailbits -import RTFDE -from . import constants -from .exceptions import UnrecognizedMSGTypeError from .message_base import MessageBase -from .recipient import Recipient from .signed_attachment import SignedAttachment -from .utils import addNumToDir, inputToBytes, inputToString, prepareFilename +from .utils import inputToString -from email.parser import Parser as EmailParser - -from imapclient.imapclient import decode_utf7 logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -28,6 +18,7 @@ class MessageSignedBase(MessageBase): """ Base class for Message like msg files. """ + def __init__(self, path, **kwargs): """ :param path: path to the msg file in the system or is the raw msg file. @@ -86,15 +77,15 @@ def attachments(self) -> list: self._signedBody = None self._signedHtmlBody = None if len(atts) > 1: - logger.warn('') + logger.warning('Found more than one regular attachment when parsing signed message.') elif len(atts) == 0: - logger.warn('Failed to access any attachments from the signed message.') + logger.warning('Failed to access any attachments from the signed message.') return self._sAttachments try: mainAttachment = next(att for att in atts if hasattr(att, 'getFilename') and att.getFilename() == 'smime.p7m') except StopIteration: - logger.warn('Failed to find signed attachment.') + logger.warning('Failed to find signed attachment.') return self._sAttachments # If we are here, we should have the attachment. So now we need to @@ -133,14 +124,6 @@ def attachments(self) -> list: return self._sAttachments - - @property - def bcc(self): - """ - Returns the bcc field, if it exists. - """ - return self._genRecipient('bcc', 3) - @property def body(self): """ @@ -189,75 +172,13 @@ def htmlBody(self) -> bytes: elif self.body: # Convert the plain text body to html. logger.info('HTML body was not found, attempting to generate from plain text body.') - correctedBody = self.body.encode('utf-8').replace('\r', '').replace('\n', '
') - self._htmlBody = f'{correctedBody}' + correctedBody = html.escpae(self.body).replace('\r', '').replace('\n', '
') + self._htmlBody = f'{correctedBody}'.encode('utf-8') else: logger.info('HTML body could not be found nor generated.') return self._htmlBody - @property - def htmlBodyPrepared(self) -> bytes: - """ - Returns the HTML body that has (where possible) the embedded attachments - inserted into the body. - """ - # If we can't get an HTML body then we have nothing to do. - if not self.htmlBody: - return self.htmlBody - - # Create the BeautifulSoup instance to use. - soup = bs4.BeautifulSoup(self.htmlBody, 'html.parser') - - # Get a list of image tags to see if we can inject into. If the source - # of an image starts with "cid:" that means it is one of the attachments - # and is using the content id of that attachment. - tags = (tag for tag in soup.findAll('img') if tag.get('src') and tag.get('src').startswith('cid:')) - - for tag in tags: - # Iterate through the attachments until we get the right one. - cid = tag['src'][4:] - data = next((attachment.data for attachment in self.attachments if attachment.cid == cid), None) - # If we found anything, inject it. - if data: - tag['src'] = (b'data:image;base64,' + base64.b64encode(data)).decode('utf-8') - - return soup.prettify('utf-8') - - @property - def inReplyTo(self) -> str: - """ - Returns the message id that this message is in reply to. - """ - return self._ensureSet('_in_reply_to', '__substg1.0_1042') - - @property - def isRead(self) -> bool: - """ - Returns if this email has been marked as read. - """ - return bool(self.mainProperties['0E070003'].value & 1) - - @property - def messageId(self): - try: - return self._messageId - except AttributeError: - headerResult = None - if self.headerInit(): - headerResult = self._header['message-id'] - if headerResult is not None: - self._messageId = headerResult - else: - if self.headerInit(): - logger.info('Header found, but "Message-Id" is not included. Will be generated from other streams.') - self._messageId = self._getStringStream('__substg1.0_1035') - return self._messageId - - @property - def parsedDate(self): - return email.utils.parsedate(self.date) - @property def _rawAttachments(self): """ @@ -265,74 +186,6 @@ def _rawAttachments(self): """ return super().attachments - @property - def recipientSeparator(self) -> str: - return self.__recipientSeparator - - @property - def recipients(self) -> list: - """ - Returns a list of all recipients. - """ - try: - return self._recipients - except AttributeError: - # Get the recipients - recipientDirs = [] - prefixLen = self.prefixLen - for dir_ in self.listDir(): - if dir_[prefixLen].startswith('__recip') and\ - dir_[prefixLen] not in recipientDirs: - recipientDirs.append(dir_[prefixLen]) - - self._recipients = [] - - for recipientDir in recipientDirs: - self._recipients.append(Recipient(recipientDir, self)) - - return self._recipients - - @property - def rtfBody(self) -> bytes: - """ - Returns the decompressed Rtf body from the message. - """ - try: - return self._rtfBody - except AttributeError: - self._rtfBody = compressed_rtf.decompress(self.compressedRtf) if self.compressedRtf else None - return self._rtfBody - - @property - def sender(self) -> str: - """ - Returns the message sender, if it exists. - """ - try: - return self._sender - except AttributeError: - # Check header first - if self.headerInit(): - headerResult = self.header['from'] - if headerResult is not None: - self._sender = headerResult - return headerResult - logger.info('Header found, but "sender" is not included. Will be generated from other streams.') - # Extract from other fields - text = self._getStringStream('__substg1.0_0C1A') - email = self._getStringStream('__substg1.0_5D01') - # Will not give an email address sometimes. Seems to exclude the email address if YOU are the sender. - result = None - if text is None: - result = email - else: - result = text - if email is not None: - result += ' <' + email + '>' - - self._sender = result - return result - @property def signedAttachmentClass(self): """ @@ -361,17 +214,3 @@ def signedHtmlBody(self): except AttributeError: self.attachments return self._signedHtmlBody - - @property - def subject(self): - """ - Returns the message subject, if it exists. - """ - return self._ensureSet('_subject', '__substg1.0_0037') - - @property - def to(self): - """ - Returns the to field, if it exists. - """ - return self._genRecipient('to', 1) diff --git a/extract_msg/msg.py b/extract_msg/msg.py index 838ebba6..142027d0 100644 --- a/extract_msg/msg.py +++ b/extract_msg/msg.py @@ -1,5 +1,6 @@ import codecs import copy +import datetime import logging import os import pathlib @@ -9,10 +10,10 @@ from . import constants from .attachment import Attachment, BrokenAttachment, UnsupportedAttachment -from .enums import AttachErrorBehavior, PropertiesType +from .enums import AttachErrorBehavior, Priority, PropertiesType, Sensitivity from .exceptions import InvalidFileFormatError, UnrecognizedMSGTypeError from .named import Named, NamedProperties -from .prop import FixedLengthProp, VariableLengthProp +from .prop import FixedLengthProp from .properties import Properties from .utils import divide, getEncodingName, hasLen, inputToMsgpath, inputToString, msgpathToString, parseType, properHex, verifyPropertyId, verifyType, windowsUnicode @@ -20,16 +21,18 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) + class MSGFile(olefile.OleFileIO): """ Parser for .msg files. """ + def __init__(self, path, **kwargs): """ :param path: path to the msg file in the system or is the raw msg file. - :param prefix: Used for extracting embeded msg files inside the main + :param prefix: Used for extracting embedded msg files inside the main one. Do not set manually unless you know what you are doing. - :param parentMsg: Used for syncronizing named properties instances. Do + :param parentMsg: Used for synchronizing named properties instances. Do not set this unless you know what you are doing. :param attachmentClass: Optional, the class the MSGFile object will use for attachments. You probably should @@ -103,8 +106,8 @@ def __init__(self, path, **kwargs): kwargsCopy = copy.copy(kwargs) if 'prefix' in kwargsCopy: del kwargsCopy['prefix'] - if 'parent' in kwargsCopy: - del kwargsCopy['parent'] + if 'parentMsg' in kwargsCopy: + del kwargsCopy['parentMsg'] self.__kwargs = kwargsCopy prefixl = [] @@ -141,13 +144,21 @@ def __init__(self, path, **kwargs): else: self.filename = None - def _ensureSet(self, variable : str, streamID, stringStream : bool = True): + def _ensureSet(self, variable : str, streamID, stringStream : bool = True, **kwargs): """ Ensures that the variable exists, otherwise will set it using the specified stream. After that, return said variable. If the specified stream is not a string stream, make sure to set :param stringStream: to False. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) @@ -156,25 +167,51 @@ def _ensureSet(self, variable : str, streamID, stringStream : bool = True): value = self._getStringStream(streamID) else: value = self._getStream(streamID) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetNamed(self, variable : str, propertyName): + def _ensureSetNamed(self, variable : str, propertyName, **kwargs): """ Ensures that the variable exists, otherwise will set it using the named property. After that, return said variable. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) except AttributeError: value = self.namedProperties.get(propertyName) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetProperty(self, variable : str, propertyName): + def _ensureSetProperty(self, variable : str, propertyName, **kwargs): """ Ensures that the variable exists, otherwise will set it using the property. After that, return said variable. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) @@ -183,19 +220,37 @@ def _ensureSetProperty(self, variable : str, propertyName): value = self.mainProperties[propertyName].value except (KeyError, AttributeError): value = None + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetTyped(self, variable : str, _id): + def _ensureSetTyped(self, variable : str, _id, **kwargs): """ Like the other ensure set functions, but designed for when something could be multiple types (where only one will be present). This way you have no need to set the type, it will be handled for you. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) except AttributeError: value = self._getTypedData(_id) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value @@ -557,6 +612,14 @@ def attachmentsReady(self) -> bool: """ return self.__attachmentsReady + @property + def classified(self) -> bool: + """ + Indicates whether the contents of this message are regarded as + classified information. + """ + return self._ensureSetNamed('_classified', '85B5') + @property def classType(self) -> str: """ @@ -564,6 +627,35 @@ def classType(self) -> str: """ return self._ensureSet('_classType', '__substg1.0_001A') + @property + def commonEnd(self) -> datetime.datetime: + """ + The end time for the object. + """ + return self._ensureSetNamed('_commonEnd', '8517') + + @property + def commonStart(self) -> datetime.datetime: + """ + The start time for the object. + """ + return self._ensureSetNamed('_commonStart', '8516') + + @property + def currentVersion(self) -> int: + """ + Specifies the build number of the client application that sent the + message. + """ + return self._ensureSetNamed('_currentVersion', '8552') + + @property + def currentVersionName(self) -> str: + """ + Specifies the name of the client application that sent the message. + """ + return self._ensureSetNamed('_currentVersionName', '8554') + @property def importance(self) -> int: """ @@ -654,18 +746,18 @@ def prefixList(self): return copy.deepcopy(self.__prefixList) @property - def priority(self) -> int: + def priority(self) -> Priority: """ The specified priority of the msg file. """ - return self._ensureSetProperty('_priority', '00260003') + return self._ensureSetProperty('_priority', '00260003', overrideClass = Priority) @property - def sensitivity(self) -> int: + def sensitivity(self) -> Sensitivity: """ The specified sensitivity of the msg file. """ - return self._ensureSetProperty('_sensitivity', '00360003') + return self._ensureSetProperty('_sensitivity', '00360003', overrideClass = Sensitivity) @property def stringEncoding(self): diff --git a/extract_msg/named.py b/extract_msg/named.py index ee3a12de..91fc3be2 100644 --- a/extract_msg/named.py +++ b/extract_msg/named.py @@ -7,9 +7,11 @@ from .utils import bytesToGuid, divide, properHex, roundUp from compressed_rtf.crc32 import crc32 + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) + class Named: __dir = '__nameid_version1.0' def __init__(self, msg): @@ -61,9 +63,7 @@ def __init__(self, msg): for entry in entries: streamID = properHex(0x8000 + entry['pid']) - #msg._registerNamedProperty(entry, entry['pkind'], names[entry['id']] if entry['pkind'] == NamedPropertyType.STRING_NAMED else None) - if msg.existsTypedProperty(streamID): - self.__properties.append(StringNamedProperty(entry, names[entry['id']]) if entry['pkind'] == NamedPropertyType.STRING_NAMED else NumericalNamedProperty(entry)) + self.__properties.append(StringNamedProperty(entry, names[entry['id']]) if entry['pkind'] == NamedPropertyType.STRING_NAMED else NumericalNamedProperty(entry)) for property in self.__properties: self.__propertiesDict[property.name if isinstance(property, StringNamedProperty) else property.propertyID] = property @@ -71,6 +71,12 @@ def __init__(self, msg): def __getitem__(self, key): return self.__propertiesDict[key] + def __iter__(self): + return self.__propertiesDict.__iter__() + + def __len__(self): + return self.__propertiesDict.__len__() + def _getStream(self, filename, prefix = True): return self.__msg._getStream([self.__dir, filename], prefix = prefix) @@ -102,20 +108,26 @@ def get(self, propertyName, default = None): if not found. """ try: - return self.namedProperties[propertyName] + return self.__propertiesDict[propertyName] except KeyError: propertyName = propertyName.upper() - for key in self.namedProperties.keys(): + for key in self.__propertiesDict.keys(): if propertyName == key.upper(): - return self.namedProperties[key] + return self.__propertiesDict[key] return default + def keys(self): + return self.__propertiesDict.keys() + def pprintKeys(self): """ Uses the pprint function on a sorted list of keys. """ pprint.pprint(sorted(tuple(self.__propertiesDict.keys()))) + def values(self): + return self.__propertiesDict.values() + @property def dir(self): """ @@ -138,6 +150,7 @@ def namedProperties(self): return copy.deepcopy(self.__propertiesDict) + class NamedProperties: """ An instance that uses a Named instance and an extract-msg class to read the diff --git a/extract_msg/properties.py b/extract_msg/properties.py index 486494c8..ad8bf7c1 100644 --- a/extract_msg/properties.py +++ b/extract_msg/properties.py @@ -5,7 +5,8 @@ from . import constants from .enums import Intelligence, PropertiesType from .prop import createProp -from .utils import divide, fromTimeStamp, filetimeToUtc, properHex +from .utils import divide, properHex + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -106,7 +107,6 @@ def pprintKeys(self): """ pprint.pprint(sorted(tuple(self.__props.keys()))) - def values(self): return self.__props.values() diff --git a/extract_msg/recipient.py b/extract_msg/recipient.py index 42ac5a5f..98b5b5f1 100644 --- a/extract_msg/recipient.py +++ b/extract_msg/recipient.py @@ -1,8 +1,8 @@ import logging -from . import constants from .data import PermanentEntryID from .enums import PropertiesType, RecipientType +from .prop import FixedLengthProp from .properties import Properties from .utils import verifyPropertyId, verifyType @@ -15,8 +15,9 @@ class Recipient: """ Contains the data of one of the recipients in an msg file. """ + def __init__(self, _dir, msg): - self.__msg = msg # Allows calls to original msg file. + self.__msg = msg # Allows calls to original msg file. self.__dir = _dir self.__props = Properties(self._getStream('__properties_version1.0'), PropertiesType.RECIPIENT) self.__email = self._getStringStream('__substg1.0_39FE') @@ -27,13 +28,21 @@ def __init__(self, _dir, msg): self.__type = RecipientType(0xF & self.__typeFlags) self.__formatted = f'{self.__name} <{self.__email}>' - def _ensureSet(self, variable, streamID, stringStream : bool = True): + def _ensureSet(self, variable, streamID, stringStream : bool = True, **kwargs): """ Ensures that the variable exists, otherwise will set it using the specified stream. After that, return said variable. If the specified stream is not a string stream, make sure to set :param string stream: to False. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) @@ -42,13 +51,26 @@ def _ensureSet(self, variable, streamID, stringStream : bool = True): value = self._getStringStream(streamID) else: value = self._getStream(streamID) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetProperty(self, variable : str, propertyName : str): + def _ensureSetProperty(self, variable : str, propertyName : str, **kwargs): """ Ensures that the variable exists, otherwise will set it using the property. After that, return said variable. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) @@ -57,23 +79,47 @@ def _ensureSetProperty(self, variable : str, propertyName : str): value = self.props[propertyName].value except (KeyError, AttributeError): value = None + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value - def _ensureSetTyped(self, variable : str, _id): + def _ensureSetTyped(self, variable : str, _id, **kwargs): """ Like the other ensure set functions, but designed for when something could be multiple types (where only one will be present). This way you have no need to set the type, it will be handled for you. + + :param overrideClass: Class/function to use to morph the data that was + read. The data will be the first argument to the class's __init__ + function or the function itself, if that is what is provided. By + default, this will be completely ignored if the value was not found. + :param preserveNone: If true (default), causes the function to ignore + :param overrideClass: when the value could not be found (is None). + If this is changed to False, then the value will be used regardless. """ try: return getattr(self, variable) except AttributeError: value = self._getTypedData(_id) + # Check if we should be overriding the data type for this instance. + if kwargs: + overrideClass = kwargs.get('overrideClass') + if overrideClass is not None and (value is not None or not kwargs.get('preserveNone', True)): + value = overrideClass(value) setattr(self, variable, value) return value def _getStream(self, filename): + """ + Gets a binary representation of the requested filename. + + This should ALWAYS return a bytes object if it was found, otherwise + returns None. + """ return self.__msg._getStream([self.__dir, filename]) def _getStringStream(self, filename): diff --git a/extract_msg/signed_attachment.py b/extract_msg/signed_attachment.py index 2e509dd1..caa09328 100644 --- a/extract_msg/signed_attachment.py +++ b/extract_msg/signed_attachment.py @@ -1,16 +1,11 @@ import logging import os import pathlib -import random -import string import zipfile -from . import constants -from .attachment_base import AttachmentBase from .enums import AttachmentType -from .prop import FixedLengthProp, VariableLengthProp -from .properties import Properties -from .utils import createZipOpen, inputToString, openMsg, prepareFilename, verifyPropertyId, verifyType +from .utils import createZipOpen, inputToString, prepareFilename + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/extract_msg/task.py b/extract_msg/task.py new file mode 100644 index 00000000..70958a9b --- /dev/null +++ b/extract_msg/task.py @@ -0,0 +1,165 @@ +import datetime +import logging + +from .enums import TaskAcceptance, TaskHistory, TaskMode, TaskOwnership, TaskStatus +from .message_base import MessageBase + + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class Task(MessageBase): + """ + Class used for parsing task files. + """ + + def __init__(self, path, **kwargs): + super().__init__(path, **kwargs) + + @property + def percentComplete(self) -> float: + """ + Indicates whether a time-flagged Message object is complete. Returns a + percentage in decimal form. 1.0 indicates it is complete. + """ + return self._ensureSetNamed('_percentComplete', '8102') + + @property + def taskAcceptanceState(self) -> TaskAcceptance: + """ + Indicates the acceptance state of the task. + """ + return self._ensureSetNamed('_percentComplete', '812A', overrideClass = TaskAcceptance) + + @property + def taskActualEffort(self) -> int: + """ + Indicates the number of minutes that the user actually spent working on + a task. + """ + return self._ensureSetNamed('_taskActualEffort', '8110') + + @property + def taskAssigner(self) -> str: + """ + Specifies the name of the user that last assigned the task. + """ + return self._ensureSetNamed('_taskAssigner', '811F') + + @property + def taskComplete(self) -> bool: + """ + Indicates if the task is complete. + """ + return self._ensureSetNamed('_taskComplete', '811C') + + @property + def taskCustomFlags(self) -> int: + """ + Custom flags set on the task. + """ + return self._ensureSetNamed('_taskCustomFlags', '8139') + + @property + def taskDueDate(self) -> datetime.datetime: + """ + Specifies the date by which the user expects work on the task to be + complete. + """ + return self._ensureSetNamed('_taskStartDate', '8105') + + @property + def taskEstimatedEffort(self) -> int: + """ + Indicates the number of minutes that the user expects to work on a task. + """ + return self._ensureSetNamed('_taskEstimatedEffort', '8111') + + @property + def taskFRecurring(self) -> bool: + """ + Indicates whether the task includes a recurrence pattern. + """ + return self._ensureSetNamed('_taskFRecurring', '8126') + + @property + def taskHistory(self) -> TaskHistory: + """ + Indicates the type of change that was last made to the Task object. + """ + return self._ensureSetNamed('_taskHistory', '811A', overrideClass = TaskHistory) + + @property + def taskLastDelegate(self) -> str: + """ + Contains the name of the user who most recently assigned the task, or + the user to whom it was most recently assigned. + """ + return self._ensureSetNamed('_taskLastDelegate', '8125') + + @property + def taskLastUser(self) -> str: + """ + Contains the name of the most recent user to have been the owner of the + task. + """ + return self._ensureSetNamed('_taskLastUser', '8122') + + @property + def taskMode(self) -> TaskMode: + """ + Used in a task communication. Should be 0 (UNASSIGNED) on task objects. + """ + return self._ensureSetNamed('_taskMode', '8518', overrideClass = TaskMode) + + @property + def taskOwner(self) -> str: + """ + Contains the name of the owner of the task. + """ + return self._ensureSetNamed('_taskOwner', '811F') + + @property + def taskOwnership(self) -> TaskOwnership: + """ + Contains the name of the owner of the task. + """ + return self._ensureSetNamed('_taskOwnership', '8129', overrideClass = TaskOwnership) + + @property + def taskStartDate(self) -> datetime.datetime: + """ + Specifies the date on which the user expects work on the task to begin. + """ + return self._ensureSetNamed('_taskStartDate', '8104') + + @property + def taskStatus(self) -> TaskStatus: + """ + The completion status of a task. + """ + return self._ensureSetNamed('_taskStatus', '8101', overrideClass = TaskStatus) + + @property + def taskStatusOnComplete(self) -> bool: + """ + Indicates whether the task assignee has been requested to send an email + message upon completion of the assigned task. + """ + return self._ensureSetNamed('_taskStatusOnComplete', '8119') + + @property + def taskUpdates(self) -> bool: + """ + Indicates whether the task assignee has been requested to send a task + update when the assigned Task object changes. + """ + return self._ensureSetNamed('_taskUpdates', '811B') + + @property + def taskVersion(self) -> int: + """ + Indicates which copy is the latest update of a Task object. + """ + return self._ensureSetNamed('_taskVersion', '8113') diff --git a/extract_msg/utils.py b/extract_msg/utils.py index 49af8cbf..7ec6d2d8 100644 --- a/extract_msg/utils.py +++ b/extract_msg/utils.py @@ -598,6 +598,7 @@ def openMsg(path, **kwargs): from .message import Message from .msg import MSGFile from .message_signed import MessageSigned + 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. @@ -614,6 +615,9 @@ def openMsg(path, **kwargs): elif classType.startswith('ipm.appointment') or classType.startswith('ipm.schedule'): msg.close() return Appointment(path, **kwargs) + elif classType.startswith('ipm.task'): + msg.close() + return Task(path, **kwargs) elif classType == 'ipm': # Unspecified format. It should be equal to this and not just start with it. return msg elif kwargs.get('strict', True):