From 87601f256a5783a8b9b5ac81a268f0f04982f65f Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 08:59:29 -0700 Subject: [PATCH 1/9] Start implementation of MSGPath, fix existsTypedProperty --- CHANGELOG.md | 5 +++ extract_msg/__init__.py | 3 ++ extract_msg/constants/__init__.py | 3 +- extract_msg/msg_classes/msg.py | 59 +++++++++++++------------------ extract_msg/msg_path.py | 59 +++++++++++++++++++++++++++++++ setup.py | 4 +-- 6 files changed, 96 insertions(+), 37 deletions(-) create mode 100644 extract_msg/msg_path.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 994cdba7..1d6f954b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +**v0.58.0** +* Changed module to primarily use `MSGPath` for pathing. All functions still except string paths or list paths, they now just also accept `MSGPath` and use it under the hood for path conversions. With this, some functions have been changed to specifically return `MSGPath` rather than list and string versions of paths. Removed: `MSGBase.prefixList`. Modified: `MSGBase.fixPath`, `MSGBase.listDir()`, `MSGBase.prefix`. +* Changed setup to use `find_packages()` to try and get ahead of deprecation and ensure all files are distributed correctly. +* Fixed `MSGFile.existsTypedProperty()` to actually do what it says it will do. Previous code had it not checking if the right directory was being searched when `location` was specified. + **v0.57.1** * [[TeamMsgExtractor #489](https://github.com/TeamMsgExtractor/msg-extractor/issues/489)] Fix an issue where `MessageSignedBase.body` would raise an error when the plain text body was not utf-8 encoded bytes. This error was due to `extract_msg.utils.unwrapMultipart()` accidentally returning the plain text body as bytes instead of string. * Change some of the command line documentation. diff --git a/extract_msg/__init__.py b/extract_msg/__init__.py index 8bbb2290..213ceff6 100644 --- a/extract_msg/__init__.py +++ b/extract_msg/__init__.py @@ -37,6 +37,7 @@ 'enums', 'exceptions', 'msg_classes', + 'msg_path', 'open_msg', 'null_date', 'properties', @@ -48,6 +49,7 @@ 'AttachmentBase', 'Message', 'MSGFile', + 'MSGPath', 'Named', 'NamedProperties', 'OleWriter', @@ -68,6 +70,7 @@ from . import attachments, msg_classes, null_date, properties, structures from .attachments import Attachment, AttachmentBase, SignedAttachment from .msg_classes import Message, MSGFile +from .msg_path import MSGPath from .ole_writer import OleWriter from .open_msg import openMsg, openMsgBulk from .properties import Named, NamedProperties, PropertiesStore diff --git a/extract_msg/constants/__init__.py b/extract_msg/constants/__init__.py index d4b4fb94..8df6c7b0 100644 --- a/extract_msg/constants/__init__.py +++ b/extract_msg/constants/__init__.py @@ -50,6 +50,7 @@ from . import ps, re, st from ..enums import SaveType +from ..msg_path import MSGPath from ..null_date import NullDate @@ -66,7 +67,7 @@ # Basically a dict of HEADER_FORMAT_TYPE and dicts containing them. HEADER_FORMAT_TYPE = Optional[Dict[str, Union[HEADER_FORMAT_VALUE_TYPE, Dict[str, HEADER_FORMAT_VALUE_TYPE]]]] SAVE_TYPE = Tuple[SaveType, Union[List[str], str, None]] -MSG_PATH = Union[str, List[str], Tuple[str, ...]] +MSG_PATH = Union[MSGPath, str, List[str], Tuple[str, ...]] # Type used for the getXAs methods' overrideClass argument. OVERRIDE_CLASS = Union[Type[_T], Callable[[Any], _T]] diff --git a/extract_msg/msg_classes/msg.py b/extract_msg/msg_classes/msg.py index 402912c5..c306c9f6 100644 --- a/extract_msg/msg_classes/msg.py +++ b/extract_msg/msg_classes/msg.py @@ -40,6 +40,7 @@ ConversionError, InvalidFileFormatError, PrefixError, StandardViolationError ) +from ..msg_path import MSGPath from ..properties.named import Named, NamedProperties from ..properties.properties_store import PropertiesStore from ..structures.contact_link_entry import ContactLinkEntry @@ -115,7 +116,7 @@ def __init__(self, path, **kwargs): """ # Retrieve all the kwargs that we need. self.__inscFeat: InsecureFeatures = kwargs.get('insecureFeatures', InsecureFeatures.NONE) - prefix: str = cast(str, kwargs.get('prefix', '')) + prefix: MSGPath = MSGPath(kwargs.get('prefix', '')) self.__parentMsg = makeWeakRef(cast(MSGFile, kwargs.get('parentMsg'))) self.__treePath = kwargs.get('treePath', []) + [weakref.ref(self)] # Verify it is a valid class. @@ -133,7 +134,7 @@ def __init__(self, path, **kwargs): self.__dateFormat = kwargs.get('dateFormat', DATE_FORMAT) self.__dtFormat = kwargs.get('datetimeFormat', DT_FORMAT) - self.__listDirRes: Dict[Tuple[bool, bool, bool], Tuple[Tuple[str, ...], ...]] = {} + self.__listDirRes: Dict[Tuple[bool, bool, bool], Tuple[MSGPath, ...]] = {} self.__existsCache = None @@ -181,16 +182,13 @@ def __init__(self, path, **kwargs): del kwargsCopy['treePath'] self.__kwargs = kwargsCopy - prefixl = [] if prefix: try: - prefixl = inputToMsgPath(prefix) - prefix = '/'.join(prefixl) + '/' + prefix = MSGPath(prefix) except ConversionError: raise PrefixError(f'The provided prefix could not be used: {prefix}') self.__prefix = prefix - self.__prefixList = prefixl - self.__prefixLen = len(prefixl) + self.__prefixLen = len(prefix.parts) if overrideEncoding is not None: logger.warning('You have chosen to override the string encoding. Do not report encoding errors caused by this.') @@ -216,10 +214,10 @@ def __init__(self, path, **kwargs): # dumb and just generate it *manually*, grab what we need, # and them immediately close it. # # We don't need anything more advanced than MSGFile. - msg = MSGFile(path, prefix = prefixl[:-2], delayAttachments = True) + msg = MSGFile(path, prefix = prefix, delayAttachments = True) parentNeedsClose = True # Now that we know we have the parent, grab the stream. - filename = msg.getStringStream(prefixl[:-1] + ['__substg1.0_3001'], prefix = False) + filename = msg.getStringStream(prefix / '__substg1.0_3001', prefix = False) # Now if we opened the parent, close it. if parentNeedsClose: msg.close() @@ -271,7 +269,7 @@ def _getOleEntry(self, filename: MSG_PATH, prefix: bool = True) -> olefile.olefi sid = -1 if filename == '/': if prefix and self.__prefix: - sid = self.__ole._find(self.__prefixList) + sid = self.__ole._find(str(self.__prefix)) else: return self.__ole.direntries[0] else: @@ -487,18 +485,20 @@ def existsTypedProperty(self, _id: str, location = None, _type = None, prefix: b _id = _id.upper() if propertiesInstance is None: propertiesInstance = self.props - prefixList = self.prefixList if prefix else [] + prefixPath = self.prefix if prefix else MSGPath() if location is not None: - prefixList.append(location) - prefixList = inputToMsgPath(prefixList) + prefixPath /= location + + prefixLen = len(prefixPath) + usableId = _id + _type if _type else _id foundNumber = 0 foundStreams = [] for item in self.listDir(): - if len(item) > self.__prefixLen: - if item[self.__prefixLen].startswith('__substg1.0_' + usableId) and item[self.__prefixLen] not in foundStreams: + if item.is_relative_to(prefixPath): + if item[prefixLen].startswith('__substg1.0_' + usableId) and item[prefixLen] not in foundStreams: foundNumber += 1 - foundStreams.append(item[self.__prefixLen]) + foundStreams.append(item[prefixLen]) for x in propertiesInstance: if x.startswith(usableId): for y in foundStreams: @@ -543,14 +543,14 @@ def exportBytes(self, allowBadEmbed: bool = False) -> bytes: self.export(out, allowBadEmbed) return out.getvalue() - def fixPath(self, inp: MSG_PATH, prefix: bool = True) -> str: + def fixPath(self, inp: MSG_PATH, prefix: bool = True) -> MSGPath: """ Changes paths so that they have the proper prefix (should :param prefix: be ``True``) and are strings rather than lists or tuples. """ - inp = msgPathToString(inp) + inp = MSGPath(inp) if prefix: - inp = self.__prefix + inp + inp = self.__prefix / inp return inp def getMultipleBinary(self, filename: MSG_PATH, prefix: bool = True) -> Optional[List[bytes]]: @@ -565,7 +565,7 @@ def getMultipleBinary(self, filename: MSG_PATH, prefix: bool = True) -> Optional MSG file (``False``) or look in the current child MSG file (``True``). (Default: ``True``) """ - filename = self.fixPath(filename, prefix) + '1102' + filename = self.fixPath(filename, prefix).add('1102') multStream = self.getStream(filename) if multStream is None: return None @@ -608,7 +608,7 @@ def getMultipleString(self, filename: MSG_PATH, prefix: bool = True) -> Optional MSG file (``False``) or look in the current child MSG file (``True``). (Default: ``True``) """ - filename = self.fixPath(filename, prefix) + '101F' if self.areStringsUnicode else '101E' + filename = self.fixPath(filename, prefix).add('101F' if self.areStringsUnicode else '101E') multStream = self.getStream(filename) if multStream is None: return [] @@ -715,7 +715,7 @@ def getSingleOrMultipleBinary(self, filename: MSG_PATH, prefix: bool = True) -> """ filename = self.fixPath(filename, prefix) # Check for a single binary stream first. - if (ret := self.getStream(filename + '0102', False)) is not None: + if (ret := self.getStream(filename.add('0102'), False)) is not None: return ret # Otherwise, we just let the return from `getMultipleBinary` do the # work. @@ -824,7 +824,7 @@ class if it is found. return value - def listDir(self, streams: bool = True, storages: bool = False, includePrefix: bool = True) -> Tuple[Tuple[str, ...], ...]: + def listDir(self, streams: bool = True, storages: bool = False, includePrefix: bool = True) -> Tuple[MSGPath, ...]: """ Replacement for ``OleFileIO.listdir`` that runs at the current prefix directory. @@ -838,7 +838,7 @@ def listDir(self, streams: bool = True, storages: bool = False, includePrefix: b except KeyError: entries = self.__ole.listdir(streams, storages) if not self.__prefix: - entries = tuple(tuple(x) for x in entries) + entries = tuple(MSGPath(*x) for x in entries) self.__listDirRes[(streams, storages, includePrefix)] = entries return entries prefix = self.__prefix.split('/') @@ -858,7 +858,7 @@ def slistDir(self, streams: bool = True, storages: bool = False, includePrefix: Replacement for OleFileIO.listdir that runs at the current prefix directory. Returns a list of strings instead of lists. """ - return tuple(msgPathToString(x) for x in self.listDir(streams, storages, includePrefix)) + return tuple(str(x) for x in self.listDir(streams, storages, includePrefix)) def save(self, **kwargs) -> SAVE_TYPE: if kwargs.get('skipNotImplemented', False): @@ -1154,15 +1154,6 @@ def prefixLen(self) -> int: """ return self.__prefixLen - @property - def prefixList(self) -> List[str]: - """ - The prefix list of the Message instance. - - Intended for developer use. - """ - return copy.deepcopy(self.__prefixList) - @functools.cached_property def priority(self) -> Optional[Priority]: """ diff --git a/extract_msg/msg_path.py b/extract_msg/msg_path.py new file mode 100644 index 00000000..2c651a44 --- /dev/null +++ b/extract_msg/msg_path.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +__all__ = [ + 'MSGPath', +] + +from pathlib import PurePosixPath +from typing import Any + + +class MSGPath(PurePosixPath): + def __new__(cls, *args: Any): + # Ensure we handle any old-style msg path types as arguments. + newArgs = [] + for item in args: + if isinstance(item, tuple) or isinstance(item, list): + newArgs += item + else: + newArgs.append(item) + + # We need to handle ensuring that the path is normalized. + # First, normalize the slashes. + path = str(PurePosixPath(*args)).replace('\\', '/') + # Check if the path starts with a / because if it does it needs to go. + if path.startswith('//'): + path = path[2:] + elif path.startswith('/'): + path = path[1:] + + # We also could remove a tailing slash but equality ends up the same so + # it's not considered important. + + return PurePosixPath.__new__(cls, path) + + def __bool__(self) -> bool: + return bool(self.parts) + + def __eq__(self, other): + return str(self).lower() == str(other).lower() + + def __getitem__(self, index: int) -> str: + return self.parts[index] + + def __len__(self) -> int: + return len(self.parts) + + def __str__(self) -> str: + # Implemented for safety to ensure consistency across versions. + ret = super().__str__() + if ret.endswith('/'): + return ret[:-1] + return ret + + # I would have used __add__ for this but that might obfuscate wrong usage. + def add(self, tail: str) -> MSGPath: + """ + Adds the tail to the last item in the path, without a separator. + """ + return self.__class__(str(self) + tail) \ No newline at end of file diff --git a/setup.py b/setup.py index a4ca2a9d..d1d680e1 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ import os -from setuptools import setup +from setuptools import find_packages, setup import re @@ -39,7 +39,7 @@ author='Destiny Peterson & Matthew Walker', author_email='arceusthe@gmail.com, mattgwwalker@gmail.com', license='GPL', - packages=[main_module], + packages=find_packages('.'), py_modules=[main_module], entry_points={ 'console_scripts': ['extract_msg = extract_msg.__main__:main',] From 8f84dffef33679e2c9a1add0b7e99c576fc03720 Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 09:20:37 -0700 Subject: [PATCH 2/9] Update to MSGPath and fix missing PROPERTIES constant --- CHANGELOG.md | 2 + extract_msg/constants/__init__.py | 217 ++++++++++++++++++++++++++++++ extract_msg/msg_classes/msg.py | 28 ++-- 3 files changed, 229 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6f954b..2b4de811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ * Changed module to primarily use `MSGPath` for pathing. All functions still except string paths or list paths, they now just also accept `MSGPath` and use it under the hood for path conversions. With this, some functions have been changed to specifically return `MSGPath` rather than list and string versions of paths. Removed: `MSGBase.prefixList`. Modified: `MSGBase.fixPath`, `MSGBase.listDir()`, `MSGBase.prefix`. * Changed setup to use `find_packages()` to try and get ahead of deprecation and ensure all files are distributed correctly. * Fixed `MSGFile.existsTypedProperty()` to actually do what it says it will do. Previous code had it not checking if the right directory was being searched when `location` was specified. +* Changed `MSGFile.sExists()` to correctly detect *only* the valid string stream type for the MSG file instead of unconditionally checking both types. +* Readded `extract_msg.constants.PROPERTIES` which was removed for unknown reasons in an earlier version (0.42.0). `MSGBase.saveRaw()` appears to have been non-functional since then. **v0.57.1** * [[TeamMsgExtractor #489](https://github.com/TeamMsgExtractor/msg-extractor/issues/489)] Fix an issue where `MessageSignedBase.body` would raise an error when the plain text body was not utf-8 encoded bytes. This error was due to `extract_msg.utils.unwrapMultipart()` accidentally returning the plain text body as bytes instead of string. diff --git a/extract_msg/constants/__init__.py b/extract_msg/constants/__init__.py index 8df6c7b0..992682a8 100644 --- a/extract_msg/constants/__init__.py +++ b/extract_msg/constants/__init__.py @@ -33,6 +33,7 @@ 'MULTIPLE_8_BYTES_HEX', 'NEEDS_ARG', 'NULL_DATE', + 'PROPERTIES', 'PTYPES', 'PYTPFLOATINGTIME_START', 'REFUSED_CLASS_TYPES', @@ -293,4 +294,220 @@ 0x1102: 'PtypMultipleBinary', } +# This property information was sourced from +# http://www.fileformat.info/format/outlookmsg/index.htm +# on 2013-07-22. +# It was extended by The Elemental of Destruction on 2018-10-12. +PROPERTIES = { + '00010102': 'Template data', + '0002000B': 'Alternate recipient allowed', + '0004001F': 'Auto forward comment', + '00040102': 'Script data', + '0005000B': 'Auto forwarded', + '000F000F': 'Deferred delivery time', + '00100040': 'Deliver time', + '00150040': 'Expiry time', + '00170003': 'Importance', + '001A001F': 'Message class', + '0023001F': 'Originator delivery report requested', + '00250102': 'Parent key', + '00260003': 'Priority', + '0029000B': 'Read receipt requested', + '002A0040': 'Receipt time', + '002B000B': 'Recipient reassignment prohibited', + '002E0003': 'Original sensitivity', + '00300040': 'Reply time', + '00310102': 'Report tag', + '00320040': 'Report time', + '00360003': 'Sensitivity', + '0037001F': 'Subject', + '00390040': 'Client Submit Time', + '003A001F': '', + '003B0102': '', + '003D001F': 'Subject prefix', + '003F0102': '', + '0040001F': 'Received by name', + '00410102': '', + '0042001F': 'Sent repr name', + '00430102': '', + '0044001F': 'Rcvd repr name', + '00450102': '', + '0046001F': '', + '00470102': '', + '0049001F': '', + '004B001F': '', + '004C0102': '', + '004D001F': 'Org author name', + '004E0040': '', + '004F0102': '', + '0050001F': 'Reply rcipnt names', + '00510102': '', + '00520102': '', + '00530102': '', + '00540102': '', + '00550040': '', + '0057000B': '', + '0058000B': '', + '0059000B': '', + '005A001F': 'Org sender name', + '005B0102': '', + '005C0102': '', + '005D001F': '', + '005E0102': '', + '005F0102': '', + '00600040': '', + '00610040': '', + '00620003': '', + '0063000B': '', + '0064001F': 'Sent repr adrtype', + '0065001F': 'Sent repr email', + '0066001F': '', + '00670102': '', + '0068001F': '', + '0069001F': '', + '0070001F': 'Topic', + '00710102': '', + '0072001F': '', + '0073001F': '', + '0074001F': '', + '0075001F': 'Rcvd by adrtype', + '0076001F': 'Rcvd by email', + '0077001F': 'Repr adrtype', + '0078001F': 'Repr email', + '007D001F': 'Message header', + '007F0102': '', + '0080001F': '', + '0081001F': '', + '08070003': '', + '0809001F': '', + '0C040003': '', + '0C050003': '', + '0C06000B': '', + '0C08000B': '', + '0C150003': '', + '0C17000B': '', + '0C190102': '', + '0C1A001F': 'Sender name', + '0C1B001F': '', + '0C1D0102': '', + '0C1E001F': 'Sender adr type', + '0C1F001F': 'Sender email', + '0C200003': '', + '0C21001F': '', + '0E01000B': '', + '0E02001F': 'Display BCC', + '0E03001F': 'Display CC', + '0E04001F': 'Display To', + '0E060040': '', + '0E070003': '', + '0E080003': '', + '0E080014': '', + '0E090102': '', + '0E0F000B': '', + '0E12000D': '', + '0E13000D': '', + '0E170003': '', + '0E1B000B': '', + '0E1D001F': 'Subject (normalized)', + '0E1F000B': '', + '0E200003': '', + '0E210003': '', + '0E28001F': 'Recvd account1 (uncertain)', + '0E29001F': 'Recvd account2 (uncertain)', + '1000001F': 'Message body', + '1008': 'RTF sync body tag', # Where did this come from ??? It's not listed in the docs + '10090102': 'Compressed RTF body', + '1013001F': 'HTML body', + '1035001F': 'Message ID (uncertain)', + '1046001F': 'Sender email (uncertain)', + '3001001F': 'Display name', + '3002001F': 'Address type', + '3003001F': 'Email address', + '30070040': 'Creation date', + '39FE001F': '7-bit email (uncertain)', + '39FF001F': '7-bit display name', + + # Attachments (37xx) + '37010102': 'Attachment data', + '37020102': '', + '3703001F': 'Attachment extension', + '3704001F': 'Attachment short filename', + '37050003': 'Attachment attach method', + '3707001F': 'Attachment long filename', + '370E001F': 'Attachment mime tag', + '3712001F': 'Attachment ID (uncertain)', + + # Address book (3Axx): + '3A00001F': 'Account', + '3A02001F': 'Callback phone no', + '3A05001F': 'Generation', + '3A06001F': 'Given name', + '3A08001F': 'Business phone', + '3A09001F': 'Home phone', + '3A0A001F': 'Initials', + '3A0B001F': 'Keyword', + '3A0C001F': 'Language', + '3A0D001F': 'Location', + '3A11001F': 'Surname', + '3A15001F': 'Postal address', + '3A16001F': 'Company name', + '3A17001F': 'Title', + '3A18001F': 'Department', + '3A19001F': 'Office location', + '3A1A001F': 'Primary phone', + '3A1B101F': 'Business phone 2', + '3A1C001F': 'Mobile phone', + '3A1D001F': 'Radio phone no', + '3A1E001F': 'Car phone no', + '3A1F001F': 'Other phone', + '3A20001F': 'Transmit dispname', + '3A21001F': 'Pager', + '3A220102': 'User certificate', + '3A23001F': 'Primary Fax', + '3A24001F': 'Business Fax', + '3A25001F': 'Home Fax', + '3A26001F': 'Country', + '3A27001F': 'Locality', + '3A28001F': 'State/Province', + '3A29001F': 'Street address', + '3A2A001F': 'Postal Code', + '3A2B001F': 'Post Office Box', + '3A2C001F': 'Telex', + '3A2D001F': 'ISDN', + '3A2E001F': 'Assistant phone', + '3A2F001F': 'Home phone 2', + '3A30001F': 'Assistant', + '3A44001F': 'Middle name', + '3A45001F': 'Dispname prefix', + '3A46001F': 'Profession', + '3A47001F': '', + '3A48001F': 'Spouse name', + '3A4B001F': 'TTYTTD radio phone', + '3A4C001F': 'FTP site', + '3A4E001F': 'Manager name', + '3A4F001F': 'Nickname', + '3A51001F': 'Business homepage', + '3A57001F': 'Company main phone', + '3A58101F': 'Childrens names', + '3A59001F': 'Home City', + '3A5A001F': 'Home Country', + '3A5B001F': 'Home Postal Code', + '3A5C001F': 'Home State/Provnce', + '3A5D001F': 'Home Street', + '3A5F001F': 'Other adr City', + '3A60': 'Other adr Country', + '3A61': 'Other adr PostCode', + '3A62': 'Other adr Province', + '3A63': 'Other adr Street', + '3A64': 'Other adr PO box', + + '3FF7': 'Server (uncertain)', + '3FF8': 'Creator1 (uncertain)', + '3FFA': 'Creator2 (uncertain)', + '3FFC': 'To email (uncertain)', + '403D': 'To adrtype (uncertain)', + '403E': 'To email (uncertain)', + '5FF6': 'To (uncertain)', +} + # END CONSTANTS. diff --git a/extract_msg/msg_classes/msg.py b/extract_msg/msg_classes/msg.py index c306c9f6..17f7b5c7 100644 --- a/extract_msg/msg_classes/msg.py +++ b/extract_msg/msg_classes/msg.py @@ -371,8 +371,8 @@ def _getTypedStream(self, filename: MSG_PATH, prefix: bool = True, _type: Option """ verifyType(_type) filename = self.fixPath(filename, prefix) - for x in (filename + _type,) if _type is not None else self.slistDir(): - if x.startswith(filename) and '-' not in x: + for x in (filename.add(_type,)) if _type is not None else self.slistDir(): + if str(x).lower().startswith(str(filename).lower()) and '-' not in x: if (contents := self.getStream(x, False)) is None: continue if len(contents) == 0: @@ -431,7 +431,7 @@ def close(self) -> None: def debug(self) -> None: for dir_ in self.listDir(): if dir_[-1].endswith('001E') or dir_[-1].endswith('001F'): - print('Directory: ' + str(dir_[:-1])) + print('Directory: ' + str(dir_.parts[:-1])) print(f'Contents: {self.getStream(dir_)}') def exists(self, filename: MSG_PATH, prefix: bool = True) -> bool: @@ -446,12 +446,7 @@ def exists(self, filename: MSG_PATH, prefix: bool = True) -> bool: # Get the cache and ensure all names are lowercase. self.__existsCache = tuple(tuple(y.lower() for y in x) for x in self.listDir(True, True, True)) - filename = self.fixPath(filename, prefix).lower() - filename = filename.split('/') - if filename[-1] == '': - filename.pop() - - return tuple(filename) in self.__existsCache + return self.fixPath(filename, prefix) in self.__existsCache def sExists(self, filename: MSG_PATH, prefix: bool = True) -> bool: """ @@ -462,7 +457,7 @@ def sExists(self, filename: MSG_PATH, prefix: bool = True) -> bool: (``True``). (Default: ``True``) """ filename = self.fixPath(filename, prefix) - return self.exists(filename + '001F') or self.exists(filename + '001E') + return self.exists(filename.add('001F' if self.areStringsUnicode else '001E')) def existsTypedProperty(self, _id: str, location = None, _type = None, prefix: bool = True, propertiesInstance: Optional[PropertiesStore] = None) -> Tuple[bool, int]: """ @@ -799,9 +794,9 @@ def getStringStream(self, filename: MSG_PATH, prefix: bool = True) -> Optional[s """ filename = self.fixPath(filename, prefix) if self.areStringsUnicode: - tmp = self.getStream(filename + '001F', prefix = False) + tmp = self.getStream(filename.add('001F'), prefix = False) else: - tmp = self.getStream(filename + '001E', prefix = False) + tmp = self.getStream(filename.add('001E'), prefix = False) return None if tmp is None else tmp.decode(self.stringEncoding) @@ -841,14 +836,11 @@ def listDir(self, streams: bool = True, storages: bool = False, includePrefix: b entries = tuple(MSGPath(*x) for x in entries) self.__listDirRes[(streams, storages, includePrefix)] = entries return entries - prefix = self.__prefix.split('/') - if prefix[-1] == '': - prefix.pop() prefixLength = self.__prefixLen - entries = tuple(tuple(x) for x in entries if len(x) > prefixLength and x[:prefixLength] == prefix) + entries = tuple(MSGPath(x) for x in entries if len(x) > prefixLength and MSGPath(x).is_relative_to(self.prefix)) if not includePrefix: - entries = tuple(x[prefixLength:] for x in entries) + entries = tuple(x.relative_to(self.prefix) for x in entries) self.__listDirRes[(streams, storages, includePrefix)] = entries return entries @@ -889,7 +881,7 @@ def saveRaw(self, path) -> None: with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as zfile: # Loop through all the directories for dir_ in self.listDir(): - sysdir = '/'.join(dir_) + sysdir = str(dir_) code = dir_[-1][-8:] if constants.PROPERTIES.get(code): sysdir += ' - ' + constants.PROPERTIES[code] From ae5b5a8dd60ec922a54dd4464b2f044339411cbf Mon Sep 17 00:00:00 2001 From: Destiny Peterson Date: Wed, 28 Oct 2026 12:49:18 -0700 Subject: [PATCH 3/9] Update msg.py --- extract_msg/msg_classes/msg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extract_msg/msg_classes/msg.py b/extract_msg/msg_classes/msg.py index 17f7b5c7..c4b28791 100644 --- a/extract_msg/msg_classes/msg.py +++ b/extract_msg/msg_classes/msg.py @@ -444,7 +444,7 @@ def exists(self, filename: MSG_PATH, prefix: bool = True) -> bool: """ if not self.__existsCache: # Get the cache and ensure all names are lowercase. - self.__existsCache = tuple(tuple(y.lower() for y in x) for x in self.listDir(True, True, True)) + self.__existsCache = self.listDir(True, True, True) return self.fixPath(filename, prefix) in self.__existsCache From f299c415ea3e4105aa1950e0c20b82a37602535a Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 19:53:09 -0700 Subject: [PATCH 4/9] Finish implementation of MSGPath --- CHANGELOG.md | 2 +- extract_msg/attachments/attachment_base.py | 40 +++++++++---------- .../custom_att_handler/custom_handler.py | 5 +-- extract_msg/msg_classes/msg.py | 13 +++--- extract_msg/msg_path.py | 21 ++++++++-- extract_msg/ole_writer.py | 33 +++++++-------- extract_msg/properties/named.py | 9 +++-- extract_msg/recipient.py | 31 +++++++------- extract_msg/utils.py | 38 ------------------ extract_msg_tests/util_tests.py | 16 ++++---- 10 files changed, 94 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4de811..c5f40a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ **v0.58.0** -* Changed module to primarily use `MSGPath` for pathing. All functions still except string paths or list paths, they now just also accept `MSGPath` and use it under the hood for path conversions. With this, some functions have been changed to specifically return `MSGPath` rather than list and string versions of paths. Removed: `MSGBase.prefixList`. Modified: `MSGBase.fixPath`, `MSGBase.listDir()`, `MSGBase.prefix`. +* Changed module to primarily use `MSGPath` for pathing. All functions still except string paths or list paths, they now just also accept `MSGPath` and use it under the hood for path conversions. With this, some functions have been changed to specifically return `MSGPath` rather than list and string versions of paths. Removed: `MSGBase.prefixList`, `utils.inputToMsgPath()`, and `utils.msgPathToString()`. Modified: `*.fixPath()`, `*.listDir()`, and `MSGBase.prefix`. * Changed setup to use `find_packages()` to try and get ahead of deprecation and ensure all files are distributed correctly. * Fixed `MSGFile.existsTypedProperty()` to actually do what it says it will do. Previous code had it not checking if the right directory was being searched when `location` was specified. * Changed `MSGFile.sExists()` to correctly detect *only* the valid string stream type for the MSG file instead of unconditionally checking both types. diff --git a/extract_msg/attachments/attachment_base.py b/extract_msg/attachments/attachment_base.py index 18909719..e84ab43a 100644 --- a/extract_msg/attachments/attachment_base.py +++ b/extract_msg/attachments/attachment_base.py @@ -21,11 +21,10 @@ from ..constants import MSG_PATH, OVERRIDE_CLASS, SAVE_TYPE from ..enums import AttachmentType +from ..msg_path import MSGPath from ..properties.named import NamedProperties from ..properties.properties_store import PropertiesStore -from ..utils import ( - msgPathToString, tryGetMimetype, verifyPropertyId, verifyType - ) +from ..utils import tryGetMimetype, verifyPropertyId, verifyType # Allow for nice type checking. @@ -51,7 +50,7 @@ def __init__(self, msg: MSGFile, dir_: str, propStore: PropertiesStore): :param propStore: The PropertiesStore instance for the attachment. """ self.__msg = weakref.ref(msg) - self.__dir = dir_ + self.__dir = MSGPath(dir_) self.__props = propStore self.__namedProperties = NamedProperties(msg.named, self) self.__treePath = msg.treePath + [weakref.ref(self)] @@ -150,7 +149,7 @@ def _getTypedStream(self, filename: MSG_PATH, _type = None): """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg._getTypedStream([self.__dir, msgPathToString(filename)], True, _type) + return msg._getTypedStream(MSGPath(self.__dir, filename), True, _type) def _handleFnc(self, _zip, filename, customPath: pathlib.Path, kwargs) -> pathlib.Path: """ @@ -203,7 +202,7 @@ def exists(self, filename: MSG_PATH) -> bool: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.exists([self.__dir, msgPathToString(filename)]) + return msg.exists(MSGPath(self.__dir, filename)) def sExists(self, filename: MSG_PATH) -> bool: """ @@ -214,7 +213,7 @@ def sExists(self, filename: MSG_PATH) -> bool: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.sExists([self.__dir, msgPathToString(filename)]) + return msg.sExists(MSGPath(self.__dir, filename)) def existsTypedProperty(self, id, _type = None) -> Tuple[bool, int]: """ @@ -255,7 +254,7 @@ def getMultipleBinary(self, filename: MSG_PATH) -> Optional[List[bytes]]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.getMultipleBinary([self.__dir, msgPathToString(filename)]) + return msg.getMultipleBinary(MSGPath(self.__dir, filename)) def getMultipleString(self, filename: MSG_PATH) -> Optional[List[str]]: """ @@ -270,7 +269,7 @@ def getMultipleString(self, filename: MSG_PATH) -> Optional[List[str]]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.getMultipleString([self.__dir, msgPathToString(filename)]) + return msg.getMultipleString(MSGPath(self.__dir, filename)) def getNamedAs(self, propertyName: str, guid: str, overrideClass: OVERRIDE_CLASS[_T]) -> Optional[_T]: """ @@ -338,7 +337,7 @@ def getSingleOrMultipleBinary(self, filename: MSG_PATH) -> Optional[Union[List[b """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.getSingleOrMultipleBinary([self.__dir, msgPathToString(filename)]) + return msg.getSingleOrMultipleBinary(MSGPath(self.__dir, filename)) def getSingleOrMultipleString(self, filename: MSG_PATH) -> Optional[Union[List[str], str]]: """ @@ -356,7 +355,7 @@ def getSingleOrMultipleString(self, filename: MSG_PATH) -> Optional[Union[List[s """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.getSingleOrMultipleString([self.__dir, msgPathToString(filename)]) + return msg.getSingleOrMultipleString(MSGPath(self.__dir, filename)) def getStream(self, filename: MSG_PATH) -> Optional[bytes]: """ @@ -370,7 +369,7 @@ def getStream(self, filename: MSG_PATH) -> Optional[bytes]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.getStream([self.__dir, msgPathToString(filename)]) + return msg.getStream(MSGPath(self.__dir, filename)) def getStreamAs(self, streamID: MSG_PATH, overrideClass: OVERRIDE_CLASS[_T]) -> Optional[_T]: """ @@ -407,7 +406,7 @@ def getStringStream(self, filename: MSG_PATH) -> Optional[str]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return msg.getStringStream([self.__dir, msgPathToString(filename)]) + return msg.getStringStream(MSGPath(self.__dir, filename)) def getStringStreamAs(self, streamID: MSG_PATH, overrideClass: OVERRIDE_CLASS[_T]) -> Optional[_T]: """ @@ -428,7 +427,7 @@ class if it is found. return value - def listDir(self, streams: bool = True, storages: bool = False) -> List[List[str]]: + def listDir(self, streams: bool = True, storages: bool = False) -> Tuple[MSGPath, ...]: """ Lists the streams and/or storages that exist in the attachment directory. @@ -438,14 +437,15 @@ def listDir(self, streams: bool = True, storages: bool = False) -> List[List[str """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Attachment instance has been garbage collected.') - return [path[1:] for path in msg.listDir(streams, storages, False) - if len(path) > 1 and path[0] == self.__dir] + return tuple(path.relative_to(self.__dir) + for path in msg.listDir(streams, storages, False) + if path.is_relative_to(self.__dir)) - def slistDir(self, streams: bool = True, storages: bool = False) -> List[str]: + def slistDir(self, streams: bool = True, storages: bool = False) -> Tuple[str, ...]: """ Like listDir, except it returns the paths as strings. """ - return ['/'.join(path) for path in self.listDir(streams, storages)] + return Tuple(str(path) for path in self.listDir(streams, storages)) @abc.abstractmethod def save(self, **kwargs) -> SAVE_TYPE: @@ -512,9 +512,9 @@ def clsid(self) -> str: dataStream = None if self.exists('__substg1.0_3701000D'): - dataStream = [self.__dir, '__substg1.0_3701000D'] + dataStream = self.__dir / '__substg1.0_3701000D' elif self.exists('__substg1.0_37010102'): - dataStream = [self.__dir, '__substg1.0_37010102'] + dataStream = self.__dir / '__substg1.0_37010102' # If we found the right item, get the CLSID. if dataStream: diff --git a/extract_msg/attachments/custom_att_handler/custom_handler.py b/extract_msg/attachments/custom_att_handler/custom_handler.py index 813f2b69..e8a2a7c4 100644 --- a/extract_msg/attachments/custom_att_handler/custom_handler.py +++ b/extract_msg/attachments/custom_att_handler/custom_handler.py @@ -12,11 +12,10 @@ from typing import Dict, Optional, TYPE_CHECKING, TypeVar from ...constants import MSG_PATH, OVERRIDE_CLASS +from ...msg_path import MSGPath from ...structures.odt import ODTStruct from ...structures.ole_pres import OLEPresentationStream from ...structures.ole_stream_struct import OleStreamStruct -from ...utils import msgPathToString - if TYPE_CHECKING: from ..attachment_base import AttachmentBase @@ -38,7 +37,7 @@ def getStream(self, filename: MSG_PATH) -> Optional[bytes]: """ Gets a stream from the custom data directory. """ - return self.attachment.getStream('__substg1.0_3701000D/' + msgPathToString(filename)) + return self.attachment.getStream(MSGPath('__substg1.0_3701000D/', filename)) def getStreamAs(self, streamID: MSG_PATH, overrideClass: OVERRIDE_CLASS[_T]) -> Optional[_T]: """ diff --git a/extract_msg/msg_classes/msg.py b/extract_msg/msg_classes/msg.py index c4b28791..8e230608 100644 --- a/extract_msg/msg_classes/msg.py +++ b/extract_msg/msg_classes/msg.py @@ -45,8 +45,8 @@ from ..properties.properties_store import PropertiesStore from ..structures.contact_link_entry import ContactLinkEntry from ..utils import ( - divide, guessEncoding, inputToMsgPath, makeWeakRef, msgPathToString, - parseType, verifyPropertyId, verifyType + divide, guessEncoding, makeWeakRef, parseType, verifyPropertyId, + verifyType ) @@ -260,20 +260,21 @@ def _getOleEntry(self, filename: MSG_PATH, prefix: bool = True) -> olefile.olefi Finds the directory entry from the OLE file for the stream or storage specified. - Use ``'/'`` to get the root entry. + Use ``'/'`` or an empty path to get the root entry. :param prefix: Bool, whether to search for the entry at the root of the MSG file (``False``) or look in the current child MSG file (``True``). (Default: ``True``) """ + filename = MSGPath(filename) sid = -1 - if filename == '/': + if not filename: if prefix and self.__prefix: sid = self.__ole._find(str(self.__prefix)) else: return self.__ole.direntries[0] else: - sid = self.__ole._find(self.fixPath(filename, prefix)) + sid = self.__ole._find(str(self.fixPath(filename, prefix))) return self.__ole.direntries[sid] @@ -752,7 +753,7 @@ def getStream(self, filename: MSG_PATH, prefix: bool = True) -> Optional[bytes]: """ filename = self.fixPath(filename, prefix) if self.exists(filename, False): - with self.__ole.openstream(filename) as stream: + with self.__ole.openstream(str(filename)) as stream: return stream.read() or b'' else: logger.info(f'Stream "{filename}" was requested but could not be found. Returning `None`.') diff --git a/extract_msg/msg_path.py b/extract_msg/msg_path.py index 2c651a44..dc17480c 100644 --- a/extract_msg/msg_path.py +++ b/extract_msg/msg_path.py @@ -20,7 +20,7 @@ def __new__(cls, *args: Any): # We need to handle ensuring that the path is normalized. # First, normalize the slashes. - path = str(PurePosixPath(*args)).replace('\\', '/') + path = str(PurePosixPath(*newArgs)).replace('\\', '/') # Check if the path starts with a / because if it does it needs to go. if path.startswith('//'): path = path[2:] @@ -30,12 +30,27 @@ def __new__(cls, *args: Any): # We also could remove a tailing slash but equality ends up the same so # it's not considered important. - return PurePosixPath.__new__(cls, path) + # Now, we have the path as a string and are about to be done. However + # our second to last step is to check for invalid characters. If we + # find any, throw a hard error. + if '!' in path or ':' in path: + raise ValueError('Illegal character ("!" or ":") found in MSG path.') + + # Our last one will require us to analyze the parts of the path. As + # such, convert the path first then check. + pp = PurePosixPath.__new__(cls, path) + + # Make sure each segment is not more than 31 characters long. + if any(len(x) > 31 for x in pp.parts): + raise ValueError('Path segments must not be greater than 31 characters.') + + # All good. + return pp def __bool__(self) -> bool: return bool(self.parts) - def __eq__(self, other): + def __eq__(self, other) -> bool: return str(self).lower() == str(other).lower() def __getitem__(self, index: int) -> str: diff --git a/extract_msg/ole_writer.py b/extract_msg/ole_writer.py index 821e4415..669e6012 100644 --- a/extract_msg/ole_writer.py +++ b/extract_msg/ole_writer.py @@ -19,7 +19,8 @@ from .constants import MSG_PATH from .enums import Color, DirectoryEntryType from .exceptions import StandardViolationError, TooManySectorsError -from .utils import ceilDiv, dictGetCasedKey, inputToMsgPath +from .msg_path import MSGPath +from .utils import ceilDiv, dictGetCasedKey from olefile.olefile import OleDirectoryEntry, OleFileIO from red_black_dict_mod import RedBlackTree @@ -113,7 +114,7 @@ def __init__(self, rootClsid: bytes = constants.DEFAULT_CLSID): # In a future version, this will be setable as an optional argument. self.__version = 3 - def __getContainingStorage(self, path: List[str], entryExists: bool = True, create: bool = False) -> Dict: + def __getContainingStorage(self, path: MSGPath, entryExists: bool = True, create: bool = False) -> Dict: """ Finds the storage ``dict`` internally where the entry specified by :param path: would be created. @@ -139,19 +140,19 @@ def __getContainingStorage(self, path: List[str], entryExists: bool = True, crea raise ValueError(':param create: and :param entryExists: cannot both be True (an entry cannot exist if it is being created).') # Check that the path is not an internal entry. Given the validation on - # paths that most functions should do because of the call to - # inputToMsgPath, this shouldn't actually be necessary. + # paths that most functions should do because of the call to MSGPath, + # this shouldn't actually be necessary. if any(x.startswith('::') for x in path): raise ValueError('Found internal name in path.') _dir = self.__dirEntries - for index, name in enumerate(path[:-1]): + for index, name in enumerate(path.parts[:-1]): # If no entry in the current stream matches the path, raise an # OSError, *unless* the option to create storages is True. if name.lower() not in map(str.lower, _dir.keys()): if create: - self.addEntry(path[:index + 1], storage = True) + self.addEntry(MSGPath(path.parts[:index + 1]), storage = True) else: raise OSError(f'Entry not found: {name}') _dir = _dir[dictGetCasedKey(_dir, name)] @@ -166,7 +167,7 @@ def __getContainingStorage(self, path: List[str], entryExists: bool = True, crea return _dir - def __getEntry(self, path: List[str]) -> DirectoryEntry: + def __getEntry(self, path: MSGPath) -> DirectoryEntry: """ Finds and returns an existing ``DirectoryEntry`` instance in the writer. @@ -688,7 +689,7 @@ def addEntry(self, path: MSG_PATH, data: Optional[Union[bytes, SupportsBytes]] = :raises ValueError: Attempts to access an internal item. :raises ValueError: The data provided is too large. """ - path = inputToMsgPath(path) + path = MSGPath(path) # First, find the current place in our dict to add the item. _dir = self.__getContainingStorage(path, False, True) # Now, check that the item *is not* already in our dict, as that would @@ -715,7 +716,7 @@ def addOleEntry(self, path: MSG_PATH, entry: OleDirectoryEntry, data: Optional[U entry where one already exists under the same name. :raises ValueError: The data provided is too large. """ - path = inputToMsgPath(path) + path = MSGPath(path) # First, find the current place in our dict to add the item. _dir = self.__getContainingStorage(path, False) # Now, check that the item *is not* already in our dict, as that would @@ -764,7 +765,7 @@ def deleteEntry(self, path) -> None: is not the last was a stream. :raises ValueError: Attempted to delete an internal data stream. """ - path = inputToMsgPath(path) + path = MSGPath(path) # Get the containing storage for the entry. _dir = self.__getContainingStorage(path) @@ -799,7 +800,7 @@ def editEntry(self, path: MSG_PATH, **kwargs) -> None: parameter was invalid. """ # First, find our entry to edit. - entry = self.__getEntry(inputToMsgPath(path)) + entry = self.__getEntry(MSGPath(path)) # Send it to be modified using the arguments given. self.__modifyEntry(entry, **kwargs) @@ -882,7 +883,7 @@ def fromOleFile(self, ole: OleFileIO, rootPath: MSG_PATH = []) -> None: :raises OSError: If :param rootPath: does not exist in the file. """ - rootPath = inputToMsgPath(rootPath) + rootPath = MSGPath(rootPath) # Check if the root path is simply the top of the file. if rootPath == []: @@ -893,7 +894,7 @@ def fromOleFile(self, ole: OleFileIO, rootPath: MSG_PATH = []) -> None: # If it is not the top of the file, we need to do some filtering. # First get the CLSID from the entry the path points to. try: - entry = ole.direntries[ole._find(rootPath)] + entry = ole.direntries[ole._find(str(rootPath))] self.__rootEntry.clsid = _unClsid(entry.clsid) except OSError as e: @@ -931,7 +932,7 @@ def getEntry(self, path: MSG_PATH) -> DirectoryEntry: :raises OSError: If the entry does not exist. :raises ValueError: If access to an internal item is attempted. """ - return copy.copy(self.__getEntry(inputToMsgPath(path))) + return copy.copy(self.__getEntry(MSGPath(path))) def listItems(self, streams: bool = True, storages: bool = False) -> List[List[str]]: """ @@ -942,7 +943,7 @@ def listItems(self, streams: bool = True, storages: bool = False) -> List[List[s :param storages: If ``True``, includes the path for each storage in the list. """ - # We are actually abusing the walk function a bit here to life much + # We are actually abusing the walk function a bit here to make life much # easier. The way we do this is to look at the current directory that # the walk function is giving information about and then deciding what # parts of it we want to use. Once we have all the paths created, we @@ -982,7 +983,7 @@ def renameEntry(self, path: MSG_PATH, newName: str) -> None: raise ValueError('New name must be less than 32 characters.') # Get the storage for our entry. Entry *must* exist. - _dir = self.__getContainingStorage(inputToMsgPath(path)) + _dir = self.__getContainingStorage(MSGPath(path)) # See if an item in the storage already has that new name. if newName.lower() in map(str.lower, _dir.keys()): diff --git a/extract_msg/properties/named.py b/extract_msg/properties/named.py index 1a1ebd85..fb0a66b9 100644 --- a/extract_msg/properties/named.py +++ b/extract_msg/properties/named.py @@ -24,7 +24,8 @@ from .. import constants from ..enums import ErrorBehavior, NamedPropertyType from ..exceptions import InvalidPropertyIdError, StandardViolationError -from ..utils import bytesToGuid, divide, msgPathToString, verifyPropertyId +from ..msg_path import MSGPath +from ..utils import bytesToGuid, divide, verifyPropertyId from compressed_rtf.crc32 import crc32 @@ -44,7 +45,7 @@ class Named: Class for handling access to the named properties themselves. """ - __dir = '__nameid_version1.0' + __dir = MSGPath('__nameid_version1.0') def __init__(self, msg: MSGFile): self.__msg = weakref.ref(msg) @@ -177,7 +178,7 @@ def exists(self, filename: constants.MSG_PATH) -> bool: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Named instance has been garbage collected.') - return msg.exists([self.__dir, msgPathToString(filename)], False) + return msg.exists(MSGPath(self.__dir, filename), False) def get(self, propertyName: Tuple[str, str], default: _T = None) -> Union[NamedPropertyBase, _T]: """ @@ -234,7 +235,7 @@ def getStream(self, filename: constants.MSG_PATH) -> Optional[bytes]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Named instance has been garbage collected.') - return msg.getStream([self.__dir, msgPathToString(filename)], False) + return msg.getStream(MSGPath(self.__dir, filename), False) def items(self) -> Iterable[Tuple[Tuple[str, str], NamedPropertyBase]]: return self.__propertiesDict.items() diff --git a/extract_msg/recipient.py b/extract_msg/recipient.py index 1b80a6cb..39a55fe5 100644 --- a/extract_msg/recipient.py +++ b/extract_msg/recipient.py @@ -18,9 +18,9 @@ from .constants import MSG_PATH, OVERRIDE_CLASS from .enums import ErrorBehavior, PropertiesType from .exceptions import StandardViolationError +from .msg_path import MSGPath from .properties.properties_store import PropertiesStore from .structures.entry_id import PermanentEntryID -from .utils import msgPathToString if TYPE_CHECKING: @@ -40,7 +40,7 @@ class Recipient(Generic[_RT]): def __init__(self, _dir: str, msg: MSGFile, recipientTypeClass: Type[_RT]): self.__msg = weakref.ref(msg) # Allows calls to original msg file. - self.__dir = _dir + self.__dir = MSGPath(_dir) if not self.exists('__properties_version1.0'): if ErrorBehavior.STANDARDS_VIOLATION in msg.errorBehavior: logger.error('Recipients MUST have a property stream.') @@ -64,7 +64,7 @@ def exists(self, filename: MSG_PATH) -> bool: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.exists([self.__dir, msgPathToString(filename)]) + return msg.exists(MSGPath(self.__dir, filename)) def sExists(self, filename: MSG_PATH) -> bool: """ @@ -75,7 +75,7 @@ def sExists(self, filename: MSG_PATH) -> bool: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.sExists([self.__dir, msgPathToString(filename)]) + return msg.sExists(MSGPath(self.__dir, filename)) def existsTypedProperty(self, id, _type = None) -> Tuple[bool, int]: """ @@ -103,7 +103,7 @@ def getMultipleBinary(self, filename: MSG_PATH) -> Optional[List[bytes]]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.getMultipleBinary([self.__dir, msgPathToString(filename)]) + return msg.getMultipleBinary(MSGPath(self.__dir, filename)) def getMultipleString(self, filename: MSG_PATH) -> Optional[List[str]]: """ @@ -118,7 +118,7 @@ def getMultipleString(self, filename: MSG_PATH) -> Optional[List[str]]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.getMultipleString([self.__dir, msgPathToString(filename)]) + return msg.getMultipleString(MSGPath(self.__dir, filename)) def getPropertyAs(self, propertyName: Union[int, str], overrideClass: OVERRIDE_CLASS[_T]) -> Optional[_T]: """ @@ -162,7 +162,7 @@ def getSingleOrMultipleBinary(self, filename: MSG_PATH) -> Optional[Union[List[b """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.getSingleOrMultipleBinary([self.__dir, msgPathToString(filename)]) + return msg.getSingleOrMultipleBinary(MSGPath(self.__dir, filename)) def getSingleOrMultipleString(self, filename: MSG_PATH) -> Optional[Union[List[str], str]]: """ @@ -180,7 +180,7 @@ def getSingleOrMultipleString(self, filename: MSG_PATH) -> Optional[Union[List[s """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.getSingleOrMultipleString([self.__dir, msgPathToString(filename)]) + return msg.getSingleOrMultipleString(MSGPath(self.__dir, filename)) def getStream(self, filename: MSG_PATH) -> Optional[bytes]: """ @@ -194,7 +194,7 @@ def getStream(self, filename: MSG_PATH) -> Optional[bytes]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.getStream([self.__dir, msgPathToString(filename)]) + return msg.getStream(MSGPath(self.__dir, filename)) def getStreamAs(self, streamID: MSG_PATH, overrideClass: OVERRIDE_CLASS[_T]) -> Optional[_T]: """ @@ -231,7 +231,7 @@ def getStringStream(self, filename: MSG_PATH) -> Optional[str]: """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return msg.getStringStream([self.__dir, msgPathToString(filename)]) + return msg.getStringStream(MSGPath(self.__dir, filename)) def getStringStreamAs(self, streamID: MSG_PATH, overrideClass: OVERRIDE_CLASS[_T]) -> Optional[_T]: """ @@ -252,7 +252,7 @@ class if it is found. return value - def listDir(self, streams: bool = True, storages: bool = False) -> List[List[str]]: + def listDir(self, streams: bool = True, storages: bool = False) -> Tuple[MSGPath, ...]: """ Lists the streams and or storages that exist in the recipient directory. @@ -261,14 +261,15 @@ def listDir(self, streams: bool = True, storages: bool = False) -> List[List[str """ if (msg := self.__msg()) is None: raise ReferenceError('The MSGFile for this Recipient instance has been garbage collected.') - return [path[1:] for path in msg.listDir(streams, storages, False) - if len(path) > 1 and path[0] == self.__dir] + return tuple(path.relative_to(self.__dir) + for path in msg.listDir(streams, storages, False) + if path.is_relative_to(self.__dir)) - def slistDir(self, streams: bool = True, storages: bool = False) -> List[str]: + def slistDir(self, streams: bool = True, storages: bool = False) -> Tuple[str, ...]: """ Like :meth:`listDir`, except it returns the paths as strings. """ - return ['/'.join(path) for path in self.listDir(streams, storages)] + return tuple(str(path) for path in self.listDir(streams, storages)) @functools.cached_property def account(self) -> Optional[str]: diff --git a/extract_msg/utils.py b/extract_msg/utils.py index 9ea725be..90490494 100644 --- a/extract_msg/utils.py +++ b/extract_msg/utils.py @@ -26,11 +26,9 @@ 'guessEncoding', 'htmlSanitize', 'inputToBytes', - 'inputToMsgPath', 'inputToString', 'isEncapsulatedRtf', 'makeWeakRef', - 'msgPathToString', 'parseType', 'patchCss', 'prepareFilename', @@ -635,32 +633,6 @@ def inputToBytes(obj: Union[bytes, None, str, SupportsBytes], encoding: str) -> raise ConversionError('Cannot convert to bytes.') -def inputToMsgPath(inp: constants.MSG_PATH) -> List[str]: - """ - Converts the input into an msg path. - - :raises ValueError: The path contains an illegal character. - """ - if isinstance(inp, (list, tuple)): - inp = '/'.join(inp) - - inp = inputToString(inp, 'utf-8') - - # Validate the path is okay. Normally we would check for '/' and '\', but - # we are expecting a string or similar which will use those as path - # separators, so we will ignore that for now. - if ':' in inp or '!' in inp: - raise ValueError('Illegal character ("!" or ":") found in MSG path.') - - ret = [x for x in inp.replace('\\', '/').split('/') if x] - - # One last thing to check: all path segments can be, at most, 31 characters - # (32 if you include the null character), so we should verify that. - if any(len(x) > 31 for x in ret): - raise ValueError('Path segments must not be greater than 31 characters.') - return ret - - def inputToString(bytesInputVar: Optional[Union[str, bytes]], encoding: str) -> str: """ Converts the input into a string. @@ -724,16 +696,6 @@ def minutesToDurationStr(minutes: int) -> str: return f'{minutes // 60} hours {minutes % 60} minutes' -def msgPathToString(inp: Union[str, Iterable[str]]) -> str: - """ - Converts an MSG path (one of the internal paths inside an MSG file) into a - string. - """ - if not isinstance(inp, str): - inp = '/'.join(inp) - return inp.replace('\\', '/') - - def parseType(_type: int, stream: Union[int, bytes], encoding: str, extras: Sequence[bytes]): """ Converts the data in :param stream: to a much more accurate type, specified diff --git a/extract_msg_tests/util_tests.py b/extract_msg_tests/util_tests.py index 7ea00eb0..17040b48 100644 --- a/extract_msg_tests/util_tests.py +++ b/extract_msg_tests/util_tests.py @@ -8,7 +8,7 @@ import RTFDE from .constants import TEST_FILE_DIR, USER_TEST_DIR -from extract_msg import enums, openMsg, utils +from extract_msg import enums, MSGPath, openMsg, utils from extract_msg.msg_classes import MessageBase @@ -58,11 +58,11 @@ def test_minutesToDurationStr(self): self.assertEqual(utils.minutesToDurationStr(121), '2 hours 1 minute') self.assertEqual(utils.minutesToDurationStr(122), '2 hours 2 minutes') - def test_msgPathToStr(self): - self.assertEqual(utils.msgPathToString('hello/world/one'), 'hello/world/one') - self.assertEqual(utils.msgPathToString('hello/world\\one'), 'hello/world/one') - self.assertEqual(utils.msgPathToString(['hello', 'world', 'one']), 'hello/world/one') - self.assertEqual(utils.msgPathToString(['hello\\world', 'one']), 'hello/world/one') + def test_msgPath(self): + self.assertEqual(str(MSGPath('hello/world/one')), 'hello/world/one') + self.assertEqual(str(MSGPath('/hello/world\\one')), 'hello/world/one') + self.assertEqual(str(MSGPath(['//hello', 'world', 'one'])), 'hello/world/one') + self.assertEqual(str(MSGPath(['///hello\\world', 'one'])), 'hello/world/one') def test_stripRtf(self, testFileDir = TEST_FILE_DIR): """ @@ -83,7 +83,7 @@ def test_stripRtf(self, testFileDir = TEST_FILE_DIR): try: htmlBody = msg.deencapsulateBody(rtfBody, enums.DeencapType.HTML) - except Exception: + except Exception: # If it's throwing an exception, it's not our problem here. htmlBody = None @@ -104,7 +104,7 @@ def test_stripRtf(self, testFileDir = TEST_FILE_DIR): de = RTFDE.DeEncapsulator(utils.stripRtf(rtfBody)) de.deencapsulate() htmlBody = de.html - except Exception: + except Exception: # If it's throwing an exception, it's not our problem here. htmlBody = None From 0fa5f3b31861d8b15387e105975b08f451be1f2a Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 20:00:29 -0700 Subject: [PATCH 5/9] Add further tests to MSGPath --- extract_msg_tests/util_tests.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/extract_msg_tests/util_tests.py b/extract_msg_tests/util_tests.py index 17040b48..02c9f98e 100644 --- a/extract_msg_tests/util_tests.py +++ b/extract_msg_tests/util_tests.py @@ -58,11 +58,22 @@ def test_minutesToDurationStr(self): self.assertEqual(utils.minutesToDurationStr(121), '2 hours 1 minute') self.assertEqual(utils.minutesToDurationStr(122), '2 hours 2 minutes') - def test_msgPath(self): + def test_MSGPath(self): self.assertEqual(str(MSGPath('hello/world/one')), 'hello/world/one') self.assertEqual(str(MSGPath('/hello/world\\one')), 'hello/world/one') self.assertEqual(str(MSGPath(['//hello', 'world', 'one'])), 'hello/world/one') self.assertEqual(str(MSGPath(['///hello\\world', 'one'])), 'hello/world/one') + self.assertEqual(str(MSGPath('hello', ['world', 'one'])), 'hello/world/one') + + with self.assertRaises(ValueError): + MSGPath('hello!') + with self.assertRaises(ValueError): + MSGPath('hello:') + with self.assertRaises(ValueError): + MSGPath(['hello'], 'jkwlkwkwkwkkwkwkwkwkkwkwkwkwkkwkwkw') + + self.assertEqual(MSGPath('hello/World'), MSGPath('Hello/world')) + self.assertNotEqual(str(MSGPath('hello/World')), str(MSGPath('Hello/world'))) def test_stripRtf(self, testFileDir = TEST_FILE_DIR): """ From a599cc269fa28ae646a4a74731dacfe682877ab6 Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 20:04:36 -0700 Subject: [PATCH 6/9] Update version info --- README.rst | 4 ++-- extract_msg/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index c26ff199..62ff3f0e 100644 --- a/README.rst +++ b/README.rst @@ -268,8 +268,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.57.1-blue.svg - :target: https://pypi.org/project/extract-msg/0.57.1/ +.. |PyPI3| image:: https://img.shields.io/badge/pypi-0.58.0-blue.svg + :target: https://pypi.org/project/extract-msg/0.58.0/ .. |PyPI2| image:: https://img.shields.io/badge/python-3.8+-brightgreen.svg :target: https://www.python.org/downloads/release/python-3810/ diff --git a/extract_msg/__init__.py b/extract_msg/__init__.py index 213ceff6..e04d3088 100644 --- a/extract_msg/__init__.py +++ b/extract_msg/__init__.py @@ -28,7 +28,7 @@ __author__ = 'Destiny Peterson & Matthew Walker' __date__ = '2026-10-28' -__version__ = '0.57.1' +__version__ = '0.58.0' __all__ = [ # Modules: From 8e2809ee480980fc2024e9d880b97c2f0be4bfdf Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 20:08:27 -0700 Subject: [PATCH 7/9] Corrections to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f40a22..ed245532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ * Changed setup to use `find_packages()` to try and get ahead of deprecation and ensure all files are distributed correctly. * Fixed `MSGFile.existsTypedProperty()` to actually do what it says it will do. Previous code had it not checking if the right directory was being searched when `location` was specified. * Changed `MSGFile.sExists()` to correctly detect *only* the valid string stream type for the MSG file instead of unconditionally checking both types. -* Readded `extract_msg.constants.PROPERTIES` which was removed for unknown reasons in an earlier version (0.42.0). `MSGBase.saveRaw()` appears to have been non-functional since then. +* Re-added `extract_msg.constants.PROPERTIES` which was removed for unknown reasons in an earlier version (0.42.0). `MSGBase.saveRaw()` appears to have been non-functional since then. **v0.57.1** * [[TeamMsgExtractor #489](https://github.com/TeamMsgExtractor/msg-extractor/issues/489)] Fix an issue where `MessageSignedBase.body` would raise an error when the plain text body was not utf-8 encoded bytes. This error was due to `extract_msg.utils.unwrapMultipart()` accidentally returning the plain text body as bytes instead of string. From 11c1218f49b82c55b826ece737b662bb9b7a24d0 Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 20:50:28 -0700 Subject: [PATCH 8/9] Add a fix for later python versions --- extract_msg/msg_path.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/extract_msg/msg_path.py b/extract_msg/msg_path.py index dc17480c..689714b2 100644 --- a/extract_msg/msg_path.py +++ b/extract_msg/msg_path.py @@ -4,6 +4,8 @@ 'MSGPath', ] +import sys + from pathlib import PurePosixPath from typing import Any @@ -39,6 +41,11 @@ def __new__(cls, *args: Any): # Our last one will require us to analyze the parts of the path. As # such, convert the path first then check. pp = PurePosixPath.__new__(cls, path) + # Pathlib changed from using __new__ to using __init__ in later + # versions, and I don't know how to cleanly handle that so we are using + # a very gross method. + if sys.version_info[1] > 11: + PurePosixPath.__init__(pp, path) # Make sure each segment is not more than 31 characters long. if any(len(x) > 31 for x in pp.parts): @@ -47,6 +54,10 @@ def __new__(cls, *args: Any): # All good. return pp + def __init__(self, *args, **kwargs): + # No call to super __init__ for compatability. + pass + def __bool__(self) -> bool: return bool(self.parts) From 98eab8179a1d4f2511f4dfa73ee5b017744cd5d9 Mon Sep 17 00:00:00 2001 From: TheElementalOfDestruction Date: Wed, 28 Oct 2026 20:53:15 -0700 Subject: [PATCH 9/9] Add additional versions to python tests --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index e7ec5898..9f931464 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v4