diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c5a495f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,8 @@
+# See https://php.watch/articles/composer-gitattributes
+/.github           export-ignore
+/tests             export-ignore
+
+/.gitignore        export-ignore
+/.gitattributes    export-ignore
+/phpunit.xml.dist  export-ignore
+/.php-cs-fixer.dist.php  export-ignore
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..47b30de
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,80 @@
+# This file was heavily based on the ci-file from SymfonyCasts/verify-email-bundle
+#
+# See https://github.com/SymfonyCasts/verify-email-bundle
+#     https://github.com/SymfonyCasts/verify-email-bundle/blob/main/.github/workflows/ci.yml
+
+name: CI
+on:
+  push:
+    branches: ['main','master']
+  pull_request:
+
+jobs:
+  lint:
+    name: Lint
+    runs-on: ubuntu-22.04
+
+    steps:
+      - name: "Checkout code"
+        uses: "actions/checkout@v4"
+
+      - name: "Install PHP"
+        uses: "shivammathur/setup-php@v2"
+        with:
+          coverage: "none"
+          php-version: "8.1"
+
+      - name: "Validate composer.json"
+        run: "composer validate --strict --no-check-lock"
+
+      - name: "Validate php-files"
+        run:  "php -l src && php -l tests"
+
+      - name: "Composer install"
+        uses: "ramsey/composer-install@v3"
+        with:
+          composer-options: "--prefer-stable"
+          dependency-versions: 'highest'
+
+      - name: "PHP-CS-Fixer"
+        run:  "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run"
+
+      - name: "PHPStan"
+        run:  "vendor/bin/phpstan analyze"
+
+  tests:
+    name: "Tests ${{ matrix.php-version }} ${{ matrix.dependency-versions }}"
+    runs-on: ubuntu-22.04
+    needs: lint
+
+    strategy:
+      fail-fast: false
+      matrix:
+        # normal, highest, non-dev installs
+        php-version: ['8.1', '8.2', '8.3', '8.4']
+        composer-options: ['--prefer-stable']
+        dependency-versions: ['highest']
+        include:
+          # testing lowest PHP version with lowest dependencies
+          - php-version: '8.1'
+            dependency-versions: 'lowest'
+            composer-options: '--prefer-lowest'
+
+    steps:
+      - name: "Checkout code"
+        uses: "actions/checkout@v4"
+
+      - name: "Install PHP"
+        uses: "shivammathur/setup-php@v2"
+        with:
+          coverage: "none"
+          php-version: "${{ matrix.php-version }}"
+
+      - name: "Composer install"
+        uses: "ramsey/composer-install@v3"
+        with:
+          dependency-versions: "${{ matrix.dependency-versions }}"
+          composer-options: "--prefer-dist --no-progress"
+
+      - name: Unit Tests
+        run: vendor/bin/phpunit
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index fb79b6c..9a994d1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
 .vscode/
 vendor/
 composer.lock
-.phpunit.result.cache
\ No newline at end of file
+.phpunit.result.cache
+.php-cs-fixer.cache
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..ec52239
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,19 @@
+<?php declare(strict_types=1);
+
+$finder = (new PhpCsFixer\Finder())
+    ->in(__DIR__)
+    ->exclude('vendor')
+    ->exclude('tools')
+;
+
+return (new PhpCsFixer\Config())
+    ->setRules([
+        '@Symfony' => true,
+        'yoda_style' => false,
+        'standardize_increment' => false,
+        'binary_operator_spaces' => [
+            'default' => 'align_single_space_minimal',
+        ],
+    ])
+    ->setFinder($finder)
+    ;
diff --git a/composer.json b/composer.json
index 8c25840..cc952b8 100644
--- a/composer.json
+++ b/composer.json
@@ -3,7 +3,7 @@
     "description": "Pure PHP library for reading and manipulating Microsoft Outlook .msg messages (MAPI documents)",
     "license": "MIT",
     "require": {
-        "php": "^7.1||^8.0",
+        "php": "^8.1",
         "ext-bcmath": "*",
         "ext-mbstring": "*",
         "pear/ole": "^1.0",
@@ -13,8 +13,10 @@
     },
     "require-dev": {
         "swiftmailer/swiftmailer": "^6.1",
-        "phpunit/phpunit": "^8.3",
-        "pear/pear-core-minimal": "^1.10"
+        "phpunit/phpunit": "^10.0",
+        "pear/pear-core-minimal": "^1.10.10",
+        "friendsofphp/php-cs-fixer": "^3.64",
+        "phpstan/phpstan": "^2.0"
     },
     "suggest": {
         "swiftmailer/swiftmailer": "Conversion to MIME (eml file) message format"  
@@ -29,5 +31,8 @@
         "psr-4": {
             "Hfig\\MAPI\\Tests\\": "tests/MAPI"
         }
+    },
+    "conflict": {
+        "pear/console_getopt": "<1.4.3"
     }
 }
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
new file mode 100644
index 0000000..b35f3d4
--- /dev/null
+++ b/phpstan.dist.neon
@@ -0,0 +1,5 @@
+parameters:
+	level: 5
+	paths:
+		- src
+		- tests
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index b150fd0..52696ad 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,7 +1,14 @@
-<phpunit bootstrap="vendor/autoload.php">
+<phpunit bootstrap="vendor/autoload.php"
+         displayDetailsOnTestsThatTriggerWarnings="true"
+         displayDetailsOnTestsThatTriggerErrors="true"
+         displayDetailsOnTestsThatTriggerDeprecations="true">
     <testsuites>
         <testsuite name="main">
             <directory>tests</directory>
         </testsuite>
     </testsuites>
+    <php>
+        <ini name="display_errors" value="1"/>
+        <ini name="error_reporting" value="-1"/>
+    </php>
 </phpunit>
\ No newline at end of file
diff --git a/src/MAPI/Item/Attachment.php b/src/MAPI/Item/Attachment.php
index 3252419..212e493 100644
--- a/src/MAPI/Item/Attachment.php
+++ b/src/MAPI/Item/Attachment.php
@@ -1,37 +1,35 @@
-<?php
-
-namespace Hfig\MAPI\Item;
-
-abstract class Attachment extends MapiObject
-{
-    protected $embedded_msg = null;
-    protected $embedded_ole = null;
-
-    public function getFilename()
-    {
-        return $this->properties['attach_long_filename'] ?? $this->properties['attach_filename'] ?? '';
-    }
-
-    public function getData()
-    {
-        return $this->embedded_msg ?? $this->embedded_ole ?? $this->properties['attach_data'] ?? null;
-    }
-
-    public function copyToStream($stream)
-    {
-        if ($this->embedded_ole) {
-            return $this->storeEmbeddedOle($stream);
-        }
-        fwrite($stream, $this->getData() ?? '');
-    }
-
-    protected function storeEmbeddedOle($stream): void
-    {
-        // this is very untested...
-        //throw new \RuntimeException('Saving an OLE Compound Document is not supported');
-
-        $this->embedded_ole->saveToStream($stream);
-    }
-
-
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Item;
+
+abstract class Attachment extends MapiObject
+{
+    protected $embedded_msg;
+    protected $embedded_ole;
+
+    public function getFilename()
+    {
+        return $this->properties['attach_long_filename'] ?? $this->properties['attach_filename'] ?? '';
+    }
+
+    public function getData()
+    {
+        return $this->embedded_msg ?? $this->embedded_ole ?? $this->properties['attach_data'] ?? null;
+    }
+
+    public function copyToStream($stream): void
+    {
+        if ($this->embedded_ole) {
+            $this->storeEmbeddedOle($stream);
+        }
+        fwrite($stream, $this->getData() ?? '');
+    }
+
+    protected function storeEmbeddedOle($stream): void
+    {
+        // this is very untested...
+        // throw new \RuntimeException('Saving an OLE Compound Document is not supported');
+
+        $this->embedded_ole->saveToStream($stream);
+    }
+}
diff --git a/src/MAPI/Item/MapiObject.php b/src/MAPI/Item/MapiObject.php
index c008e6c..a166b2a 100644
--- a/src/MAPI/Item/MapiObject.php
+++ b/src/MAPI/Item/MapiObject.php
@@ -4,12 +4,7 @@
 
 class MapiObject
 {
-    protected $properties;
-
-    public function __construct($properties)
+    public function __construct(protected $properties)
     {
-        $this->properties = $properties;
     }
-
-
-}
\ No newline at end of file
+}
diff --git a/src/MAPI/Item/Message.php b/src/MAPI/Item/Message.php
index 8f64e2f..da9fd52 100644
--- a/src/MAPI/Item/Message.php
+++ b/src/MAPI/Item/Message.php
@@ -1,12 +1,13 @@
-<?php
-
-namespace Hfig\MAPI\Item;
-
-//# IMessage essentially, but there's also stuff like IMAPIFolder etc. so, for this to form
-//# basis for PST Item, it'd need to be more general.
-
-abstract class Message extends MapiObject
-{
-    abstract public function getAttachments();
-    abstract public function getRecipients();
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Item;
+
+// # IMessage essentially, but there's also stuff like IMAPIFolder etc. so, for this to form
+// # basis for PST Item, it'd need to be more general.
+
+abstract class Message extends MapiObject
+{
+    abstract public function getAttachments();
+
+    abstract public function getRecipients();
+}
diff --git a/src/MAPI/Item/Recipient.php b/src/MAPI/Item/Recipient.php
index 1a68e58..c32c0e9 100644
--- a/src/MAPI/Item/Recipient.php
+++ b/src/MAPI/Item/Recipient.php
@@ -1,77 +1,70 @@
-<?php
-
-namespace Hfig\MAPI\Item;
-
-class Recipient extends MapiObject
-{
-    const RECIPIENT_TYPES = [
-        0 => 'From',
-        1 => 'To',
-        2 => 'Cc',
-        3 => 'Bcc'
-    ];
-
-    //# some kind of best effort guess for converting to standard mime style format.
-	//# there are some rules for encoding non 7bit stuff in mail headers. should obey
-	//# that here, as these strings could be unicode
-	//# email_address will be an EX:/ address (X.400?), unless external recipient. the
-	//# other two we try first.
-    //# consider using entry id for this too.
-    public function getName()
-    {
-        $name = $this->properties['transmittable_display_name'] ?? $this->properties['display_name'] ?? '';
-        return preg_replace('/^\'(.*)\'/', '\1', $name);
-    }
-
-    public function getEmail()
-    {
-        return $this->properties['smtp_address'] ??
-                $this->properties['org_email_addr'] ??
-                $this->properties['email_address'] ??
-                '';
-    }
-
-    public function getType()
-    {
-        $type = $this->properties['recipient_type'];
-        if (isset(static::RECIPIENT_TYPES[$type])) {
-            return static::RECIPIENT_TYPES[$type];
-        }
-
-        return $type;
-    }
-
-    public function getAddressType()
-    {
-        $type = $this->properties['addrtype'] ?? 'Unknown';
-        return $type;
-
-        /*if ($this->properties['smtp_address']) {
-            return 'SMTP';
-        }
-        if ($this->properties['org_email_addr']) {
-            return 'ORG';
-        }
-        if ($this->properties['email_address']) {
-            return 'MAPI';
-        }
-        return 'Unknown';*/
-
-    }
-
-    public function __toString()
-    {
-        $name  = $this->getName();
-        $email = $this->getEmail();
-
-        //echo $this->getAddressType() . ': ' . sprintf('%s <%s>', $name, unpack('H*', $email)[1]) . "\n";
-
-        if  ($name && $name != $email) {
-            return sprintf('%s <%s>', $name, $email);
-        } 
-        return $email ?: $name;
-    }
-
-
-
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Item;
+
+class Recipient extends MapiObject implements \Stringable
+{
+    public const RECIPIENT_TYPES = [
+        0 => 'From',
+        1 => 'To',
+        2 => 'Cc',
+        3 => 'Bcc',
+    ];
+
+    // # some kind of best effort guess for converting to standard mime style format.
+    // # there are some rules for encoding non 7bit stuff in mail headers. should obey
+    // # that here, as these strings could be unicode
+    // # email_address will be an EX:/ address (X.400?), unless external recipient. the
+    // # other two we try first.
+    // # consider using entry id for this too.
+    public function getName(): ?string
+    {
+        $name = $this->properties['transmittable_display_name'] ?? $this->properties['display_name'] ?? '';
+
+        return preg_replace('/^\'(.*)\'/', '\1', (string) $name);
+    }
+
+    public function getEmail()
+    {
+        return $this->properties['smtp_address'] ?? $this->properties['org_email_addr'] ?? $this->properties['email_address'] ?? '';
+    }
+
+    public function getType()
+    {
+        $type = $this->properties['recipient_type'];
+
+        return static::RECIPIENT_TYPES[$type] ?? $type;
+    }
+
+    public function getAddressType()
+    {
+        $type = $this->properties['addrtype'] ?? 'Unknown';
+
+        return $type;
+
+        /*if ($this->properties['smtp_address']) {
+            return 'SMTP';
+        }
+        if ($this->properties['org_email_addr']) {
+            return 'ORG';
+        }
+        if ($this->properties['email_address']) {
+            return 'MAPI';
+        }
+        return 'Unknown';*/
+    }
+
+    public function __toString(): string
+    {
+        $name  = $this->getName();
+        $email = $this->getEmail();
+
+        // echo $this->getAddressType() . ': ' . sprintf('%s <%s>', $name, unpack('H*', $email)[1]) . "\n";
+
+        if ($name && $name != $email) {
+            return sprintf('%s <%s>', $name, $email);
+        }
+
+        return (string) ($email ?: $name);
+    }
+}
diff --git a/src/MAPI/MapiMessageFactory.php b/src/MAPI/MapiMessageFactory.php
index 744102e..cde78e4 100644
--- a/src/MAPI/MapiMessageFactory.php
+++ b/src/MAPI/MapiMessageFactory.php
@@ -1,24 +1,25 @@
-<?php
-
-namespace Hfig\MAPI;
-
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-use Hfig\MAPI\Mime\ConversionFactory;
-
-class MapiMessageFactory
-{
-    private $parent = null;
-
-    public function __construct(ConversionFactory $conversionFactory = null)
-    {
-        $this->parent = $conversionFactory;
-    }
-
-    public function parseMessage(Element $root)
-    {
-        if ($this->parent) {
-            return $this->parent->parseMessage($root);
-        }
-        return new \Hfig\MAPI\Message\Message($root);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI;
+
+use Hfig\MAPI\Mime\ConversionFactory;
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+
+class MapiMessageFactory
+{
+    private $parent;
+
+    public function __construct(?ConversionFactory $conversionFactory = null)
+    {
+        $this->parent = $conversionFactory;
+    }
+
+    public function parseMessage(Element $root)
+    {
+        if ($this->parent instanceof ConversionFactory) {
+            return $this->parent->parseMessage($root);
+        }
+
+        return new Message\Message($root);
+    }
+}
diff --git a/src/MAPI/Message/Attachment.php b/src/MAPI/Message/Attachment.php
index f542f1f..137b883 100644
--- a/src/MAPI/Message/Attachment.php
+++ b/src/MAPI/Message/Attachment.php
@@ -1,122 +1,111 @@
-<?php
-
-namespace Hfig\MAPI\Message;
-
-use Hfig\MAPI\Item\Attachment as AttachmentItem;
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-use Hfig\MAPI\Property\PropertyStore;
-use Hfig\MAPI\Property\PropertySet;
-
-
-/**
- * @var PropertySet $properties
- */
-class Attachment extends AttachmentItem
-{
-    /** @var Element */
-    protected $obj;
-
-    /** @var Message */
-    protected $parent;
-
-    protected $embedded_ole_type;
-
-    public function __construct(Element $obj, Message $parent)
-    {
-        $this->obj = $obj;
-        $this->parent = $parent;
-
-        $this->embedded_msg = null;
-        $this->embedded_ole  = null;
-        $this->embedded_ole_type = '';
-
-        // Set properties
-        parent::__construct(new PropertySet(
-            new PropertyStore($obj, $parent->getNameId())
-        ));
-
-        // initialise property set
-        //super PropertySet.new(PropertyStore.load(@obj))
-        //Msg.warn_unknown @obj
-        foreach ($obj->getChildren() as $child) {
-            if ($child->isDirectory() && preg_match(PropertyStore::SUBSTG_RX, $child->getName(), $matches)) {
-                // magic numbers??
-                if ($matches[1] == '3701' && strtolower($matches[2]) == '000d') {
-                    $this->embedded_ole = $child;
-                }
-            }
-
-        }
-
-        if ($this->embedded_ole) {
-            $type = $this->checkEmbeddedOleType();
-            if ($type == 'Microsoft Office Outlook Message') {
-                $this->embedded_msg = new Message($this->embedded_ole, $parent);
-            }
-        }
-
-    }
-
-    protected function checkEmbeddedOleType()
-    {
-        $found = 0;
-        $type = null;
-
-        foreach ($this->embedded_ole->getChildren() as $child) {
-            if (preg_match('/__(substg|properties|recip|attach|nameid)/', $child->getName())) {
-                $found++;
-                if ($found > 2) break;
-            }
-        }
-        if ($found > 2) {
-            $type = 'Microsoft Office Outlook Message';
-        }
-
-        if ($type) {
-            $this->embedded_ole_type = $type;
-        }
-
-        return $type;
-        
-    }
-
-    public function getMimeType()
-    {
-
-        $mime = $this->properties['attach_mime_tag'] ?? $this->embedded_ole_type;
-        if (!$mime) {
-            $mime = 'application/octet-stream';
-        }
-        
-
-        return $mime;
-    }
-
-    public function getContentId(): ?string
-    {
-        return $this->properties['attach_content_id'] ?? null;
-    }
-
-    public function getEmbeddedOleData(): ?string
-    {
-        $compobj = $this->properties["\01CompObj"];
-        if (is_null($compobj)) {
-            return null;
-        }
-        return substr($compobj, 32);
-    }
-
-    public function isValid(): bool
-    {
-        return $this->properties !== null;
-    }
-
-    public function __get($name)
-    {
-        if ($name == 'properties') {
-            return $this->properties;
-        }
-
-        return null;
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Message;
+
+use Hfig\MAPI\Item\Attachment as AttachmentItem;
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+use Hfig\MAPI\Property\PropertySet;
+use Hfig\MAPI\Property\PropertyStore;
+
+class Attachment extends AttachmentItem
+{
+    protected Element $obj;
+
+    protected Message $parent;
+
+    protected $embedded_ole_type = '';
+
+    public function __construct(Element $obj, Message $parent)
+    {
+        $this->obj    = $obj;
+        $this->parent = $parent;
+
+        $this->embedded_msg = null;
+        $this->embedded_ole = null;
+
+        // Set properties
+        parent::__construct(new PropertySet(
+            new PropertyStore($obj, $parent->getNameId()),
+        ));
+
+        // initialise property set
+        // super PropertySet.new(PropertyStore.load(@obj))
+        // Msg.warn_unknown @obj
+        foreach ($obj->getChildren() as $child) {
+            // magic numbers??
+            if ($child->isDirectory() && preg_match(PropertyStore::SUBSTG_RX, (string) $child->getName(), $matches) && ($matches[1] === '3701' && strtolower($matches[2]) === '000d')) {
+                $this->embedded_ole = $child;
+            }
+        }
+
+        if ($this->embedded_ole) {
+            $type = $this->checkEmbeddedOleType();
+            if ($type == 'Microsoft Office Outlook Message') {
+                $this->embedded_msg = new Message($this->embedded_ole, $parent);
+            }
+        }
+    }
+
+    protected function checkEmbeddedOleType(): ?string
+    {
+        $found = 0;
+        $type  = null;
+
+        foreach ($this->embedded_ole->getChildren() as $child) {
+            if (preg_match('/__(substg|properties|recip|attach|nameid)/', (string) $child->getName())) {
+                ++$found;
+                if ($found > 2) {
+                    break;
+                }
+            }
+        }
+        if ($found > 2) {
+            $type = 'Microsoft Office Outlook Message';
+        }
+
+        if ($type) {
+            $this->embedded_ole_type = $type;
+        }
+
+        return $type;
+    }
+
+    public function getMimeType()
+    {
+        $mime = $this->properties['attach_mime_tag'] ?? $this->embedded_ole_type;
+        if (!$mime) {
+            $mime = 'application/octet-stream';
+        }
+
+        return $mime;
+    }
+
+    public function getContentId(): ?string
+    {
+        return $this->properties['attach_content_id'] ?? null;
+    }
+
+    public function getEmbeddedOleData(): ?string
+    {
+        $compobj = $this->properties["\01CompObj"];
+        if (is_null($compobj)) {
+            return null;
+        }
+
+        return substr((string) $compobj, 32);
+    }
+
+    public function isValid(): bool
+    {
+        return $this->properties !== null;
+    }
+
+    public function __get($name)
+    {
+        if ($name == 'properties') {
+            return $this->properties;
+        }
+
+        return null;
+    }
+}
diff --git a/src/MAPI/Message/Message.php b/src/MAPI/Message/Message.php
index 08a7c61..2a4ceeb 100644
--- a/src/MAPI/Message/Message.php
+++ b/src/MAPI/Message/Message.php
@@ -1,230 +1,208 @@
-<?php
-
-namespace Hfig\MAPI\Message;
-
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-use Hfig\MAPI\OLE\Guid\OleGuid;
-use Hfig\MAPI\OLE\RTF;
-
-use Hfig\MAPI\Item\Message as MessageItem;
-
-use Hfig\MAPI\Property\PropertyStore;
-use Hfig\MAPI\Property\PropertySet;
-
-
-class Message extends MessageItem
-{
-
-
-    const ATTACH_RX     = '/^__attach_version1\.0_.*/';
-	const RECIP_RX      = '/^__recip_version1\.0_.*/';
-    const VALID_RX      = PropertyStore::VALID_RX + [
-        self::ATTACH_RX,
-        self::RECIP_RX
-    ];
-
-
-    /** @var Element */
-    protected $obj;
-
-    /** @var PropertySet */
-    protected $properties;
-
-    /** @var Message */
-    protected $parent;
-
-    /** @var Attachment[] */
-    protected $attachments = [];
-    /** @var Recipient[] */
-    protected $recipients = [];
-
-    protected $bodyPlain;
-    protected $bodyRTF;
-    protected $bodyHTML;
-
-
-
-    public function __construct(Element $obj, Message $parent = null)
-    {
-        
-        $this->obj = $obj;
-        $this->parent = $parent;
-        
-        $this->properties = new PropertySet(
-            new PropertyStore($obj, ($parent) ? $parent->getNameId() : null)
-        );
-
-        $this->buildAttachments();
-        $this->buildRecipients();
-        
-
-    }
-
-    
-
-    protected function buildAttachments()
-    {
-        foreach ($this->obj->getChildren() as $child) {
-            if ($child->isDirectory() && preg_match(self::ATTACH_RX, $child->getName())) {
-                $attachment = new Attachment($child, $this);
-                if ($attachment->isValid()) {
-                    $this->attachments[] = $attachment;
-                }
-            }
-        }
-    }
-
-    protected function buildRecipients()
-    {
-        foreach ($this->obj->getChildren() as $child) {
-            if ($child->isDirectory() && preg_match(self::RECIP_RX, $child->getName())) {
-
-                //echo 'Got child . ' . $child->getName() . "\n";
-
-                $recipient = new Recipient($child, $this);
-                $this->recipients[] = $recipient;
-            }
-        }
-    }
-
-    /** @return Attachment[] */
-    public function getAttachments(): array
-    {
-        return $this->attachments;
-    }
-
-    /** @return  Recipient[] */
-    public function getRecipients(): array
-    {
-        return $this->recipients;
-    }
-
-    public function getRecipientsOfType($type): array
-    {
-        $response = [];
-        foreach ($this->recipients as $r) {
-            if ($r->getType() == $type) {
-                $response[] = $r;
-            }
-        }
-        return $response;
-    }
-
-    public function getNameId()
-    {
-        return $this->properties->getStore()->getNameId();
-    }
-
-    public function getInternetMessageId(): ?string
-    {
-        return $this->properties['internet_message_id'] ?? null;
-    }
-
-    public function getBody()
-    {
-        if ($this->bodyPlain) return $this->bodyPlain;
-        
-        if ($this->properties['body']) {
-            $this->bodyPlain = $this->properties['body'];
-        }
-
-        // parse from RTF
-        if (!$this->bodyPlain) {
-            //jstewmc/rtf
-            throw new \Exception('No Plain Text body. Convert from RTF not implemented');
-        }
-
-        return $this->bodyPlain;
-    }
-
-    public function getBodyRTF()
-    {
-        if ($this->bodyRTF) return $this->bodyRTF;
-
-        if ($this->properties['rtf_compressed']) {
-
-            $this->bodyRTF = RTF\CompressionCodec::decode($this->properties['rtf_compressed']);
-        }
-
-        return $this->bodyRTF;
-    }
-
-    public function getBodyHTML()
-    {
-        if ($this->bodyHTML) return $this->bodyHTML;
-
-        if ($this->properties['body_html']) {
-            $this->bodyHTML = $this->properties['body_html'];
-            
-            if ($this->bodyHTML) {
-                $this->bodyHTML = trim($this->bodyHTML);
-            }
-        }
-
-        if (!$this->bodyHTML) {
-            if ($rtf = $this->getBodyRTF()) {
-                $this->bodyHTML = RTF\EmbeddedHTML::extract($rtf);
-            }
-
-            if (!$this->bodyHTML) {
-                //jstewmc/rtf
-                throw new \Exception('No HTML or Embedded RTF body. Convert from RTF not implemented');
-            }
-        }
-
-        return $this->bodyHTML;
-    }
-
-    public function getSender()
-    {
-        $senderName = $this->properties['sender_name'];
-        $senderAddr = $this->properties['sender_email_address'];
-        $senderType = $this->properties['sender_addrtype'];
-
-        $from = '';
-        if ($senderType == 'SMTP') {
-           $from = $senderAddr;
-        }
-        else {
-            $from = $this->properties['sender_smtp_address'] ?? 
-                    $this->properties['sender_representing_smtp_address'] ??
-                    // synthesise??
-                    // for now settle on type:address eg X400:<dn>
-                    sprintf('%s:%s', $senderType, $senderAddr);
-        }
-
-        if ($senderName) {
-            $from = sprintf('%s <%s>', $senderName, $from);
-        }        
-        
-        return $from;
-    }
-
-    public function getSendTime(): ?\DateTime
-    {
-        $sendTime = $this->properties['client_submit_time'];
-
-        if (!$sendTime) {
-            return null;
-        }
-
-        return \DateTime::createFromFormat('U',$sendTime);
-    }
-
-    public function properties(): PropertySet
-    {
-	return $this->properties;
-    }
-	
-    public function __get($name)
-    {
-        if ($name == 'properties') {
-            return $this->properties;
-        }
-
-        return null;
-    }
-
-
-
-}
+<?php
+
+namespace Hfig\MAPI\Message;
+
+use Hfig\MAPI\Item\Message as MessageItem;
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+use Hfig\MAPI\OLE\RTF;
+use Hfig\MAPI\Property\PropertySet;
+use Hfig\MAPI\Property\PropertyStore;
+
+class Message extends MessageItem
+{
+    public const ATTACH_RX = '/^__attach_version1\.0_.*/';
+    public const RECIP_RX  = '/^__recip_version1\.0_.*/';
+    public const VALID_RX  = PropertyStore::VALID_RX + [
+        self::ATTACH_RX,
+        self::RECIP_RX,
+    ];
+
+    protected Element $obj;
+    protected ?Message $parent;
+
+    /** @var Attachment[] */
+    protected $attachments = [];
+    /** @var Recipient[] */
+    protected $recipients = [];
+
+    protected $bodyPlain;
+    protected $bodyRTF;
+    protected ?string $bodyHTML = null;
+
+    public function __construct(Element $obj, ?Message $parent = null)
+    {
+        parent::__construct(new PropertySet(
+            new PropertyStore($obj, ($parent instanceof Message) ? $parent->getNameId() : null),
+        ));
+
+        $this->obj    = $obj;
+        $this->parent = $parent;
+
+        $this->buildAttachments();
+        $this->buildRecipients();
+    }
+
+    protected function buildAttachments()
+    {
+        foreach ($this->obj->getChildren() as $child) {
+            if ($child->isDirectory() && preg_match(self::ATTACH_RX, (string) $child->getName())) {
+                $attachment = new Attachment($child, $this);
+                if ($attachment->isValid()) {
+                    $this->attachments[] = $attachment;
+                }
+            }
+        }
+    }
+
+    protected function buildRecipients()
+    {
+        foreach ($this->obj->getChildren() as $child) {
+            if ($child->isDirectory() && preg_match(self::RECIP_RX, (string) $child->getName())) {
+                // echo 'Got child . ' . $child->getName() . "\n";
+
+                $recipient          = new Recipient($child, $this);
+                $this->recipients[] = $recipient;
+            }
+        }
+    }
+
+    /** @return Attachment[] */
+    public function getAttachments(): array
+    {
+        return $this->attachments;
+    }
+
+    /** @return  Recipient[] */
+    public function getRecipients(): array
+    {
+        return $this->recipients;
+    }
+
+    public function getRecipientsOfType($type): array
+    {
+        $response = [];
+        foreach ($this->recipients as $r) {
+            if ($r->getType() == $type) {
+                $response[] = $r;
+            }
+        }
+
+        return $response;
+    }
+
+    public function getNameId()
+    {
+        return $this->properties->getStore()->getNameId();
+    }
+
+    public function getInternetMessageId(): ?string
+    {
+        return $this->properties['internet_message_id'] ?? null;
+    }
+
+    public function getBody()
+    {
+        if ($this->bodyPlain) {
+            return $this->bodyPlain;
+        }
+
+        if ($this->properties['body']) {
+            $this->bodyPlain = $this->properties['body'];
+        }
+
+        // parse from RTF
+        if (!$this->bodyPlain) {
+            // jstewmc/rtf
+            throw new \Exception('No Plain Text body. Convert from RTF not implemented');
+        }
+
+        return $this->bodyPlain;
+    }
+
+    public function getBodyRTF()
+    {
+        if ($this->bodyRTF) {
+            return $this->bodyRTF;
+        }
+
+        if ($this->properties['rtf_compressed']) {
+            $this->bodyRTF = RTF\CompressionCodec::decode($this->properties['rtf_compressed']);
+        }
+
+        return $this->bodyRTF;
+    }
+
+    public function getBodyHTML(): string
+    {
+        if ($this->bodyHTML === null) {
+            $this->bodyHTML = $this->getBodyHtmlWithoutCache();
+        }
+
+        return $this->bodyHTML;
+    }
+
+    private function getBodyHtmlWithoutCache(): string
+    {
+        if ($this->properties['body_html']) {
+            return trim((string) $this->properties['body_html']);
+        }
+
+        $rtf = $this->getBodyRTF();
+        if (!empty($rtf)) {
+            $extractedHtml = RTF\EmbeddedHTML::extract($rtf);
+
+            if (!empty($extractedHtml)) {
+                return $extractedHtml;
+            }
+        }
+
+        throw new \Exception('No HTML or Embedded RTF body. Convert from RTF not implemented');
+    }
+
+    public function getSender()
+    {
+        $senderName = $this->properties['sender_name'];
+        $senderAddr = $this->properties['sender_email_address'];
+        $senderType = $this->properties['sender_addrtype'];
+
+        $from = '';
+        if ($senderType === 'SMTP') {
+            $from = $senderAddr;
+        } else {
+            $from = $this->properties['sender_smtp_address'] ?? $this->properties['sender_representing_smtp_address'] ?? // synthesise??
+                    // for now settle on type:address eg X400:<dn>
+                    sprintf('%s:%s', $senderType, $senderAddr);
+        }
+
+        if ($senderName) {
+            $from = sprintf('%s <%s>', $senderName, $from);
+        }
+
+        return $from;
+    }
+
+    public function getSendTime(): ?\DateTime
+    {
+        $sendTime = $this->properties['client_submit_time'];
+
+        if (!$sendTime) {
+            return null;
+        }
+
+        return \DateTime::createFromFormat('U', $sendTime);
+    }
+
+    public function properties(): PropertySet
+    {
+        return $this->properties;
+    }
+
+    public function __get($name)
+    {
+        if ($name === 'properties') {
+            return $this->properties;
+        }
+
+        return null;
+    }
+}
diff --git a/src/MAPI/Message/Recipient.php b/src/MAPI/Message/Recipient.php
index 9e354ba..f7a4f8e 100644
--- a/src/MAPI/Message/Recipient.php
+++ b/src/MAPI/Message/Recipient.php
@@ -1,37 +1,35 @@
-<?php
-
-namespace Hfig\MAPI\Message;
-
-use Hfig\MAPI\Item\Recipient as RecipientItem;
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-
-use Hfig\MAPI\Property\PropertyStore;
-use Hfig\MAPI\Property\PropertySet;
-
-class Recipient extends RecipientItem
-{
-    /** @var Element */
-    protected $obj;
-
-    /** @var PropertySet */
-    protected $properties;
-
-    public function __construct(Element $obj, Message $parent)
-    {
-        $this->obj = $obj;
-
-        // initialise property set
-        $this->properties = new PropertySet(
-            new PropertyStore($obj, $parent->getNameId())
-        );
-    }
-
-    public function __get($name)
-    {
-        if ($name == 'properties') {
-            return $this->properties;
-        }
-
-        return null;
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Message;
+
+use Hfig\MAPI\Item\Recipient as RecipientItem;
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+use Hfig\MAPI\Property\PropertySet;
+use Hfig\MAPI\Property\PropertyStore;
+
+class Recipient extends RecipientItem
+{
+    protected Element $obj;
+
+    /** @var PropertySet */
+    protected $properties;
+
+    public function __construct(Element $obj, Message $parent)
+    {
+        $this->obj = $obj;
+
+        // initialise property set
+        $this->properties = new PropertySet(
+            new PropertyStore($obj, $parent->getNameId()),
+        );
+    }
+
+    public function __get($name)
+    {
+        if ($name == 'properties') {
+            return $this->properties;
+        }
+
+        return null;
+    }
+}
diff --git a/src/MAPI/Mime/ConversionFactory.php b/src/MAPI/Mime/ConversionFactory.php
index b7c27e7..62fb10e 100644
--- a/src/MAPI/Mime/ConversionFactory.php
+++ b/src/MAPI/Mime/ConversionFactory.php
@@ -1,11 +1,10 @@
-<?php
-
-namespace Hfig\MAPI\Mime;
-
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-
-interface ConversionFactory
-{
-    public function parseMessage(Element $root);
-
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Mime;
+
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+
+interface ConversionFactory
+{
+    public function parseMessage(Element $root);
+}
diff --git a/src/MAPI/Mime/HeaderCollection.php b/src/MAPI/Mime/HeaderCollection.php
index 8351c50..ba25680 100644
--- a/src/MAPI/Mime/HeaderCollection.php
+++ b/src/MAPI/Mime/HeaderCollection.php
@@ -1,91 +1,89 @@
-<?php
-
-namespace Hfig\MAPI\Mime;
-
-class HeaderCollection implements \IteratorAggregate
-{
-    protected $rawHeaders = [];
-
-    public function getIterator(): \ArrayIterator
-    {
-        return new \ArrayIterator($this->rawHeaders);
-    }
-    
-    public function add($header, $value = null): void
-    {
-        if (is_null($value)) {
-            //echo $header . "\n";
-            @list($header, $value) = explode(':', $header, 2);
-            //if (!$value) throw new \Exception('No value for ' . $header);
-            $value = ltrim($value);
-        }
-
-        $key = strtolower($header);
-        $val = [
-            'rawkey' => $header,
-            'value'  => $value,
-        ];
-        $val = (object)$val;
-
-
-        if (isset($this->rawHeaders[$key])) {
-            if (!is_array($this->rawHeaders[$key])) {
-                $this->rawHeaders[$key] = [ $this->rawHeaders[$key] ];
-            }
-            
-            $this->rawHeaders[$key][] = $val;
-        }
-        else {
-            $this->rawHeaders[$key] = $val;
-        }
-    }
-
-    public function set($header, $value): void
-    {
-        $key = strtolower($header);
-        $val = [
-            'rawkey' => $header,
-            'value'  => $value,
-        ];
-        $val = (object)$val;
-
-        $this->rawHeaders[$key] = $val;
-    }
-
-    public function get($header)
-    {
-        $key = strtolower($header);
-        if (!isset($this->rawHeaders[$key])) {
-            return null;
-        }
-
-        return $this->rawHeaders[$key];
-    }
-
-    public function getValue($header)
-    {
-        $raw = $this->get($header);
-        
-        if (is_null($raw)) return null;
-        if (is_array($raw)) {
-            return array_map(function ($e) {
-                return $e->value;
-            }, $raw);
-        }
-
-        return $raw->value;
-
-    }
-
-    public function has($header): bool
-    {
-        $key = strtolower($header);
-        return isset($this->rawHeaders[$key]);
-    }
-
-    public function unset($header): void
-    {
-        $key = strtolower($header);
-        unset($this->rawHeaders[$key]);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Mime;
+
+class HeaderCollection implements \IteratorAggregate
+{
+    protected $rawHeaders = [];
+
+    public function getIterator(): \ArrayIterator
+    {
+        return new \ArrayIterator($this->rawHeaders);
+    }
+
+    public function add($header, $value = null): void
+    {
+        if (is_null($value)) {
+            // echo $header . "\n";
+            @[$header, $value] = explode(':', (string) $header, 2);
+            // if (!$value) throw new \Exception('No value for ' . $header);
+            $value = ltrim($value);
+        }
+
+        $key = strtolower((string) $header);
+        $val = [
+            'rawkey' => $header,
+            'value'  => $value,
+        ];
+        $val = (object) $val;
+
+        if (isset($this->rawHeaders[$key])) {
+            if (!is_array($this->rawHeaders[$key])) {
+                $this->rawHeaders[$key] = [$this->rawHeaders[$key]];
+            }
+
+            $this->rawHeaders[$key][] = $val;
+        } else {
+            $this->rawHeaders[$key] = $val;
+        }
+    }
+
+    public function set($header, $value): void
+    {
+        $key = strtolower((string) $header);
+        $val = [
+            'rawkey' => $header,
+            'value'  => $value,
+        ];
+        $val = (object) $val;
+
+        $this->rawHeaders[$key] = $val;
+    }
+
+    public function get($header)
+    {
+        $key = strtolower((string) $header);
+        if (!isset($this->rawHeaders[$key])) {
+            return null;
+        }
+
+        return $this->rawHeaders[$key];
+    }
+
+    public function getValue($header)
+    {
+        $raw = $this->get($header);
+
+        if (is_null($raw)) {
+            return null;
+        }
+        if (is_array($raw)) {
+            return array_map(fn ($e) => $e->value, $raw);
+        }
+
+        return $raw->value;
+    }
+
+    public function has($header): bool
+    {
+        $key = strtolower((string) $header);
+
+        return isset($this->rawHeaders[$key]);
+    }
+
+    public function unset($header): void
+    {
+        $key = strtolower((string) $header);
+        unset($this->rawHeaders[$key]);
+    }
+}
diff --git a/src/MAPI/Mime/MimeConvertible.php b/src/MAPI/Mime/MimeConvertible.php
index 2900e71..7aecf13 100644
--- a/src/MAPI/Mime/MimeConvertible.php
+++ b/src/MAPI/Mime/MimeConvertible.php
@@ -1,12 +1,12 @@
-<?php
-
-namespace Hfig\MAPI\Mime;
-
-interface MimeConvertible
-{
-    public function toMime();
-
-    public function toMimeString(): string;
-
-    public function copyMimeToStream($stream);
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Mime;
+
+interface MimeConvertible
+{
+    public function toMime();
+
+    public function toMimeString(): string;
+
+    public function copyMimeToStream($stream);
+}
diff --git a/src/MAPI/Mime/Swiftmailer/Adapter/DependencySet.php b/src/MAPI/Mime/Swiftmailer/Adapter/DependencySet.php
index 3dd31b7..1b0f4d2 100644
--- a/src/MAPI/Mime/Swiftmailer/Adapter/DependencySet.php
+++ b/src/MAPI/Mime/Swiftmailer/Adapter/DependencySet.php
@@ -1,31 +1,29 @@
-<?php
-
-namespace Hfig\MAPI\Mime\Swiftmailer\Adapter;
-
-use \Swift_DependencyContainer;
-
-
-class DependencySet {
-
-    // override the HeaderFactory registration in the DI container
-    public static function register($force = false): void
-    {
-        static $registered = false;
-
-        if ($registered && !$force) return;
-
-        $container = Swift_DependencyContainer::getInstance();
-        $container->register('mime.headerfactory')
-            ->asNewInstanceOf(HeaderFactory::class)
-            ->withDependencies([
-                'mime.qpheaderencoder',
-                'mime.rfc2231encoder',
-                'email.validator',
-                'properties.charset',
-                'address.idnaddressencoder',
-            ]);
-
-        $registered = true;
-    }
-
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Mime\Swiftmailer\Adapter;
+
+class DependencySet
+{
+    // override the HeaderFactory registration in the DI container
+    public static function register($force = false): void
+    {
+        static $registered = false;
+
+        if ($registered && !$force) {
+            return;
+        }
+
+        $container = \Swift_DependencyContainer::getInstance();
+        $container->register('mime.headerfactory')
+            ->asNewInstanceOf(HeaderFactory::class)
+            ->withDependencies([
+                'mime.qpheaderencoder',
+                'mime.rfc2231encoder',
+                'email.validator',
+                'properties.charset',
+                'address.idnaddressencoder',
+            ]);
+
+        $registered = true;
+    }
+}
diff --git a/src/MAPI/Mime/Swiftmailer/Adapter/HeaderFactory.php b/src/MAPI/Mime/Swiftmailer/Adapter/HeaderFactory.php
index 88c27e9..54732ae 100644
--- a/src/MAPI/Mime/Swiftmailer/Adapter/HeaderFactory.php
+++ b/src/MAPI/Mime/Swiftmailer/Adapter/HeaderFactory.php
@@ -1,42 +1,37 @@
-<?php
-
-namespace Hfig\MAPI\Mime\Swiftmailer\Adapter;
-
-use \Swift_Mime_SimpleHeaderFactory;
-use \Swift_Mime_HeaderEncoder;
-use \Swift_Mime_Header;
-use \Swift_Encoder;
-use \Swift_AddressEncoder;
-use Egulias\EmailValidator\EmailValidator;
-
-class HeaderFactory extends Swift_Mime_SimpleHeaderFactory
-{
-    protected $encoder;
-    protected $charset;
-
-    public function __construct(Swift_Mime_HeaderEncoder $encoder, Swift_Encoder $paramEncoder, EmailValidator $emailValidator, $charset = null, Swift_AddressEncoder $addressEncoder = null)
-    {
-        parent::__construct($encoder, $paramEncoder, $emailValidator, $charset, $addressEncoder);
-
-        $this->encoder = $encoder;
-        $this->charset = $charset;
-    }
-
-    public function createTextHeader($name, $value = null): UnstructuredHeader
-    {
-        $header = new UnstructuredHeader($name, $this->encoder);
-        if (isset($value)) {
-            $header->setFieldBodyModel($value);
-        }
-        $this->setHeaderCharset($header);
-
-        return $header;
-    }
-
-    protected function setHeaderCharset(Swift_Mime_Header $header): void
-    {
-        if (isset($this->charset)) {
-            $header->setCharset($this->charset);
-        }
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Mime\Swiftmailer\Adapter;
+
+use Egulias\EmailValidator\EmailValidator;
+
+class HeaderFactory extends \Swift_Mime_SimpleHeaderFactory
+{
+    protected $encoder;
+    protected $charset;
+
+    public function __construct(\Swift_Mime_HeaderEncoder $encoder, \Swift_Encoder $paramEncoder, EmailValidator $emailValidator, $charset = null, ?\Swift_AddressEncoder $addressEncoder = null)
+    {
+        parent::__construct($encoder, $paramEncoder, $emailValidator, $charset, $addressEncoder);
+
+        $this->encoder = $encoder;
+        $this->charset = $charset;
+    }
+
+    public function createTextHeader($name, $value = null): UnstructuredHeader
+    {
+        $header = new UnstructuredHeader($name, $this->encoder);
+        if (isset($value)) {
+            $header->setFieldBodyModel($value);
+        }
+        $this->setHeaderCharset($header);
+
+        return $header;
+    }
+
+    protected function setHeaderCharset(\Swift_Mime_Header $header): void
+    {
+        if ($this->charset !== null) {
+            $header->setCharset($this->charset);
+        }
+    }
+}
diff --git a/src/MAPI/Mime/Swiftmailer/Adapter/UnstructuredHeader.php b/src/MAPI/Mime/Swiftmailer/Adapter/UnstructuredHeader.php
index 99c1525..6d6f630 100644
--- a/src/MAPI/Mime/Swiftmailer/Adapter/UnstructuredHeader.php
+++ b/src/MAPI/Mime/Swiftmailer/Adapter/UnstructuredHeader.php
@@ -1,52 +1,46 @@
-<?php
-
-namespace Hfig\MAPI\Mime\Swiftmailer\Adapter;
-
-use \Swift_Mime_Headers_UnstructuredHeader;
-
-
-// this is an UnstructuredHeader that is less zealous about encoding parameters
-// to implement this we must build a new factory that can instantiate this class
-// and update the DI container to use the factory
-
-class UnstructuredHeader extends Swift_Mime_Headers_UnstructuredHeader
-{
-     /**
-     * Test if a token needs to be encoded or not.
-     *
-     * @param string $token
-     *
-     * @return bool
-     */
-    protected function tokenNeedsEncoding($token): bool
-    {
-        static $prevToken = '';
-
-        $encode = false;
-        
-        // better --
-        // any non-printing character
-        // any non-ASCII character
-        // any \n not preceded by \r
-        // any \r\n not proceeded by a space or tab (requires joining the current token with the previous token as \r\n splits tokens)
-        
-        if (preg_match('~([\x00-\x08\x10-\x19\x7F-\xFF]|(?<!\r)\n)~', $token)) {
-            $encode = true;
-        }
-        if (substr($token, -2) == "\r\n") {
-            $prevToken = $token;
-            //$encode = true;
-        }
-        else {
-            $matchToken = $prevToken . $token;
-
-            if (preg_match('~(\r\n(?![ \t]))~', $matchToken)) {
-                $encode = true;
-            }
-
-            $prevToken = '';
-        }
-
-        return $encode;
-    }
-}
+<?php
+
+namespace Hfig\MAPI\Mime\Swiftmailer\Adapter;
+
+// this is an UnstructuredHeader that is less zealous about encoding parameters
+// to implement this we must build a new factory that can instantiate this class
+// and update the DI container to use the factory
+
+class UnstructuredHeader extends \Swift_Mime_Headers_UnstructuredHeader
+{
+    /**
+     * Test if a token needs to be encoded or not.
+     *
+     * @param string $token
+     */
+    protected function tokenNeedsEncoding($token): bool
+    {
+        static $prevToken = '';
+
+        $encode = false;
+
+        // better --
+        // any non-printing character
+        // any non-ASCII character
+        // any \n not preceded by \r
+        // any \r\n not proceeded by a space or tab (requires joining the current token with the previous token as \r\n splits tokens)
+
+        if (preg_match('~([\x00-\x08\x10-\x19\x7F-\xFF]|(?<!\r)\n)~', $token)) {
+            $encode = true;
+        }
+        if (str_ends_with($token, "\r\n")) {
+            $prevToken = $token;
+        // $encode = true;
+        } else {
+            $matchToken = $prevToken.$token;
+
+            if (preg_match('~(\r\n(?![ \t]))~', $matchToken)) {
+                $encode = true;
+            }
+
+            $prevToken = '';
+        }
+
+        return $encode;
+    }
+}
diff --git a/src/MAPI/Mime/Swiftmailer/Attachment.php b/src/MAPI/Mime/Swiftmailer/Attachment.php
index d50a2b9..4a58afd 100644
--- a/src/MAPI/Mime/Swiftmailer/Attachment.php
+++ b/src/MAPI/Mime/Swiftmailer/Attachment.php
@@ -1,74 +1,71 @@
-<?php
-
-namespace Hfig\MAPI\Mime\Swiftmailer;
-
-use Hfig\MAPI\Message\Attachment as BaseAttachment;
-use Hfig\MAPI\Mime\MimeConvertible;
-use Hfig\MAPI\Mime\Swiftmailer\Adapter\DependencySet;
-
-class Attachment extends BaseAttachment implements MimeConvertible
-{
-    public static function wrap(BaseAttachment $attachment)
-    {
-        if ($attachment instanceof MimeConvertible) {
-            return $attachment;
-        }
-
-        return new self($attachment->obj, $attachment->parent);
-    }
-
-    public function toMime(): \Swift_Attachment
-    {
-        DependencySet::register();
-
-        $attachment = new \Swift_Attachment();
-
-        if ($this->getMimeType() != 'Microsoft Office Outlook Message') {
-            $attachment->setFilename($this->getFilename());
-            $attachment->setContentType($this->getMimeType());
-        }
-        else {
-            $attachment->setFilename($this->getFilename() . '.eml');
-            $attachment->setContentType('message/rfc822');
-        }
-
-        if ($data = $this->properties['attach_content_disposition']) {
-            $attachment->setDisposition($data);
-        }
-
-        if ($data = $this->properties['attach_content_location']) {
-            $attachment->getHeaders()->addTextHeader('Content-Location', $data);
-        }
-        
-        if ($data = $this->properties['attach_content_id'])  {
-            $attachment->setId($data);
-        }
-
-        if ($this->embedded_msg) {
-            $attachment->setBody(
-                Message::wrap($this->embedded_msg)->toMime()
-            );
-        }
-        elseif ($this->embedded_ole) {
-            // in practice this scenario doesn't seem to occur
-            // MS Office documents are attached as files not
-            // embedded ole objects
-            throw new \Exception('Not implemented: saving emebed OLE content');
-        }
-        else {
-            $attachment->setBody($this->getData());
-        }
-
-        return $attachment;
-    }
-
-    public function toMimeString(): string
-    {
-        return (string)$this->toMime();
-    }
-
-    public function copyMimeToStream($stream): void
-    {
-        fwrite($stream, $this->toMimeString());
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Mime\Swiftmailer;
+
+use Hfig\MAPI\Message\Attachment as BaseAttachment;
+use Hfig\MAPI\Mime\MimeConvertible;
+use Hfig\MAPI\Mime\Swiftmailer\Adapter\DependencySet;
+
+class Attachment extends BaseAttachment implements MimeConvertible
+{
+    public static function wrap(BaseAttachment $attachment)
+    {
+        if ($attachment instanceof MimeConvertible) {
+            return $attachment;
+        }
+
+        return new self($attachment->obj, $attachment->parent);
+    }
+
+    public function toMime(): \Swift_Attachment
+    {
+        DependencySet::register();
+
+        $attachment = new \Swift_Attachment();
+
+        if ($this->getMimeType() != 'Microsoft Office Outlook Message') {
+            $attachment->setFilename($this->getFilename());
+            $attachment->setContentType($this->getMimeType());
+        } else {
+            $attachment->setFilename($this->getFilename().'.eml');
+            $attachment->setContentType('message/rfc822');
+        }
+
+        if ($data = $this->properties['attach_content_disposition']) {
+            $attachment->setDisposition($data);
+        }
+
+        if ($data = $this->properties['attach_content_location']) {
+            $attachment->getHeaders()->addTextHeader('Content-Location', $data);
+        }
+
+        if ($data = $this->properties['attach_content_id']) {
+            $attachment->setId($data);
+        }
+
+        if ($this->embedded_msg) {
+            $attachment->setBody(
+                Message::wrap($this->embedded_msg)->toMime(),
+            );
+        } elseif ($this->embedded_ole) {
+            // in practice this scenario doesn't seem to occur
+            // MS Office documents are attached as files not
+            // embedded ole objects
+            throw new \Exception('Not implemented: saving emebed OLE content');
+        } else {
+            $attachment->setBody($this->getData());
+        }
+
+        return $attachment;
+    }
+
+    public function toMimeString(): string
+    {
+        return (string) $this->toMime();
+    }
+
+    public function copyMimeToStream($stream): void
+    {
+        fwrite($stream, $this->toMimeString());
+    }
+}
diff --git a/src/MAPI/Mime/Swiftmailer/Factory.php b/src/MAPI/Mime/Swiftmailer/Factory.php
index 6a8d6f4..45fb649 100644
--- a/src/MAPI/Mime/Swiftmailer/Factory.php
+++ b/src/MAPI/Mime/Swiftmailer/Factory.php
@@ -1,26 +1,24 @@
-<?php
-
-namespace Hfig\MAPI\Mime\Swiftmailer;
-
-use Hfig\MAPI\Mime\ConversionFactory;
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-
-
-class Factory implements ConversionFactory
-{
-
-    protected $muteConversionExceptions;
-
-    public function __construct(bool $muteConversionExceptions = false)
-    {
-        $this->muteConversionExceptions = $muteConversionExceptions;
-    }
-
-    public function parseMessage(Element $root): Message
-    {
-        $message = new Message($root);
-        $message->setMuteConversionExceptions($this->muteConversionExceptions);
-
-        return $message;
-    }
-}
+<?php
+
+namespace Hfig\MAPI\Mime\Swiftmailer;
+
+use Hfig\MAPI\Mime\ConversionFactory;
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+
+class Factory implements ConversionFactory
+{
+    protected bool $muteConversionExceptions;
+
+    public function __construct(bool $muteConversionExceptions = false)
+    {
+        $this->muteConversionExceptions = $muteConversionExceptions;
+    }
+
+    public function parseMessage(Element $root): Message
+    {
+        $message = new Message($root);
+        $message->setMuteConversionExceptions($this->muteConversionExceptions);
+
+        return $message;
+    }
+}
diff --git a/src/MAPI/Mime/Swiftmailer/Message.php b/src/MAPI/Mime/Swiftmailer/Message.php
index 9ec5a08..5b73542 100644
--- a/src/MAPI/Mime/Swiftmailer/Message.php
+++ b/src/MAPI/Mime/Swiftmailer/Message.php
@@ -1,377 +1,352 @@
-<?php
-
-namespace Hfig\MAPI\Mime\Swiftmailer;
-
-use Hfig\MAPI\Message\Message as BaseMessage;
-use Hfig\MAPI\Mime\HeaderCollection;
-use Hfig\MAPI\Mime\MimeConvertible;
-use Hfig\MAPI\Mime\Swiftmailer\Adapter\DependencySet;
-
-
-// maybe should use decorator pattern? lots to reimplement then though
-
-class Message extends BaseMessage implements MimeConvertible
-{
-    protected $conversionExceptionsList = [];
-    protected $muteConversionExceptions = false;
-
-    public static function wrap(BaseMessage $message)
-    {
-        if ($message instanceof MimeConvertible) {
-            return $message;
-        }
-
-        return new self($message->obj, $message->parent);
-    }
-
-    public function toMime(): \Swift_Message
-    {
-        DependencySet::register();
-
-        $message = new \Swift_Message();
-        $message->setEncoder(new \Swift_Mime_ContentEncoder_RawContentEncoder());
-
-
-        // get headers
-        $headers = $this->translatePropertyHeaders();
-
-        // add them to the message
-        $add = [$message, 'setTo']; // function
-        try {
-            $this->addRecipientHeaders('To', $headers, $add);
-        }
-        catch (\Swift_RfcComplianceException $e) {
-            if (!$this->muteConversionExceptions) {
-                throw $e;
-            }
-            $this->conversionExceptionsList[] = $e;
-        }
-        $headers->unset('To');
-
-        $add = [$message, 'setCc']; // function
-        try {
-            $this->addRecipientHeaders('Cc', $headers, $add);
-        }
-        catch (\Swift_RfcComplianceException $e) {
-            if (!$this->muteConversionExceptions) {
-                throw $e;
-            }
-            $this->conversionExceptionsList[] = $e;
-        }
-        $headers->unset('Cc');
-
-        $add = [$message, 'setBcc']; // function
-        try {
-            $this->addRecipientHeaders('Bcc', $headers, $add);
-        }
-        catch (\Swift_RfcComplianceException $e) {
-            if (!$this->muteConversionExceptions) {
-                throw $e;
-            }
-            $this->conversionExceptionsList[] = $e;}
-        $headers->unset('Bcc');
-
-        $add = [$message, 'setFrom']; // function
-        try {
-            $this->addRecipientHeaders('From', $headers, $add);
-        }
-        catch (\Swift_RfcComplianceException $e) {
-            if (!$this->muteConversionExceptions) {
-                throw $e;
-            }
-            $this->conversionExceptionsList[] = $e;
-        }
-        $headers->unset('From');
-
-
-        try {
-            $message->setId(trim($headers->getValue('Message-ID'), '<>'));
-        }
-        catch (\Swift_RfcComplianceException $e) {
-            if (!$this->muteConversionExceptions) {
-                throw $e;
-            }
-            $this->conversionExceptionsList[] = $e;
-        }
-
-        try {
-            $message->setDate(new \DateTime($headers->getValue('Date')));
-        }
-        catch (\Exception $e) { // the \DateTime can throw \Exception
-            if (!$this->muteConversionExceptions) {
-                throw $e;
-            }
-            $this->conversionExceptionsList[] = $e;
-        }
-
-        if ($boundary = $this->getMimeBoundary($headers)) {
-            $message->setBoundary($boundary);
-        }
-
-
-        $headers->unset('Message-ID');
-        $headers->unset('Date');
-        $headers->unset('Mime-Version');
-        $headers->unset('Content-Type');
-
-        $add = [$message->getHeaders(), 'addTextHeader'];
-        $this->addPlainHeaders($headers, $add);
-
-
-        // body
-        $hasHtml = false;
-        $bodyBoundary = '';
-        if ($boundary) {
-            if (preg_match('~^_(\d\d\d)_([^_]+)_~', $boundary, $matches)) {
-                $bodyBoundary = sprintf('_%03d_%s_', (int)$matches[1]+1, $matches[2]);
-            }
-        }
-        try {
-            $html = $this->getBodyHTML();
-            if ($html) {
-                $hasHtml = true;
-            }
-        }
-        catch (\Exception $e) { // getBodyHTML() can throw \Exception
-            if (!$this->muteConversionExceptions) {
-                throw $e;
-            }
-            $this->conversionExceptionsList[] = $e;
-        }
-
-        if (!$hasHtml) {
-            try {
-                $message->setBody($this->getBody(), 'text/plain');
-            }
-            catch (\Exception $e) { // getBody() can throw \Exception
-                if (!$this->muteConversionExceptions) {
-                    throw $e;
-                }
-                $this->conversionExceptionsList[] = $e;
-            }
-        }
-        else {
-            // build multi-part
-            // (simple method is to just call addPart() on message but we can't control the ID
-            $multipart = new \Swift_Attachment();
-            $multipart->setContentType('multipart/alternative');
-            $multipart->setEncoder($message->getEncoder());
-            if ($bodyBoundary) {
-                $multipart->setBoundary($bodyBoundary);
-            }
-            try {
-                $multipart->setBody($this->getBody(), 'text/plain');
-            }
-            catch (\Exception $e) { // getBody() can throw \Exception
-                if (!$this->muteConversionExceptions) {
-                    throw $e;
-                }
-                $this->conversionExceptionsList[] = $e;
-            }
-
-            $part = new \Swift_MimePart($html, 'text/html', null);
-            $part->setEncoder($message->getEncoder());
-
-
-            $message->attach($multipart);
-            $multipart->setChildren(array_merge($multipart->getChildren(), [$part]));
-        }
-
-
-        // attachments
-        foreach ($this->getAttachments() as $a) {
-            $wa = Attachment::wrap($a);
-            $attachment = $wa->toMime();
-
-            $message->attach($attachment);
-        }
-
-        return $message;
-    }
-
-    public function toMimeString(): string
-    {
-        return (string) $this->toMime();
-    }
-
-    public function copyMimeToStream($stream)
-    {
-        // TODO: use \Swift_Message::toByteStream instead
-        fwrite($stream, $this->toMimeString());
-    }
-
-    public function setMuteConversionExceptions(bool $muteConversionExceptions)
-    {
-        $this->muteConversionExceptions = $muteConversionExceptions;
-    }
-
-    protected function addRecipientHeaders($field, HeaderCollection $headers, callable $add)
-    {
-        $recipient = $headers->getValue($field);
-
-        if (is_null($recipient)) {
-            return;
-        }
-
-        if (!is_array($recipient)) {
-            $recipient = [$recipient];
-        }
-
-
-        $map = [];
-        foreach ($recipient as $r) {
-            if (preg_match('/^((?:"[^"]*")|.+) (<.+>)$/', $r, $matches)) {
-                $map[trim($matches[2], '<>')] = $matches[1];
-            }
-            else {
-                $map[] = $r;
-            }
-        }
-
-        $add($map);
-    }
-
-    protected function addPlainHeaders(HeaderCollection $headers, callable $add)
-    {
-        foreach ($headers as $key => $value)
-        {
-            if (is_array($value)) {
-                foreach ($value as $ikey => $ivalue) {
-                    $header = $ivalue->rawkey;
-                    $value  = $ivalue->value;
-                    $add($header, $value);
-                }
-            }
-            else {
-                $header = $value->rawkey;
-                $value  = $value->value;
-                $add($header, $value);
-            }
-        }
-    }
-
-    protected function translatePropertyHeaders()
-    {
-        $rawHeaders = new HeaderCollection();
-
-        // additional headers - they can be multiple lines
-        $transport = [];
-        $transportKey = 0;
-
-        $transportRaw = explode("\r\n", $this->properties['transport_message_headers']);
-        foreach ($transportRaw as $v) {
-            if (!$v) continue;
-
-            if ($v[0] !== "\t" && $v[0] !== ' ') {
-                $transportKey++;
-                $transport[$transportKey] = $v;
-            }
-            else {
-                $transport[$transportKey] = $transport[$transportKey] . "\r\n" . $v;
-            }
-        }
-
-        foreach ($transport as $header) {
-            $rawHeaders->add($header);
-        }
-
-
-
-        // sender
-        $senderType = $this->properties['sender_addrtype'];
-        if ($senderType == 'SMTP') {
-            $rawHeaders->set('From', $this->getSender());
-        }
-        elseif (!$rawHeaders->has('From')) {
-            if ($from = $this->getSender()) {
-                $rawHeaders->set('From', $from);
-            }
-        }
-
-
-        // recipients
-        foreach ($this->getRecipients() as $r) {
-            $rawHeaders->add($r->getType(), (string)$r);
-        }
-
-        // subject - preference to msg properties
-        if ($this->properties['subject']) {
-            $rawHeaders->set('Subject', $this->properties['subject']);
-        }
-
-        // date - preference to transport headers
-        if (!$rawHeaders->has('Date')) {
-            $date = $this->properties['message_delivery_time'] ?? $this->properties['client_submit_time']
-                ?? $this->properties['last_modification_time'] ?? $this->properties['creation_time'] ?? null;
-            if (!is_null($date)) {
-                // ruby-msg suggests this is stored as an iso8601 timestamp in the message properties, not a Windows timestamp
-                $date = date('r', strtotime($date));
-                $rawHeaders->set('Date', $date);
-            }
-        }
-
-        // other headers map
-        $map = [
-            ['internet_message_id', 'Message-ID'],
-            ['in_reply_to_id',      'In-Reply-To'],
-
-            ['importance',          'Importance',  function($val) { return ($val == '1') ? null : $val; }],
-            ['priority',            'Priority',    function($val) { return ($val == '1') ? null : $val; }],
-            ['sensitivity',         'Sensitivity', function($val) { return ($val == '0') ? null : $val; }],
-
-            ['conversation_topic',  'Thread-Topic'],
-
-            //# not sure of the distinction here
-            //# :originator_delivery_report_requested ??
-            ['read_receipt_requested', 'Disposition-Notification-To', function($val) use ($rawHeaders) {
-                $from = $rawHeaders->getValue('From');
-
-                if (preg_match('/^((?:"[^"]*")|.+) (<.+>)$/', $from, $matches)) {
-                    $from = trim($matches[2], '<>');
-                }
-                return $from;
-            }]
-        ];
-        foreach ($map as $do) {
-            $value = $this->properties[$do[0]];
-            if (isset($do[2])) {
-                $value = $do[2]($value);
-            }
-            if (!is_null($value)) {
-                $rawHeaders->set($do[1], $value);
-            }
-        }
-
-        return $rawHeaders;
-
-    }
-
-    protected function getMimeBoundary(HeaderCollection $headers)
-    {
-        // firstly - use the value in the headers
-        if ($type = $headers->getValue('Content-Type')) {
-            if (preg_match('~boundary="([a-zA-z0-9\'()+_,-.\/:=? ]+)"~', $type, $matches)) {
-                return $matches[1];
-            }
-        }
-
-        // if never sent via SMTP then it has to be synthesised
-        // this is done using the message id
-        if ($mid = $headers->getValue('Message-ID')) {
-            $recount = 0;
-            $mid = preg_replace('~[^a-zA-z0-9\'()+_,-.\/:=? ]~', '', $mid, -1, $recount);
-            $mid = substr($mid, 0, 55);
-            return sprintf('_%03d_%s_', $recount, $mid);
-        }
-        return '';
-    }
-
-    /**
-     * Returns the list of conversion exceptions.
-     *
-     * @return array
-     */
-    public function getConversionExceptionsList() : array {
-        return $this->conversionExceptionsList;
-    }
-}
+<?php
+
+namespace Hfig\MAPI\Mime\Swiftmailer;
+
+use Hfig\MAPI\Message\Message as BaseMessage;
+use Hfig\MAPI\Mime\HeaderCollection;
+use Hfig\MAPI\Mime\MimeConvertible;
+use Hfig\MAPI\Mime\Swiftmailer\Adapter\DependencySet;
+
+// maybe should use decorator pattern? lots to reimplement then though
+
+class Message extends BaseMessage implements MimeConvertible
+{
+    protected $conversionExceptionsList = [];
+    protected $muteConversionExceptions = false;
+
+    public static function wrap(BaseMessage $message)
+    {
+        if ($message instanceof MimeConvertible) {
+            return $message;
+        }
+
+        return new self($message->obj, $message->parent);
+    }
+
+    public function toMime(): \Swift_Message
+    {
+        DependencySet::register();
+
+        $message = new \Swift_Message();
+        $message->setEncoder(new \Swift_Mime_ContentEncoder_RawContentEncoder());
+
+        // get headers
+        $headers = $this->translatePropertyHeaders();
+
+        // add them to the message
+        $add = $message->setTo(...); // function
+        try {
+            $this->addRecipientHeaders('To', $headers, $add);
+        } catch (\Swift_RfcComplianceException $e) {
+            if (!$this->muteConversionExceptions) {
+                throw $e;
+            }
+            $this->conversionExceptionsList[] = $e;
+        }
+        $headers->unset('To');
+
+        $add = $message->setCc(...); // function
+        try {
+            $this->addRecipientHeaders('Cc', $headers, $add);
+        } catch (\Swift_RfcComplianceException $e) {
+            if (!$this->muteConversionExceptions) {
+                throw $e;
+            }
+            $this->conversionExceptionsList[] = $e;
+        }
+        $headers->unset('Cc');
+
+        $add = $message->setBcc(...); // function
+        try {
+            $this->addRecipientHeaders('Bcc', $headers, $add);
+        } catch (\Swift_RfcComplianceException $e) {
+            if (!$this->muteConversionExceptions) {
+                throw $e;
+            }
+            $this->conversionExceptionsList[] = $e;
+        }
+        $headers->unset('Bcc');
+
+        $add = $message->setFrom(...); // function
+        try {
+            $this->addRecipientHeaders('From', $headers, $add);
+        } catch (\Swift_RfcComplianceException $e) {
+            if (!$this->muteConversionExceptions) {
+                throw $e;
+            }
+            $this->conversionExceptionsList[] = $e;
+        }
+        $headers->unset('From');
+
+        try {
+            $message->setId(trim((string) $headers->getValue('Message-ID'), '<>'));
+        } catch (\Swift_RfcComplianceException $e) {
+            if (!$this->muteConversionExceptions) {
+                throw $e;
+            }
+            $this->conversionExceptionsList[] = $e;
+        }
+
+        try {
+            $message->setDate(new \DateTime($headers->getValue('Date')));
+        } catch (\Exception $e) { // the \DateTime can throw \Exception
+            if (!$this->muteConversionExceptions) {
+                throw $e;
+            }
+            $this->conversionExceptionsList[] = $e;
+        }
+
+        if ($boundary = $this->getMimeBoundary($headers)) {
+            $message->setBoundary($boundary);
+        }
+
+        $headers->unset('Message-ID');
+        $headers->unset('Date');
+        $headers->unset('Mime-Version');
+        $headers->unset('Content-Type');
+
+        $add = [$message->getHeaders(), 'addTextHeader'];
+        $this->addPlainHeaders($headers, $add);
+
+        // body
+        $hasHtml      = false;
+        $html         = '';
+        $bodyBoundary = '';
+        if ($boundary && preg_match('~^_(\d\d\d)_([^_]+)_~', (string) $boundary, $matches)) {
+            $bodyBoundary = sprintf('_%03d_%s_', (int) $matches[1] + 1, $matches[2]);
+        }
+        try {
+            $html = $this->getBodyHTML();
+            if ($html) {
+                $hasHtml = true;
+            }
+        } catch (\Exception $e) { // getBodyHTML() can throw \Exception
+            if (!$this->muteConversionExceptions) {
+                throw $e;
+            }
+            $this->conversionExceptionsList[] = $e;
+        }
+
+        if (!$hasHtml) {
+            try {
+                $message->setBody($this->getBody(), 'text/plain');
+            } catch (\Exception $e) { // getBody() can throw \Exception
+                if (!$this->muteConversionExceptions) {
+                    throw $e;
+                }
+                $this->conversionExceptionsList[] = $e;
+            }
+        } else {
+            // build multi-part
+            // (simple method is to just call addPart() on message but we can't control the ID
+            $multipart = new \Swift_Attachment();
+            $multipart->setContentType('multipart/alternative');
+            $multipart->setEncoder($message->getEncoder());
+            if ($bodyBoundary !== '' && $bodyBoundary !== '0') {
+                $multipart->setBoundary($bodyBoundary);
+            }
+            try {
+                $multipart->setBody($this->getBody(), 'text/plain');
+            } catch (\Exception $e) { // getBody() can throw \Exception
+                if (!$this->muteConversionExceptions) {
+                    throw $e;
+                }
+                $this->conversionExceptionsList[] = $e;
+            }
+
+            $part = new \Swift_MimePart($html, 'text/html', null);
+            $part->setEncoder($message->getEncoder());
+
+            $message->attach($multipart);
+            $multipart->setChildren(array_merge($multipart->getChildren(), [$part]));
+        }
+
+        // attachments
+        foreach ($this->getAttachments() as $a) {
+            $wa         = Attachment::wrap($a);
+            $attachment = $wa->toMime();
+
+            $message->attach($attachment);
+        }
+
+        return $message;
+    }
+
+    public function toMimeString(): string
+    {
+        return (string) $this->toMime();
+    }
+
+    public function copyMimeToStream($stream): void
+    {
+        // TODO: use \Swift_Message::toByteStream instead
+        fwrite($stream, $this->toMimeString());
+    }
+
+    public function setMuteConversionExceptions(bool $muteConversionExceptions): void
+    {
+        $this->muteConversionExceptions = $muteConversionExceptions;
+    }
+
+    protected function addRecipientHeaders($field, HeaderCollection $headers, callable $add)
+    {
+        $recipient = $headers->getValue($field);
+
+        if (is_null($recipient)) {
+            return;
+        }
+
+        if (!is_array($recipient)) {
+            $recipient = [$recipient];
+        }
+
+        $map = [];
+        foreach ($recipient as $r) {
+            if (preg_match('/^((?:"[^"]*")|.+) (<.+>)$/', (string) $r, $matches)) {
+                $map[trim($matches[2], '<>')] = $matches[1];
+            } else {
+                $map[] = $r;
+            }
+        }
+
+        $add($map);
+    }
+
+    protected function addPlainHeaders(HeaderCollection $headers, callable $add)
+    {
+        foreach ($headers as $key => $value) {
+            if (is_array($value)) {
+                foreach ($value as $ikey => $ivalue) {
+                    $header = $ivalue->rawkey;
+                    $value  = $ivalue->value;
+                    $add($header, $value);
+                }
+            } else {
+                $header = $value->rawkey;
+                $value  = $value->value;
+                $add($header, $value);
+            }
+        }
+    }
+
+    protected function translatePropertyHeaders(): HeaderCollection
+    {
+        $rawHeaders = new HeaderCollection();
+
+        // additional headers - they can be multiple lines
+        $transport    = [];
+        $transportKey = 0;
+
+        $transportRaw = explode("\r\n", (string) $this->properties['transport_message_headers']);
+        foreach ($transportRaw as $v) {
+            if ($v === '' || $v === '0') {
+                continue;
+            }
+
+            if ($v[0] !== "\t" && $v[0] !== ' ') {
+                ++$transportKey;
+                $transport[$transportKey] = $v;
+            } else {
+                $transport[$transportKey] = $transport[$transportKey]."\r\n".$v;
+            }
+        }
+
+        foreach ($transport as $header) {
+            $rawHeaders->add($header);
+        }
+
+        // sender
+        $senderType = $this->properties['sender_addrtype'];
+        if ($senderType == 'SMTP') {
+            $rawHeaders->set('From', $this->getSender());
+        } elseif (!$rawHeaders->has('From')) {
+            if ($from = $this->getSender()) {
+                $rawHeaders->set('From', $from);
+            }
+        }
+
+        // recipients
+        foreach ($this->getRecipients() as $r) {
+            $rawHeaders->add($r->getType(), (string) $r);
+        }
+
+        // subject - preference to msg properties
+        if ($this->properties['subject']) {
+            $rawHeaders->set('Subject', $this->properties['subject']);
+        }
+
+        // date - preference to transport headers
+        if (!$rawHeaders->has('Date')) {
+            $date = $this->properties['message_delivery_time'] ?? $this->properties['client_submit_time']
+                                                               ?? $this->properties['last_modification_time'] ?? $this->properties['creation_time'] ?? null;
+            if (!is_null($date)) {
+                // ruby-msg suggests this is stored as an iso8601 timestamp in the message properties, not a Windows timestamp
+                $date = date('r', strtotime((string) $date));
+                $rawHeaders->set('Date', $date);
+            }
+        }
+
+        // other headers map
+        $map = [
+            ['internet_message_id', 'Message-ID'],
+            ['in_reply_to_id',      'In-Reply-To'],
+
+            ['importance',          'Importance',  fn ($val) => ($val == '1') ? null : $val],
+            ['priority',            'Priority',    fn ($val) => ($val == '1') ? null : $val],
+            ['sensitivity',         'Sensitivity', fn ($val) => ($val == '0') ? null : $val],
+
+            ['conversation_topic',  'Thread-Topic'],
+
+            // # not sure of the distinction here
+            // # :originator_delivery_report_requested ??
+            ['read_receipt_requested', 'Disposition-Notification-To', function ($val) use ($rawHeaders) {
+                $from = $rawHeaders->getValue('From');
+
+                if (preg_match('/^((?:"[^"]*")|.+) (<.+>)$/', (string) $from, $matches)) {
+                    $from = trim($matches[2], '<>');
+                }
+
+                return $from;
+            }],
+        ];
+        foreach ($map as $do) {
+            $value = $this->properties[$do[0]];
+            if (isset($do[2])) {
+                $value = $do[2]($value);
+            }
+            if (!is_null($value)) {
+                $rawHeaders->set($do[1], $value);
+            }
+        }
+
+        return $rawHeaders;
+    }
+
+    protected function getMimeBoundary(HeaderCollection $headers): string
+    {
+        // firstly - use the value in the headers
+        if (($type = $headers->getValue('Content-Type')) && preg_match('~boundary="([a-zA-z0-9\'()+_,-.\/:=? ]+)"~', (string) $type, $matches)) {
+            return $matches[1];
+        }
+
+        // if never sent via SMTP then it has to be synthesised
+        // this is done using the message id
+        if ($mid = $headers->getValue('Message-ID')) {
+            $recount = 0;
+            $mid     = preg_replace('~[^a-zA-z0-9\'()+_,-.\/:=? ]~', '', (string) $mid, -1, $recount);
+            $mid     = substr($mid, 0, 55);
+
+            return sprintf('_%03d_%s_', $recount, $mid);
+        }
+
+        return '';
+    }
+
+    /**
+     * Returns the list of conversion exceptions.
+     */
+    public function getConversionExceptionsList(): array
+    {
+        return $this->conversionExceptionsList;
+    }
+}
diff --git a/src/MAPI/OLE/CompoundDocumentElement.php b/src/MAPI/OLE/CompoundDocumentElement.php
index 40b2f94..a9e1a1e 100644
--- a/src/MAPI/OLE/CompoundDocumentElement.php
+++ b/src/MAPI/OLE/CompoundDocumentElement.php
@@ -1,54 +1,65 @@
-<?php
-
-namespace Hfig\MAPI\OLE;
-
-// interface to abstract IPersistStorage/IPersistStream data
-// elements in an OLE Compound Document
-// PEAR::OLE refers to these as PPS elements
-
-interface CompoundDocumentElement
-{
-    const TYPE_ROOT      = 5;
-    const TYPE_DIRECTORY = 1;
-    const TYPE_FILE      = 2;
-
-    public function getIndex();
-    public function setIndex($index);
-
-    public function getName();
-    public function setName($name);
-
-    public function getType();    
-    public function setType($type); 
-
-    public function isFile();
-    public function isDirectory();
-    public function isRoot();
-    
-    public function getPreviousIndex();
-    public function setPreviousIndex($index);
-
-    public function getNextIndex();
-    public function setNextIndex($index);
- 
-    public function getFirstChildIndex();
-    public function setFirstChildIndex($index);
-
-    public function getTimeCreated();
-    public function setTimeCreated($time);
-
-    public function getTimeModified();
-    public function setTimeModified($time);
- 
-    // private, so no setter interface
-    public function getStartBlock();
-    
-    public function getSize();
-    public function setSize($size);
-
-    /** @return Pear\DocumentElementCollection */
-    public function getChildren();
-    public function getData();
-
-    public function saveToStream($stream);
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE;
+
+// interface to abstract IPersistStorage/IPersistStream data
+// elements in an OLE Compound Document
+// PEAR::OLE refers to these as PPS elements
+
+interface CompoundDocumentElement
+{
+    public const TYPE_ROOT      = 5;
+    public const TYPE_DIRECTORY = 1;
+    public const TYPE_FILE      = 2;
+
+    public function getIndex();
+
+    public function setIndex($index);
+
+    public function getName();
+
+    public function setName($name);
+
+    public function getType();
+
+    public function setType($type);
+
+    public function isFile();
+
+    public function isDirectory();
+
+    public function isRoot();
+
+    public function getPreviousIndex();
+
+    public function setPreviousIndex($index);
+
+    public function getNextIndex();
+
+    public function setNextIndex($index);
+
+    public function getFirstChildIndex();
+
+    public function setFirstChildIndex($index);
+
+    public function getTimeCreated();
+
+    public function setTimeCreated($time);
+
+    public function getTimeModified();
+
+    public function setTimeModified($time);
+
+    // private, so no setter interface
+    public function getStartBlock();
+
+    public function getSize();
+
+    public function setSize($size);
+
+    public function getChildren(): Pear\DocumentElementCollection;
+
+    public function getData(): string;
+
+    public function saveToStream($stream);
+}
diff --git a/src/MAPI/OLE/CompoundDocumentFactory.php b/src/MAPI/OLE/CompoundDocumentFactory.php
index f3ac156..91f52e8 100644
--- a/src/MAPI/OLE/CompoundDocumentFactory.php
+++ b/src/MAPI/OLE/CompoundDocumentFactory.php
@@ -1,9 +1,10 @@
-<?php
-
-namespace Hfig\MAPI\OLE;
-
-interface CompoundDocumentFactory
-{
-    public function createFromFile($file): CompoundDocumentElement;
-    public function createFromStream($stream): CompoundDocumentElement;
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE;
+
+interface CompoundDocumentFactory
+{
+    public function createFromFile($file): CompoundDocumentElement;
+
+    public function createFromStream($stream): CompoundDocumentElement;
+}
diff --git a/src/MAPI/OLE/Guid/OleGuid.php b/src/MAPI/OLE/Guid/OleGuid.php
index d707cf1..6d9296d 100644
--- a/src/MAPI/OLE/Guid/OleGuid.php
+++ b/src/MAPI/OLE/Guid/OleGuid.php
@@ -1,35 +1,34 @@
-<?php
-
-namespace Hfig\MAPI\OLE\Guid;
-
-use Ramsey\Uuid\UuidFactory;
-use Ramsey\Uuid\Codec\GuidStringCodec;
-use Ramsey\Uuid\UuidInterface as OleGuidInterface;
-
-class OleGuid
-{
-    /** @var UuidFactory */
-    private static $factory = null;
-
-    protected static function getFactory(): UuidFactory
-    {
-        if (!self::$factory) {
-            self::$factory = new UuidFactory();
-            self::$factory->setCodec(
-                new GuidStringCodec(self::$factory->getUuidBuilder())
-            );
-        }
-
-        return self::$factory;
-    }
-
-    public static function fromBytes($bytes): OleGuidInterface
-    {
-        return self::getFactory()->fromBytes($bytes);
-    }
-
-    public static function fromString($guid): OleGuidInterface
-    {
-        return self::getFactory()->fromString($guid);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE\Guid;
+
+use Ramsey\Uuid\Codec\GuidStringCodec;
+use Ramsey\Uuid\UuidFactory;
+use Ramsey\Uuid\UuidInterface as OleGuidInterface;
+
+class OleGuid
+{
+    private static ?UuidFactory $factory = null;
+
+    protected static function getFactory(): UuidFactory
+    {
+        if (!self::$factory) {
+            self::$factory = new UuidFactory();
+            self::$factory->setCodec(
+                new GuidStringCodec(self::$factory->getUuidBuilder()),
+            );
+        }
+
+        return self::$factory;
+    }
+
+    public static function fromBytes(string $bytes): OleGuidInterface
+    {
+        return self::getFactory()->fromBytes($bytes);
+    }
+
+    public static function fromString(string $guid): OleGuidInterface
+    {
+        return self::getFactory()->fromString($guid);
+    }
+}
diff --git a/src/MAPI/OLE/Pear/DocumentElement.php b/src/MAPI/OLE/Pear/DocumentElement.php
index b650449..8f0ed4d 100644
--- a/src/MAPI/OLE/Pear/DocumentElement.php
+++ b/src/MAPI/OLE/Pear/DocumentElement.php
@@ -1,199 +1,187 @@
-<?php
-
-namespace Hfig\MAPI\OLE\Pear;
-
-use Hfig\MAPI\OLE\CompoundDocumentElement;
-use OLE;
-use OLE_PPS;
-use OLE_PPS_Root;
-
-class DocumentElement implements CompoundDocumentElement
-{
-    /** @var OLE_PPS */
-    private $pps;
-
-    /** @var OLE */
-    private $ole;
-
-    /** @var DocumentElementCollection */
-    //private $wrappedChildren;
-
-    // the OLE file reference is required because the member ->ole on the PPS
-    // element is never actually set (ie is a bug in PEAR::OLE)
-    public function __construct(OLE $file, OLE_PPS $pps)
-    {
-        $this->pps = $pps;
-        $this->ole = $file;
-        //$this->wrappedChildren = null;
-    }
-
-    public function getIndex()
-    {
-        return $this->pps->No;
-    }
-
-    public function setIndex($index): void
-    {
-        $this->pps->No = $index;
-    }
-
-    public function getName()
-    {
-        return $this->pps->Name;
-    }
-
-    public function setName($name): void
-    {
-        $this->pps->Name = $name;
-    }
-
-    public function getType(): ?int
-    {
-        static $map = [
-            OLE_PPS_TYPE_ROOT =>  CompoundDocumentElement::TYPE_ROOT,
-            OLE_PPS_TYPE_DIR  =>  CompoundDocumentElement::TYPE_DIRECTORY,
-            OLE_PPS_TYPE_FILE =>  CompoundDocumentElement::TYPE_FILE,
-        ];
-
-        return $map[$this->pps->Type] ?? null;
-    }
-
-    public function setType($type): void
-    {
-        static $map = [
-            CompoundDocumentElement::TYPE_ROOT => OLE_PPS_TYPE_ROOT,
-            CompoundDocumentElement::TYPE_DIRECTORY => OLE_PPS_TYPE_DIR,
-            CompoundDocumentElement::TYPE_FILE => OLE_PPS_TYPE_FILE ,
-        ];
-
-        if (!isset($map[$type])) {
-            throw new \InvalidArgumentException(sprintf('Unknown document element type "%d"', $type));
-        }
-
-        $this->pps->Type = $map[$type];
-    }
-
-    public function isDirectory(): bool
-    {
-        return ($this->getType() == CompoundDocumentElement::TYPE_DIRECTORY);
-    }
-
-    public function isFile(): bool
-    {
-        return ($this->getType() == CompoundDocumentElement::TYPE_FILE);
-    }
-
-    public function isRoot(): bool
-    {
-        return ($this->getType() == CompoundDocumentElement::TYPE_ROOT);
-    }
-    
-    public function getPreviousIndex()
-    {
-        return $this->pps->PrevPps;
-    }
-
-    public function setPreviousIndex($index): void
-    {
-        $this->pps->PrevPps = $index;
-    }
-
-    public function getNextIndex()
-    {
-        return $this->pps->NextPps;
-    }
-
-    public function setNextIndex($index): void
-    {
-        $this->pps->NextPps = $index;
-    }
- 
-    public function getFirstChildIndex()
-    {
-        return $this->pps->DirPps;
-    }
-
-    public function setFirstChildIndex($index): void
-    {
-        $this->pps->DirPps = $index;
-    }
-
-    public function getTimeCreated()
-    {
-        return $this->pps->Time1st;
-    }
-
-    public function setTimeCreated($time): void
-    {
-        $this->pps->Time1st = $time;
-    }
-
-    public function getTimeModified()
-    {
-        return $this->pps->Time2nd;
-    }
-
-    public function setTimeModified($time): void
-    {
-        $this->pps->Time2nd = $time;
-    }
- 
-    // private, so no setter interface
-    public function getStartBlock()
-    {
-        return $this->pps->_StartBlock;
-    }
-    
-    public function getSize()
-    {
-        return $this->pps->Size;
-    }
-
-    public function setSize($size): void
-    {
-        $this->pps->Size = $size;
-    }
-
-    public function getChildren(): DocumentElementCollection
-    {
-        //if (!$this->wrappedChildren) {
-        //    $this->wrappedChildren = new DocumentElementCollection($this->ole, $this->pps->Children);
-        //}
-        //return $this->wrappedChildren;
-
-        return new DocumentElementCollection($this->ole, $this->pps->children);
-    }
-
-    public function getData()
-    {
-        //echo sprintf('Reading data for %s: index: %d, start: 0, length: %d'."\n", $this->getName(), $this->getIndex(), $this->getSize());
-
-        return $this->ole->getData($this->getIndex(), 0, $this->getSize());
-    }
-
-    public function unwrap()
-    {
-        return $this->pps;
-    }
-
-    public function saveToStream($stream): void
-    {
-        
-
-        $root = new OLE_PPS_Root($this->pps->Time1st, $this->pps->Time2nd, $this->pps->children);
-
-        // nasty Pear_OLE actually writes out a temp file and fpassthru's on it. Yuck.
-        // so let's give a wrapped stream which ignores Pear_OLE's fopen() and fclose()
-        $wrappedStreamUrl = StreamWrapper::wrapStream($stream, 'r');
-        $root->save($wrappedStreamUrl);
-
-        /*ob_start();
-        try {
-            $root->save('');
-            fwrite($stream, ob_get_clean());
-        }
-        finally {
-            ob_end_clean();
-        }*/
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE\Pear;
+
+use Hfig\MAPI\OLE\CompoundDocumentElement;
+use OLE;
+
+class DocumentElement implements CompoundDocumentElement
+{
+    // ** @var DocumentElementCollection */
+    // private $wrappedChildren;
+
+    // the OLE file reference is required because the member ->ole on the PPS
+    // element is never actually set (ie is a bug in PEAR::OLE)
+    public function __construct(private readonly \OLE $ole, private readonly \OLE_PPS $pps)
+    {
+        // $this->wrappedChildren = null;
+    }
+
+    public function getIndex()
+    {
+        return $this->pps->No;
+    }
+
+    public function setIndex($index): void
+    {
+        $this->pps->No = $index;
+    }
+
+    public function getName()
+    {
+        return $this->pps->Name;
+    }
+
+    public function setName($name): void
+    {
+        $this->pps->Name = $name;
+    }
+
+    public function getType(): ?int
+    {
+        static $map = [
+            OLE_PPS_TYPE_ROOT => CompoundDocumentElement::TYPE_ROOT,
+            OLE_PPS_TYPE_DIR  => CompoundDocumentElement::TYPE_DIRECTORY,
+            OLE_PPS_TYPE_FILE => CompoundDocumentElement::TYPE_FILE,
+        ];
+
+        return $map[$this->pps->Type] ?? null;
+    }
+
+    public function setType($type): void
+    {
+        static $map = [
+            CompoundDocumentElement::TYPE_ROOT      => OLE_PPS_TYPE_ROOT,
+            CompoundDocumentElement::TYPE_DIRECTORY => OLE_PPS_TYPE_DIR,
+            CompoundDocumentElement::TYPE_FILE      => OLE_PPS_TYPE_FILE,
+        ];
+
+        if (!isset($map[$type])) {
+            throw new \InvalidArgumentException(sprintf('Unknown document element type "%d"', $type));
+        }
+
+        $this->pps->Type = $map[$type];
+    }
+
+    public function isDirectory(): bool
+    {
+        return $this->getType() == CompoundDocumentElement::TYPE_DIRECTORY;
+    }
+
+    public function isFile(): bool
+    {
+        return $this->getType() == CompoundDocumentElement::TYPE_FILE;
+    }
+
+    public function isRoot(): bool
+    {
+        return $this->getType() == CompoundDocumentElement::TYPE_ROOT;
+    }
+
+    public function getPreviousIndex()
+    {
+        return $this->pps->PrevPps;
+    }
+
+    public function setPreviousIndex($index): void
+    {
+        $this->pps->PrevPps = $index;
+    }
+
+    public function getNextIndex()
+    {
+        return $this->pps->NextPps;
+    }
+
+    public function setNextIndex($index): void
+    {
+        $this->pps->NextPps = $index;
+    }
+
+    public function getFirstChildIndex()
+    {
+        return $this->pps->DirPps;
+    }
+
+    public function setFirstChildIndex($index): void
+    {
+        $this->pps->DirPps = $index;
+    }
+
+    public function getTimeCreated()
+    {
+        return $this->pps->Time1st;
+    }
+
+    public function setTimeCreated($time): void
+    {
+        $this->pps->Time1st = $time;
+    }
+
+    public function getTimeModified()
+    {
+        return $this->pps->Time2nd;
+    }
+
+    public function setTimeModified($time): void
+    {
+        $this->pps->Time2nd = $time;
+    }
+
+    // private, so no setter interface
+    public function getStartBlock()
+    {
+        return $this->pps->_StartBlock;
+    }
+
+    public function getSize()
+    {
+        return $this->pps->Size;
+    }
+
+    public function setSize($size): void
+    {
+        $this->pps->Size = $size;
+    }
+
+    public function getChildren(): DocumentElementCollection
+    {
+        // if (!$this->wrappedChildren) {
+        //    $this->wrappedChildren = new DocumentElementCollection($this->ole, $this->pps->Children);
+        // }
+        // return $this->wrappedChildren;
+
+        return new DocumentElementCollection($this->ole, $this->pps->children);
+    }
+
+    public function getData(): string
+    {
+        // echo sprintf('Reading data for %s: index: %d, start: 0, length: %d'."\n", $this->getName(), $this->getIndex(), $this->getSize());
+
+        return $this->ole->getData($this->getIndex(), 0, $this->getSize());
+    }
+
+    public function unwrap(): \OLE_PPS
+    {
+        return $this->pps;
+    }
+
+    public function saveToStream($stream): void
+    {
+        $root = new \OLE_PPS_Root($this->pps->Time1st, $this->pps->Time2nd, $this->pps->children);
+
+        // nasty Pear_OLE actually writes out a temp file and fpassthru's on it. Yuck.
+        // so let's give a wrapped stream which ignores Pear_OLE's fopen() and fclose()
+        $wrappedStreamUrl = StreamWrapper::wrapStream($stream, 'r');
+        $root->save($wrappedStreamUrl);
+
+        /*ob_start();
+        try {
+            $root->save('');
+            fwrite($stream, ob_get_clean());
+        }
+        finally {
+            ob_end_clean();
+        }*/
+    }
+}
diff --git a/src/MAPI/OLE/Pear/DocumentElementCollection.php b/src/MAPI/OLE/Pear/DocumentElementCollection.php
index b15ecc5..93dd261 100644
--- a/src/MAPI/OLE/Pear/DocumentElementCollection.php
+++ b/src/MAPI/OLE/Pear/DocumentElementCollection.php
@@ -1,69 +1,54 @@
-<?php
-
-namespace Hfig\MAPI\OLE\Pear;
-
-use OLE;
-
-class DocumentElementCollection implements \ArrayAccess, \IteratorAggregate
-{
-    /** @var OLE */
-    private $ole;
-    private $col = [];
-    private $proxy_col = [];
-
-    public function __construct(OLE $ole, array &$collection = null)
-    {
-        if (is_null($collection)) {
-            $tmpcol = [];
-            $collection =& $tmpcol;
-        }
-        $this->col = &$collection;
-        $this->ole = $ole;
-    }
-
-    public function getIterator(): \Traversable
-    {
-        foreach ($this->col as $k => $v)
-        {
-            yield $k => $this->offsetGet($k);
-        }
-    }
-
-    public function offsetExists($offset): bool
-    {
-        return isset($this->col[$offset]);
-    }
-
-    /**
-     * @return mixed
-     */
-    #[\ReturnTypeWillChange]
-    public function offsetGet($offset)
-    {
-        if (!isset($this->col[$offset])) {
-            return null;
-        }
-
-        if (!isset($this->proxy_col[$offset])) {
-            $this->proxy_col[$offset] = new DocumentElement($this->ole, $this->col[$offset]);
-        }
-
-        return $this->proxy_col[$offset];
-    }
-
-    public function offsetSet($offset, $value): void
-    {
-        if (!$value instanceof DocumentElement) {
-            throw new \InvalidArgumentException('Collection must contain DocumentElement instances');
-        }
-
-        $this->proxy_col[$offset] = $value;
-        $this->col[$offset] = $value->unwrap();
-    }
-
-    public function offsetUnset($offset): void
-    {
-        unset($this->proxy_col[$offset]);
-        unset($this->col[$offset]);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE\Pear;
+
+class DocumentElementCollection implements \ArrayAccess, \IteratorAggregate
+{
+    private array $col;
+    private array $proxy_col = [];
+
+    public function __construct(private readonly \OLE $ole, ?array $collection = null)
+    {
+        $this->col = $collection ?? [];
+    }
+
+    public function getIterator(): \Traversable
+    {
+        foreach ($this->col as $k => $v) {
+            yield $k => $this->offsetGet($k);
+        }
+    }
+
+    public function offsetExists($offset): bool
+    {
+        return isset($this->col[$offset]);
+    }
+
+    public function offsetGet($offset): mixed
+    {
+        if (!isset($this->col[$offset])) {
+            return null;
+        }
+
+        if (!isset($this->proxy_col[$offset])) {
+            $this->proxy_col[$offset] = new DocumentElement($this->ole, $this->col[$offset]);
+        }
+
+        return $this->proxy_col[$offset];
+    }
+
+    public function offsetSet($offset, $value): void
+    {
+        if (!$value instanceof DocumentElement) {
+            throw new \InvalidArgumentException('Collection must contain DocumentElement instances');
+        }
+
+        $this->proxy_col[$offset] = $value;
+        $this->col[$offset]       = $value->unwrap();
+    }
+
+    public function offsetUnset($offset): void
+    {
+        unset($this->proxy_col[$offset], $this->col[$offset]);
+    }
+}
diff --git a/src/MAPI/OLE/Pear/DocumentFactory.php b/src/MAPI/OLE/Pear/DocumentFactory.php
index c031c36..b828909 100644
--- a/src/MAPI/OLE/Pear/DocumentFactory.php
+++ b/src/MAPI/OLE/Pear/DocumentFactory.php
@@ -1,30 +1,28 @@
-<?php
-
-namespace Hfig\MAPI\OLE\Pear;
-
-use Hfig\MAPI\OLE\CompoundDocumentFactory;
-use Hfig\MAPI\OLE\CompoundDocumentElement;
-
-use OLE;
-
-class DocumentFactory implements CompoundDocumentFactory
-{
-    public function createFromFile($file): CompoundDocumentElement
-    {
-        $ole = new OLE();
-        $ole->read($file);
-
-        return new DocumentElement($ole, $ole->root);
-    }
-
-    public function createFromStream($stream): CompoundDocumentElement
-    {
-        // PHP buffering appears to prevent us using this wrapper - sometimes fseek() is not called
-        //$wrappedStreamUrl = StreamWrapper::wrapStream($stream, 'r');
-
-        $ole = new OLE();
-        $ole->readStream($stream);
-
-        return new DocumentElement($ole, $ole->root);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE\Pear;
+
+use Hfig\MAPI\OLE\CompoundDocumentElement;
+use Hfig\MAPI\OLE\CompoundDocumentFactory;
+
+class DocumentFactory implements CompoundDocumentFactory
+{
+    public function createFromFile($file): CompoundDocumentElement
+    {
+        $ole = new \OLE();
+        $ole->read($file);
+
+        return new DocumentElement($ole, $ole->root);
+    }
+
+    public function createFromStream($stream): CompoundDocumentElement
+    {
+        // PHP buffering appears to prevent us using this wrapper - sometimes fseek() is not called
+        // $wrappedStreamUrl = StreamWrapper::wrapStream($stream, 'r');
+
+        $ole = new \OLE();
+        $ole->readStream($stream);
+
+        return new DocumentElement($ole, $ole->root);
+    }
+}
diff --git a/src/MAPI/OLE/Pear/StreamWrapper.php b/src/MAPI/OLE/Pear/StreamWrapper.php
index 0dce89c..be8b04e 100644
--- a/src/MAPI/OLE/Pear/StreamWrapper.php
+++ b/src/MAPI/OLE/Pear/StreamWrapper.php
@@ -1,147 +1,128 @@
-<?php
-
-namespace Hfig\MAPI\OLE\Pear;
-
-class StreamWrapper 
-{
-    const PROTOCOL = 'olewrap';
-
-    private $stream;
-    public $context;
-    private $mode;
-    private $buffer;
-    private $position;
-
-
-    private static $handles = [];
-
-    public static function wrapStream($stream, $mode): string
-    {
-        self::register();
-
-        $data = ['mode' => $mode, 'stream' => $stream];
-        self::$handles[] = $data;
-
-        end(self::$handles);
-        $key = key(self::$handles);
-
-        return 'olewrap://stream/' . (string)$key;
-    }
-
-    /**
-     * @return resource
-     */
-    public static function createStreamContext($stream)
-    {
-        return stream_context_create([
-            'olewrap' => ['stream' => $stream]
-        ]);
-    }
-
-    public static function register(): void
-    {
-        if (!in_array('olewrap', stream_get_wrappers())) {
-            stream_wrapper_register('olewrap', __CLASS__);
-        }
-    }
-
-    public function stream_cast($cast_as)
-    {
-        return $this->stream;
-    }
-
-
-    public function stream_open($path, $mode, $options, &$opened_path): bool
-    { 
-        $url = parse_url($path);
-        $streampath = [];
-        $handle = null;
-        
-        
-        if (isset($url['path'])) {
-            $streampath = explode('/', $url['path']);
-        }
-        if (isset($streampath[1])) {
-            $handle = $streampath[1];
-        }
-        if (isset($handle) && isset(self::$handles[$handle])) {
-            $this->stream = self::$handles[$handle]['stream'];
-
-            if ($mode[0] == 'r' || $mode[0] == 'a') {
-                fseek($this->stream, 0);            
-            }
-
-            $this->buffer = '';
-            $this->position = 0;
-                    
-            
-
-            return true;
-        }
-
-        return false;
-    }
-
-    public function stream_read($count): string
-    {
-        // always read a block to satisfy the buffer
-        $this->buffer = fread($this->stream, 8192);
-
-
-        return substr($this->buffer, 0, $count);
-    }
-
-    /**
-     * @return false|int
-     */
-    public function stream_write($data)
-    {
-        return fwrite($this->stream, $data);
-    }
-
-    /**
-     * @return false|int
-     */
-    public function stream_tell()
-    {
-        return ftell($this->stream);
-    }
-
-    public function stream_eof(): bool
-    {
-        return feof($this->stream);
-    }
-
-    public function stream_seek($offset, $whence): int
-    {
-        //echo 'seeking on parent stream (' . $offset . '  ' . $whence . ')'."\n";
-        return fseek($this->stream, $offset, $whence);
-    }
-
-    /**
-     * @return array|false
-     */
-    public function stream_stat()
-    {
-        return fstat($this->stream);
-    }
-
-    public function url_stat($path, $flags): array
-    {
-        return [
-            'dev'     => 0,
-            'ino'     => 0,
-            'mode'    => 0,
-            'nlink'   => 0,
-            'uid'     => 0,
-            'gid'     => 0,
-            'rdev'    => 0,
-            'size'    => 0,
-            'atime'   => 0,
-            'mtime'   => 0,
-            'ctime'   => 0,
-            'blksize' => 0,
-            'blocks'  => 0
-        ];
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE\Pear;
+
+class StreamWrapper
+{
+    public const PROTOCOL = 'olewrap';
+
+    private $stream;
+    public $context;
+
+    private string|bool|null $buffer = null;
+
+    private static array $handles = [];
+
+    public static function wrapStream($stream, $mode): string
+    {
+        self::register();
+
+        $data            = ['mode' => $mode, 'stream' => $stream];
+        self::$handles[] = $data;
+        $key             = array_key_last(self::$handles);
+
+        return 'olewrap://stream/'.(string) $key;
+    }
+
+    /**
+     * @return resource
+     */
+    public static function createStreamContext($stream)
+    {
+        return stream_context_create([
+            'olewrap' => ['stream' => $stream],
+        ]);
+    }
+
+    public static function register(): void
+    {
+        if (!in_array('olewrap', stream_get_wrappers())) {
+            stream_wrapper_register('olewrap', self::class);
+        }
+    }
+
+    public function stream_cast($cast_as)
+    {
+        return $this->stream;
+    }
+
+    public function stream_open($path, $mode, $options, &$opened_path): bool
+    {
+        $url        = parse_url((string) $path);
+        $streampath = [];
+        $handle     = null;
+
+        if (isset($url['path'])) {
+            $streampath = explode('/', $url['path']);
+        }
+        if (isset($streampath[1])) {
+            $handle = $streampath[1];
+        }
+        if (isset($handle) && isset(self::$handles[$handle])) {
+            $this->stream = self::$handles[$handle]['stream'];
+
+            if ($mode[0] == 'r' || $mode[0] == 'a') {
+                fseek($this->stream, 0);
+            }
+
+            $this->buffer = '';
+
+            return true;
+        }
+
+        return false;
+    }
+
+    public function stream_read($count): string
+    {
+        // always read a block to satisfy the buffer
+        $this->buffer = fread($this->stream, 8192);
+
+        return substr($this->buffer, 0, $count);
+    }
+
+    public function stream_write($data): int|false
+    {
+        return fwrite($this->stream, (string) $data);
+    }
+
+    public function stream_tell(): int|false
+    {
+        return ftell($this->stream);
+    }
+
+    public function stream_eof(): bool
+    {
+        return feof($this->stream);
+    }
+
+    public function stream_seek($offset, $whence): int
+    {
+        // echo 'seeking on parent stream (' . $offset . '  ' . $whence . ')'."\n";
+        return fseek($this->stream, $offset, $whence);
+    }
+
+    public function stream_stat(): array|false
+    {
+        return fstat($this->stream);
+    }
+
+    public function url_stat($path, $flags): array
+    {
+        return [
+            'dev'     => 0,
+            'ino'     => 0,
+            'mode'    => 0,
+            'nlink'   => 0,
+            'uid'     => 0,
+            'gid'     => 0,
+            'rdev'    => 0,
+            'size'    => 0,
+            'atime'   => 0,
+            'mtime'   => 0,
+            'ctime'   => 0,
+            'blksize' => 0,
+            'blocks'  => 0,
+        ];
+    }
+}
diff --git a/src/MAPI/OLE/RTF/CompressionCodec.php b/src/MAPI/OLE/RTF/CompressionCodec.php
index 103c6e5..a66583e 100644
--- a/src/MAPI/OLE/RTF/CompressionCodec.php
+++ b/src/MAPI/OLE/RTF/CompressionCodec.php
@@ -1,110 +1,105 @@
-<?php
-
-namespace Hfig\MAPI\OLE\RTF;
-
-class CompressionCodec
-{
-    public const DICT = "{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}" .
-                   "{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript ".
-                   "\\fdecor MS Sans SerifSymbolArialTimes New RomanCourier" .
-                   "{\\colortbl\\red0\\green0\\blue0\n\r\\par " .
-                   "\\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx";
-    public const BLOCKSIZE = 4096;
-    public const HEADERSIZE = 16;
-
-    // this is adapted from Java libpst instead of Ruby ruby-msg
-    private static function uncompress($raw, $compressedSize, $uncompressedSize): string
-    {
-        $buf = str_pad(self::DICT, self::BLOCKSIZE, "\0");
-        $wp  = strlen(self::DICT);
-
-        $pos = self::HEADERSIZE;
-        $data = '';
-        $eof  = strlen($raw);
-        $flags = 0;
-
-        while ($pos < $eof && strlen($data) < $uncompressedSize) {
-            $flags = ord($raw[$pos++]) & 0xFF; 
-            for ($x = 0; $x < 8; $x++) { 
-                $isRef = (($flags & 1) == 1); 
-                $flags >>= 1; 
-                
-                if ($isRef) { 
-                    // get the starting point for the buffer and the 
-                    // length to read 
-                    $refOffsetOrig = ord($raw[$pos++]) & 0xFF; 
-                    $refSizeOrig = ord($raw[$pos++]) & 0xFF; 
-                    $refOffset = ($refOffsetOrig << 4) | ($refSizeOrig >> 4); 
-                    $refSize = ($refSizeOrig & 0xF) + 2; 
-                    //$refOffset &= 0xFFF; 
-      
-                    // copy the data from the buffer 
-                    $index = $refOffset; 
-                    for ($y = 0; $y < $refSize; $y++) { 
-                        $data .= $buf[$index]; 
-
-                        if (strlen($data) >= $uncompressedSize) break;
-
-                        $buf[$wp] = $buf[$index]; 
-                        
-                        $wp    = ($wp    + 1) % self::BLOCKSIZE; 
-                        $index = ($index + 1) % self::BLOCKSIZE; 
-                    } 
-                }
-                else {
-                    $buf[$wp] = $raw[$pos];
-                    $wp = ($wp + 1) % self::BLOCKSIZE;
-                    
-                    $data .= $raw[$pos++];
-                }
-
-                if (strlen($data) >= $uncompressedSize) {
-                    break;
-                }
-                if ($pos >= $eof) {
-                    break;
-                }
-            }
-        }
-
-        //echo 'Decompressed: ', $data, "\n"; die();
-        return $data;
-    }
-
-    public static function decode($data): string
-    {
-        
-        $result = '';
-        //echo 'Data: ' . bin2hex($data), "\n";
-        //echo 'Len: ' . strlen($data), "\n";
-
-        $header = array_values(unpack('Vcs/Vus/a4m/Vcrc', $data));
-        [$compressedSize, $uncompressedSize, $magic, $crc32] = $header;
-
-        if ($magic == 'MELA') {
-            $data = substr($data, self::HEADERSIZE, $uncompressedSize);
-        }
-        elseif ($magic == 'LZFu') {
-            $data = self::uncompress($data, $compressedSize, $uncompressedSize);
-        }
-        else {
-            throw new \Exception('Unknown stream data type ' . $magic);
-        }
-        
-        return rtrim($data, "\0");
-
-    }
-
-    /**
-     * @comment see Kopano-core Mapi4Linux or Python delimitry/compressed_rtf
-     *
-     * @return false|string
-     */
-    public static function encode($data)
-    {
-        $uncompressedSize = strlen($data);
-        $compressedSize = $uncompressedSize + self::HEADERSIZE;
-
-        return pack('V/V/a4/V/a*', $compressedSize, $uncompressedSize, 'MELA', $data);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE\RTF;
+
+class CompressionCodec
+{
+    public const DICT = '{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}'.
+        '{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript '.
+        '\\fdecor MS Sans SerifSymbolArialTimes New RomanCourier'.
+        "{\\colortbl\\red0\\green0\\blue0\n\r\\par ".
+        '\\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx';
+    public const BLOCKSIZE  = 4096;
+    public const HEADERSIZE = 16;
+
+    // this is adapted from Java libpst instead of Ruby ruby-msg
+    private static function uncompress($raw, $compressedSize, $uncompressedSize): string
+    {
+        $buf = str_pad(self::DICT, self::BLOCKSIZE, "\0");
+        $wp  = strlen(self::DICT);
+
+        $pos   = self::HEADERSIZE;
+        $data  = '';
+        $eof   = strlen((string) $raw);
+        $flags = 0;
+
+        while ($pos < $eof && strlen($data) < $uncompressedSize) {
+            $flags = ord($raw[$pos++]) & 0xFF;
+            for ($x = 0; $x < 8; ++$x) {
+                $isRef = (($flags & 1) == 1);
+                $flags >>= 1;
+
+                if ($isRef) {
+                    // get the starting point for the buffer and the
+                    // length to read
+                    $refOffsetOrig = ord($raw[$pos++]) & 0xFF;
+                    $refSizeOrig   = ord($raw[$pos++]) & 0xFF;
+                    $refOffset     = ($refOffsetOrig << 4) | ($refSizeOrig >> 4);
+                    $refSize       = ($refSizeOrig & 0xF) + 2;
+                    // $refOffset &= 0xFFF;
+
+                    // copy the data from the buffer
+                    $index = $refOffset;
+                    for ($y = 0; $y < $refSize; ++$y) {
+                        $data .= $buf[$index];
+
+                        if (strlen($data) >= $uncompressedSize) {
+                            break;
+                        }
+
+                        $buf[$wp] = $buf[$index];
+
+                        $wp    = ($wp + 1)    % self::BLOCKSIZE;
+                        $index = ($index + 1) % self::BLOCKSIZE;
+                    }
+                } else {
+                    $buf[$wp] = $raw[$pos];
+                    $wp       = ($wp + 1) % self::BLOCKSIZE;
+
+                    $data .= $raw[$pos++];
+                }
+
+                if (strlen($data) >= $uncompressedSize) {
+                    break;
+                }
+                if ($pos >= $eof) {
+                    break;
+                }
+            }
+        }
+
+        // echo 'Decompressed: ', $data, "\n"; die();
+        return $data;
+    }
+
+    public static function decode($data): string
+    {
+        $result = '';
+        // echo 'Data: ' . bin2hex($data), "\n";
+        // echo 'Len: ' . strlen($data), "\n";
+
+        $header                                              = array_values(unpack('Vcs/Vus/a4m/Vcrc', (string) $data));
+        [$compressedSize, $uncompressedSize, $magic, $crc32] = $header;
+
+        if ($magic == 'MELA') {
+            $data = substr((string) $data, self::HEADERSIZE, $uncompressedSize);
+        } elseif ($magic == 'LZFu') {
+            $data = self::uncompress($data, $compressedSize, $uncompressedSize);
+        } else {
+            throw new \Exception('Unknown stream data type '.$magic);
+        }
+
+        return rtrim($data, "\0");
+    }
+
+    /**
+     * @comment see Kopano-core Mapi4Linux or Python delimitry/compressed_rtf
+     */
+    public static function encode($data): string
+    {
+        $uncompressedSize = strlen((string) $data);
+        $compressedSize   = $uncompressedSize + self::HEADERSIZE;
+
+        return pack('V/V/a4/V/a*', $compressedSize, $uncompressedSize, 'MELA', $data);
+    }
+}
diff --git a/src/MAPI/OLE/RTF/EmbeddedHTML.php b/src/MAPI/OLE/RTF/EmbeddedHTML.php
index 74227c5..c6cca40 100644
--- a/src/MAPI/OLE/RTF/EmbeddedHTML.php
+++ b/src/MAPI/OLE/RTF/EmbeddedHTML.php
@@ -1,93 +1,82 @@
-<?php
-
-namespace Hfig\MAPI\OLE\RTF;
-
-class EmbeddedHTML
-{
-    // the fact that this seems to work is rather amazing because it's a horrid mess!
-    // the proper format is specified by [MS-OXRTFEX]
-
-    public static function extract($data): string
-    {
-        if ($pos = strpos($data, '{\*\htmltag') === false) {
-            return '';
-        }
-
-        $html = '';
-        $ignoreTag = '';
-
-        $scanner = new StringScanner($data);
-        // fix cf ruby-msg - skip the \htmltag element's parameter
-        if ($scanner->scanUntilRegex('/\x5c\*\x5chtmltag(\d+) ?/') === false) {
-            return '';
-        }
-
-        while (!$scanner->eos()) {
-            //echo 'next 40 ' .  str_pad(str_replace(["\r","\n"], '', trim(substr((string)$scanner, 0, 40))), 40) . '          ';
-
-            if ($scanner->scan('{')) {
-                //echo 'skip {';
-            }
-            elseif ($scanner->scan('}')) {
-                //echo 'skip }';
-            }
-            
-            elseif ($scanner->scanRegex('/\x5c\*\x5chtmltag(\d+) ?/')) {
-                if ($ignoreTag == $scanner->result()[1][0]) {
-                    //echo 'duplicate. skip to }';
-                    $scanner->scanUntil('}');
-                    $ignoreTag = '';
-                }
-            }            
-            elseif ($scanner->scanRegex('/\x5c\*\x5cmhtmltag(\d+) ?/')) {
-                //echo 'set ignore on this';
-                $ignoreTag = $scanner->result()[1][0];
-            }
-            // fix cf ruby-msg - negative lookahead of \par elements so we don't match \pard
-            elseif ($scanner->scanRegex('/\x5cpar(?!\w) ?/')) {
-                //echo 'CRLF';
-                $html .= "\r\n";
-            }
-            elseif ($scanner->scanRegex('/\x5ctab ?/')) {
-                //echo 'Tab';
-                $html .= "\t";
-            }
-            elseif ($scanner->scanRegex('/\x5c\'([0-9A-Za-z]{2})/')) {
-                //echo 'Append char' . $scanner->result()[1][0];
-                $html .= chr(hexdec($scanner->result()[1][0]));
-            }
-            elseif ($scanner->scan('\pntext')) {
-                //echo 'skip to }';
-                $scanner->scanUntil('}');
-            }
-            elseif ($scanner->scanRegex('/\x5chtmlrtf1? ?/')) {
-                //echo 'skip to htmlrtf0';
-                $scanner->scanUntilRegex('/\x5chtmlrtf0 ?/');
-            }
-            //# a generic throw away unknown tags thing.
-			//# the above 2 however, are handled specially
-			elseif ($scanner->scanRegex('/\x5c[a-z-]+(\d+)? ?/')) {
-                //echo 'skip unknown tag';
-            }
-			//#elseif ($scanner->scanRegex('/\\li(\d+) ?/')) {}
-			//#elseif ($scanner->scanRegex('/\\fi-(\d+) ?/')) {}
-			elseif ($scanner->scanRegex('/\r?\n/')) {
-                //echo 'data CRLF';
-            }
-			elseif ($scanner->scanRegex('/\x5c({|}|\x5c)/')) {
-                //echo 'append special char';
-                $html .= $scanner->result()[1][0];
-            }
-            else {
-                //echo 'append';
-
-                $html .= $scanner->increment();
-            }						
-
-            //echo '    ' . substr($html, -20) . "\n";
-        }
-
-
-        return trim($html);
-    }
-}
+<?php
+
+namespace Hfig\MAPI\OLE\RTF;
+
+class EmbeddedHTML
+{
+    // the fact that this seems to work is rather amazing because it's a horrid mess!
+    // the proper format is specified by [MS-OXRTFEX]
+
+    public static function extract($data): string
+    {
+        if ($pos = !str_contains((string) $data, '{\*\htmltag')) {
+            return '';
+        }
+
+        $html      = '';
+        $ignoreTag = '';
+
+        $scanner = new StringScanner($data);
+        // fix cf ruby-msg - skip the \htmltag element's parameter
+        if ($scanner->scanUntilRegex('/\x5c\*\x5chtmltag(\d+) ?/') === false) {
+            return '';
+        }
+
+        while (!$scanner->eos()) {
+            // echo 'next 40 ' .  str_pad(str_replace(["\r","\n"], '', trim(substr((string)$scanner, 0, 40))), 40) . '          ';
+
+            if ($scanner->scan('{')) {
+                // echo 'skip {';
+            } elseif ($scanner->scan('}')) {
+                // echo 'skip }';
+            } elseif ($scanner->scanRegex('/\x5c\*\x5chtmltag(\d+) ?/')) {
+                if ($ignoreTag == $scanner->result()[1][0]) {
+                    // echo 'duplicate. skip to }';
+                    $scanner->scanUntil('}');
+                    $ignoreTag = '';
+                }
+            } elseif ($scanner->scanRegex('/\x5c\*\x5cmhtmltag(\d+) ?/')) {
+                // echo 'set ignore on this';
+                $ignoreTag = $scanner->result()[1][0];
+            }
+            // fix cf ruby-msg - negative lookahead of \par elements so we don't match \pard
+            elseif ($scanner->scanRegex('/\x5cpar(?!\w) ?/')) {
+                // echo 'CRLF';
+                $html .= "\r\n";
+            } elseif ($scanner->scanRegex('/\x5ctab ?/')) {
+                // echo 'Tab';
+                $html .= "\t";
+            } elseif ($scanner->scanRegex('/\x5c\'([0-9A-Za-z]{2})/')) {
+                // echo 'Append char' . $scanner->result()[1][0];
+                $html .= chr(hexdec((string) $scanner->result()[1][0]));
+            } elseif ($scanner->scan('\pntext')) {
+                // echo 'skip to }';
+                $scanner->scanUntil('}');
+            } elseif ($scanner->scanRegex('/\x5chtmlrtf1? ?/')) {
+                // echo 'skip to htmlrtf0';
+                $scanner->scanUntilRegex('/\x5chtmlrtf0 ?/');
+            }
+            // # a generic throw away unknown tags thing.
+            // # the above 2 however, are handled specially
+            elseif ($scanner->scanRegex('/\x5c[a-z-]+(\d+)? ?/')) {
+                // echo 'skip unknown tag';
+            }
+            // #elseif ($scanner->scanRegex('/\\li(\d+) ?/')) {}
+            // #elseif ($scanner->scanRegex('/\\fi-(\d+) ?/')) {}
+            elseif ($scanner->scanRegex('/\r?\n/')) {
+                // echo 'data CRLF';
+            } elseif ($scanner->scanRegex('/\x5c({|}|\x5c)/')) {
+                // echo 'append special char';
+                $html .= $scanner->result()[1][0];
+            } else {
+                // echo 'append';
+
+                $html .= $scanner->increment();
+            }
+
+            // echo '    ' . substr($html, -20) . "\n";
+        }
+
+        return trim($html);
+    }
+}
diff --git a/src/MAPI/OLE/RTF/StringScanner.php b/src/MAPI/OLE/RTF/StringScanner.php
index 9822874..8ac2436 100644
--- a/src/MAPI/OLE/RTF/StringScanner.php
+++ b/src/MAPI/OLE/RTF/StringScanner.php
@@ -1,89 +1,90 @@
-<?php
-
-namespace Hfig\MAPI\OLE\RTF;
-
-// this is a partial implementation of the Ruby stringscanner class
-// it seemed like a moderately useful concept, even though the
-// parser logic in the ruby-msg library (and ported) is pretty awful
-
-
-class StringScanner
-{
-    private $buffer;
-    private $pos;
-    private $last;
-
-    public function __construct($data)
-    {
-        $this->buffer = $data;
-        $this->pos = 0;
-    }
-
-    public function scan($str)
-    {
-        $len = strlen($str);
-        if (substr($this->buffer, $this->pos, $len) == $str) {
-            $this->pos += $len;
-            $this->last = $str;
-            return $this->last;
-        }
-        return false;
-    }
-
-    public function scanRegex($regex)
-    {
-        if (preg_match($regex, $this->buffer, $matches, PREG_OFFSET_CAPTURE, $this->pos)) {
-            if ($matches[0][1] == $this->pos) {
-                $this->pos += strlen($matches[0][0]);
-                $this->last = $matches;
-                return $this->last;
-            }
-        }
-        return false;
-    }
-
-    public function scanUntil($str)
-    {
-        if (($newpos = strpos($this->buffer, $str, $this->pos)) !== false) {
-            $this->last = substr($this->buffer, $this->pos, $newpos - $this->pos);
-            $this->pos = $newpos + strlen($str);
-            return $this->last;
-        }
-        return false;
-    }
-
-    public function scanUntilRegex($regex)
-    {
-        if (preg_match($regex, $this->buffer, $matches, PREG_OFFSET_CAPTURE, $this->pos)) {
-            $mlen  = strlen($matches[0][0]);
-            $this->last = substr($this->buffer, $this->pos, $matches[0][1] + $mlen);
-            $this->pos = $matches[0][1] + $mlen;
-            return $this->last;
-        }
-        return false;
-    }
-
-    public function eos(): bool
-    {
-        return $this->pos >= strlen($this->buffer);
-    }
-
-    public function increment($count = 1)
-    {
-        $this->last = substr($this->buffer, $this->pos, $count);
-        $this->pos += $count;
-        return $this->last;
-    }
-
-    public function result()
-    {
-        return $this->last;
-    }
-
-
-    public function __toString()
-    {
-        return substr($this->buffer, $this->pos);
-    }
-}
-        
+<?php
+
+namespace Hfig\MAPI\OLE\RTF;
+
+// this is a partial implementation of the Ruby stringscanner class
+// it seemed like a moderately useful concept, even though the
+// parser logic in the ruby-msg library (and ported) is pretty awful
+
+class StringScanner implements \Stringable
+{
+    private int $pos = 0;
+    private $last;
+
+    public function __construct(private $buffer)
+    {
+    }
+
+    public function scan($str)
+    {
+        $len = strlen((string) $str);
+        if (substr((string) $this->buffer, $this->pos, $len) == $str) {
+            $this->pos += $len;
+            $this->last = $str;
+
+            return $this->last;
+        }
+
+        return false;
+    }
+
+    public function scanRegex($regex): array|false
+    {
+        if (preg_match($regex, (string) $this->buffer, $matches, PREG_OFFSET_CAPTURE, $this->pos) && $matches[0][1] == $this->pos) {
+            $this->pos += strlen($matches[0][0]);
+            $this->last = $matches;
+
+            return $this->last;
+        }
+
+        return false;
+    }
+
+    public function scanUntil($str): string|false
+    {
+        if (($newpos = strpos((string) $this->buffer, (string) $str, $this->pos)) !== false) {
+            $this->last = substr((string) $this->buffer, $this->pos, $newpos - $this->pos);
+            $this->pos  = $newpos + strlen((string) $str);
+
+            return $this->last;
+        }
+
+        return false;
+    }
+
+    public function scanUntilRegex($regex): string|false
+    {
+        if (preg_match($regex, (string) $this->buffer, $matches, PREG_OFFSET_CAPTURE, $this->pos)) {
+            $mlen       = strlen($matches[0][0]);
+            $this->last = substr((string) $this->buffer, $this->pos, $matches[0][1] + $mlen);
+            $this->pos  = $matches[0][1] + $mlen;
+
+            return $this->last;
+        }
+
+        return false;
+    }
+
+    public function eos(): bool
+    {
+        return $this->pos >= strlen((string) $this->buffer);
+    }
+
+    public function increment($count = 1): string
+    {
+        $this->last = substr((string) $this->buffer, $this->pos, $count);
+        $this->pos += $count;
+
+        return $this->last;
+    }
+
+    public function result()
+    {
+        return $this->last;
+    }
+
+    public function __toString(): string
+    {
+        return substr((string) $this->buffer, $this->pos);
+    }
+}
diff --git a/src/MAPI/OLE/Time/OleTime.php b/src/MAPI/OLE/Time/OleTime.php
index 025125f..551bf1a 100644
--- a/src/MAPI/OLE/Time/OleTime.php
+++ b/src/MAPI/OLE/Time/OleTime.php
@@ -1,40 +1,40 @@
-<?php
-
-namespace Hfig\MAPI\OLE\Time;
-
-use OLE;
-
-class OleTime
-{
-    /**
-     * Convert OLE-bytestring to unix timestamp in seconds
-     *
-     * Input is little-endian encoded number which equal amount of 100-nanoseconds
-     *   since 1 January 1601 (FILETIME-structure)
-     * Not any longer adapted from PEAR::OLE (which we assumed is correct)
-     *
-     * @see https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oleps/bf7aeae8-c47a-4939-9f45-700158dac3bc
-     *
-     * @param $string
-     * @return int
-     */
-    public static function getTimeFromOleTime($string)
-    {
-        if (strlen($string) !== 8) {
-            return 0;
-        }
-
-        // date is encoded as little endian integer
-        $big_date = unpack('P',$string)[1];
-
-        // translate to seconds
-        $big_date /= 10000000;
-        
-        // days from 1-1-1601 until the beginning of UNIX era
-        $days = 134774;
-        
-        // translate to seconds from beginning of UNIX era
-        $big_date -= ($days * 24 * 3600);
-        return floor($big_date);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\OLE\Time;
+
+use OLE;
+
+class OleTime
+{
+    /**
+     * Convert OLE-bytestring to unix timestamp in seconds.
+     *
+     * Input is little-endian encoded number which equal amount of 100-nanoseconds
+     *   since 1 January 1601 (FILETIME-structure)
+     * Not any longer adapted from PEAR::OLE (which we assumed is correct)
+     *
+     * @see https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oleps/bf7aeae8-c47a-4939-9f45-700158dac3bc
+     *
+     * @return int
+     */
+    public static function getTimeFromOleTime($string): int|float
+    {
+        if (strlen((string) $string) !== 8) {
+            return 0;
+        }
+
+        // date is encoded as little endian integer
+        $big_date = unpack('P', (string) $string)[1];
+
+        // translate to seconds
+        $big_date /= 10000000;
+
+        // days from 1-1-1601 until the beginning of UNIX era
+        $days = 134774;
+
+        // translate to seconds from beginning of UNIX era
+        $big_date -= ($days * 24 * 3600);
+
+        return floor($big_date);
+    }
+}
diff --git a/src/MAPI/Property/PropertyCollection.php b/src/MAPI/Property/PropertyCollection.php
index 2ed0e78..e53de30 100644
--- a/src/MAPI/Property/PropertyCollection.php
+++ b/src/MAPI/Property/PropertyCollection.php
@@ -1,55 +1,51 @@
-<?php
-
-namespace Hfig\MAPI\Property;
-
-class PropertyCollection implements \IteratorAggregate
-{
-    private $col = [];
-
-    public function set(PropertyKey $key, $value): void
-    {
-        //echo sprintf('Setting for %s %s'."\n", $key->getCode(), $key->getGuid());
-        $this->col[$key->getHash()] = ['key' => $key, 'value' => $value];
-    }
-
-    public function delete(PropertyKey $key): void
-    {
-        unset($this->col[$key->getHash()]);
-    }
-
-    public function get(PropertyKey $key)
-    {
-        $bucket = $this->col[$key->getHash()] ?? null;
-        if (is_null($bucket)) {
-            return null;
-        }
-        return $bucket['value'];
-    }
-
-    public function has(PropertyKey $key): bool
-    {
-        return isset($this->col[$key->getHash()]);
-    }
-
-    public function keys(): array
-    {
-        return array_map(function($bucket) {
-            return $bucket['key'];
-        }, $this->col);
-    }
-
-    public function values(): array
-    {
-        return array_map(function($bucket) {
-            return $bucket['value'];
-        }, $this->col);
-    }
-
-    public function getIterator(): \Traversable
-    {
-        foreach ($this->col as $bucket) {
-            yield $bucket['key'] => $bucket['value'];
-        }
-    }
-
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Property;
+
+class PropertyCollection implements \IteratorAggregate
+{
+    private array $col = [];
+
+    public function set(PropertyKey $key, $value): void
+    {
+        // echo sprintf('Setting for %s %s'."\n", $key->getCode(), $key->getGuid());
+        $this->col[$key->getHash()] = ['key' => $key, 'value' => $value];
+    }
+
+    public function delete(PropertyKey $key): void
+    {
+        unset($this->col[$key->getHash()]);
+    }
+
+    public function get(PropertyKey $key)
+    {
+        $bucket = $this->col[$key->getHash()] ?? null;
+        if (is_null($bucket)) {
+            return null;
+        }
+
+        return $bucket['value'];
+    }
+
+    public function has(PropertyKey $key): bool
+    {
+        return isset($this->col[$key->getHash()]);
+    }
+
+    public function keys(): array
+    {
+        return array_map(fn ($bucket) => $bucket['key'], $this->col);
+    }
+
+    public function values(): array
+    {
+        return array_map(fn ($bucket) => $bucket['value'], $this->col);
+    }
+
+    public function getIterator(): \Traversable
+    {
+        foreach ($this->col as $bucket) {
+            yield $bucket['key'] => $bucket['value'];
+        }
+    }
+}
diff --git a/src/MAPI/Property/PropertyKey.php b/src/MAPI/Property/PropertyKey.php
index 3a71a17..a4561e5 100644
--- a/src/MAPI/Property/PropertyKey.php
+++ b/src/MAPI/Property/PropertyKey.php
@@ -1,51 +1,41 @@
-<?php
-
-namespace Hfig\MAPI\Property;
-
-class PropertyKey {
-
-    private $code;
-    private $guid;
-
-    public function __construct($code, $guid = null)
-    {
-        
-        if (!$guid) {
-            $guid = PropertySetConstants::PS_MAPI();
-        }
-
-        $guid = (string)$guid;
-
-        $this->code = $code;
-        $this->guid = $guid;
-
-        //echo '  Created with code ' . $code . "\n";
-    }
-
-    public function getHash(): string
-    {
-        return static::getHashOf($this->code, $this->guid);
-    }
-
-    public function getCode()
-    {
-        return $this->code;
-    }
-
-    public function getGuid()
-    {
-        return $this->guid;
-    }
-
-    public static function getHashOf($code, $guid = null): string
-    {
-        if (!$guid) {
-            $guid = PropertySetConstants::PS_MAPI();
-        }
-        $guid = (string)$guid;
-
-        return $code . '::' . $guid;
-    }
-
-    
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Property;
+
+class PropertyKey
+{
+    private readonly string $code;
+    private readonly string $guid;
+
+    public function __construct(
+        int|string $code,
+        ?string $guid = null,
+    ) {
+        $this->code = (string) $code;
+        $this->guid = $guid ?: (string) PropertySetConstants::PS_MAPI();
+    }
+
+    public function getHash(): string
+    {
+        return static::getHashOf($this->code, $this->guid);
+    }
+
+    public function getCode()
+    {
+        return $this->code;
+    }
+
+    public function getGuid()
+    {
+        return $this->guid;
+    }
+
+    public static function getHashOf(string $code, ?string $guid = null): string
+    {
+        if (empty($guid)) {
+            $guid = (string) PropertySetConstants::PS_MAPI();
+        }
+
+        return $code.'::'.$guid;
+    }
+}
diff --git a/src/MAPI/Property/PropertySet.php b/src/MAPI/Property/PropertySet.php
index f70c007..218f8c7 100644
--- a/src/MAPI/Property/PropertySet.php
+++ b/src/MAPI/Property/PropertySet.php
@@ -1,165 +1,147 @@
-<?php
-
-namespace Hfig\MAPI\Property;
-
-use Symfony\Component\Yaml\Yaml;
-
-class PropertySet implements \ArrayAccess
-{
-    const SCHEMA_DIR = __DIR__ . '/../Schema';
-
-    /** @var PropertyStore */
-    private $store;
-
-    /** @var PropertyCollection */
-    private $raw;
-
-    private static $tagsMsg;
-    private static $tagsOther;
-    private $map = [];
-
-
-    public function __construct(PropertyStore $store)
-    {
-        $this->store = $store;
-        $this->raw = $store->getCollection();
-
-        if (!self::$tagsMsg || !self::$tagsOther) {
-            self::init();
-        }
-
-        $this->map();
-    
-    }
-
-    private static function init(): void
-    {
-        self::$tagsMsg   = Yaml::parseFile(self::SCHEMA_DIR . '/MapiFieldsMessage.yaml');
-        self::$tagsOther = Yaml::parseFile(self::SCHEMA_DIR . '/MapiFieldsOther.yaml');
-
-        foreach (self::$tagsOther as $propSet => $props) {
-            $guid = (string)PropertySetConstants::$propSet();
-            if ($guid) {
-                self::$tagsOther[$guid] = $props;
-                unset(self::$tagsOther[$propSet]);
-            }
-        }
-    }
-    
-    protected function map(): void
-    {
-        //print_r($this->raw->keys());
-
-        foreach ($this->raw->keys() as $key) {
-            //echo sprintf('Mapping %s %s'."\n", $key->getGuid(), $key->getCode());
-
-            if ((string)$key->getGuid() == (string)PropertySetConstants::PS_MAPI()) {
-                // read from tagsMsg
-                //echo '  Seeking '.sprintf('%04x', $key->getCode())."\n";
-                $propertyName  = strtolower($key->getCode());
-                $schemaElement = self::$tagsMsg[sprintf('%04x', $key->getCode())] ?? null;
-                if ($schemaElement) {                    
-                    $propertyName = strtolower(preg_replace('/^[^_]*_/', '', $schemaElement[0]));
-                    //echo '    Found msg '.$propertyName."\n";
-                }
-                $this->map[$propertyName] = $key;
-            }
-            else {
-                // read from tagsOther
-                $propertyName = strtolower($key->getCode());
-                $schemaElement = self::$tagsOther[(string)$key->getGuid()][$key->getCode()] ?? null;
-                if ($schemaElement) {
-                    $propertyName = $schemaElement;                    
-                    //echo '    Found other '.$propertyName."\n";
-                }
-                $this->map[$propertyName] = $key;
-            }
-        }
-
-    }
-
-
-    protected function resolveName($name)
-    {
-        if (isset($this->map[$name])) {
-            return $this->map[$name];
-        }
-        return new PropertyKey($name);
-    }
-
-    protected function resolveKey($code, $guid = null)
-    {
-        if (is_string($code) && is_null($guid)) {
-            return $this->resolveName($code);
-        }
-        return new PropertyKey($code, $guid);
-    }
-
-    /* public methods */
-
-    public function getStore()
-    {
-        return $this->store;
-    }
-
-    public function get($code, $guid = null)
-    {
-        $val = $this->raw->get($this->resolveKey($code, $guid));
-        
-        // resolve streams when they're requested
-        if (is_callable($val)) {
-            
-            $val = $val();
-           
-        }
-
-        return $val;
-    }
-
-    public function set($code, $value, $guid = null): void
-    {        
-        $this->raw->set($this->resolveKey($code, $guid), $value);
-    }
-
-    public function delete($code, $guid = null): void
-    {
-        $this->raw->delete($this->resolveKey($code, $guid));
-    }
-
-    /* magic methods */
-
-    public function __get($name)
-    {
-        return $this->get($name);
-    }
-
-    public function __set($name, $value)
-    {
-        return $this->set($name, $value);
-    }
-
-    public function offsetExists($offset): bool
-    {
-        //return (!is_null($this->get($offset)));
-        return (!is_null($this->raw->get($this->resolveKey($offset))));
-    }
-
-    /**
-     * @return mixed
-     */
-    #[\ReturnTypeWillChange]
-    public function offsetGet($offset)
-    {
-        return $this->get($offset);
-    }
-
-    public function offsetSet($offset, $value): void
-    {
-        $this->set($offset, $value);
-    }
-
-    public function offsetUnset($offset): void
-    {
-        $this->delete($offset);
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Property;
+
+use Symfony\Component\Yaml\Yaml;
+
+class PropertySet implements \ArrayAccess
+{
+    public const SCHEMA_DIR = __DIR__.'/../Schema';
+
+    private PropertyCollection $raw;
+
+    private static $tagsMsg;
+    private static $tagsOther;
+    private array $map = [];
+
+    public function __construct(private readonly PropertyStore $store)
+    {
+        $this->raw = $this->store->getCollection();
+
+        if (!self::$tagsMsg || !self::$tagsOther) {
+            $this->init();
+        }
+
+        $this->map();
+    }
+
+    private function init(): void
+    {
+        self::$tagsMsg   = Yaml::parseFile(self::SCHEMA_DIR.'/MapiFieldsMessage.yaml');
+        self::$tagsOther = Yaml::parseFile(self::SCHEMA_DIR.'/MapiFieldsOther.yaml');
+
+        foreach (self::$tagsOther as $propSet => $props) {
+            $guid = (string) PropertySetConstants::$propSet();
+            if ($guid !== '' && $guid !== '0') {
+                self::$tagsOther[$guid] = $props;
+                unset(self::$tagsOther[$propSet]);
+            }
+        }
+    }
+
+    protected function map(): void
+    {
+        // print_r($this->raw->keys());
+
+        foreach ($this->raw->keys() as $key) {
+            // echo sprintf('Mapping %s %s'."\n", $key->getGuid(), $key->getCode());
+
+            if ((string) $key->getGuid() === (string) PropertySetConstants::PS_MAPI()) {
+                // read from tagsMsg
+                // echo '  Seeking '.sprintf('%04x', $key->getCode())."\n";
+                $propertyName  = strtolower((string) $key->getCode());
+                $schemaElement = self::$tagsMsg[sprintf('%04x', $key->getCode())] ?? null;
+                if ($schemaElement) {
+                    $propertyName = strtolower(preg_replace('/^[^_]*_/', '', (string) $schemaElement[0]));
+                    // echo '    Found msg '.$propertyName."\n";
+                }
+                $this->map[$propertyName] = $key;
+            } else {
+                // read from tagsOther
+                $propertyName  = strtolower((string) $key->getCode());
+                $schemaElement = self::$tagsOther[(string) $key->getGuid()][$key->getCode()] ?? null;
+                if ($schemaElement) {
+                    $propertyName = $schemaElement;
+                    // echo '    Found other '.$propertyName."\n";
+                }
+                $this->map[$propertyName] = $key;
+            }
+        }
+    }
+
+    protected function resolveName($name)
+    {
+        return $this->map[$name] ?? new PropertyKey($name);
+    }
+
+    protected function resolveKey($code, $guid = null)
+    {
+        if (is_string($code) && is_null($guid)) {
+            return $this->resolveName($code);
+        }
+
+        return new PropertyKey($code, $guid);
+    }
+
+    /* public methods */
+
+    public function getStore(): PropertyStore
+    {
+        return $this->store;
+    }
+
+    public function get($code, $guid = null)
+    {
+        $val = $this->raw->get($this->resolveKey($code, $guid));
+
+        // resolve streams when they're requested
+        if (is_callable($val)) {
+            $val = $val();
+        }
+
+        return $val;
+    }
+
+    public function set($code, $value, $guid = null): void
+    {
+        $this->raw->set($this->resolveKey($code, $guid), $value);
+    }
+
+    public function delete($code, $guid = null): void
+    {
+        $this->raw->delete($this->resolveKey($code, $guid));
+    }
+
+    /* magic methods */
+
+    public function __get($name)
+    {
+        return $this->get($name);
+    }
+
+    public function __set($name, $value): void
+    {
+        $this->set($name, $value);
+    }
+
+    public function offsetExists($offset): bool
+    {
+        // return (!is_null($this->get($offset)));
+        return !is_null($this->raw->get($this->resolveKey($offset)));
+    }
+
+    public function offsetGet($offset): mixed
+    {
+        return $this->get($offset);
+    }
+
+    public function offsetSet($offset, $value): void
+    {
+        $this->set($offset, $value);
+    }
+
+    public function offsetUnset($offset): void
+    {
+        $this->delete($offset);
+    }
+}
diff --git a/src/MAPI/Property/PropertySetConstants.php b/src/MAPI/Property/PropertySetConstants.php
index cdac9ab..263e19f 100644
--- a/src/MAPI/Property/PropertySetConstants.php
+++ b/src/MAPI/Property/PropertySetConstants.php
@@ -1,72 +1,132 @@
-<?php
-
-namespace Hfig\MAPI\Property;
-
-use Hfig\MAPI\OLE\Guid\OleGuid;
-
-// ruby-msg Mapi::PropertySet
-
-class PropertySetConstants
-{
-    // the property set guid constants
-	// these guids are all defined with the macro DEFINE_OLEGUID in mapiguid.h.
-    // see http://doc.ddart.net/msdn/header/include/mapiguid.h.html
-    
-    public const OLE_GUID = '{${prefix}-0000-0000-c000-000000000046}';
-
-    public const NAMES = [
-        '00020328' => 'PS_MAPI',
-        '00020329' => 'PS_PUBLIC_STRINGS',
-        '00020380' => 'PS_ROUTING_EMAIL_ADDRESSES',
-        '00020381' => 'PS_ROUTING_ADDRTYPE',
-        '00020382' => 'PS_ROUTING_DISPLAY_NAME',
-        '00020383' => 'PS_ROUTING_ENTRYID',
-        '00020384' => 'PS_ROUTING_SEARCH_KEY',
-        // string properties in this namespace automatically get added to the internet headers
-        '00020386' => 'PS_INTERNET_HEADERS',
-        // theres are bunch of outlook ones i think
-        // http://blogs.msdn.com/stephen_griffin/archive/2006/05/10/outlook-2007-beta-documentation-notification-based-indexing-support.aspx
-        // IPM.Appointment
-        '00062002' => 'PSETID_Appointment',
-        // IPM.Task
-        '00062003' => 'PSETID_Task',
-        // used for IPM.Contact
-        '00062004' => 'PSETID_Address',
-        '00062008' => 'PSETID_Common',
-        // didn't find a source for this name. it is for IPM.StickyNote
-        '0006200e' => 'PSETID_Note',
-        // for IPM.Activity. also called the journal?
-        '0006200a' => 'PSETID_Log',
-    ];
-
-    protected static function get($offset)
-    {
-        static $lookup = [];
-        if (isset($lookup[$offset])) return $lookup[$offset];
-
-        $guid = array_search($offset, static::NAMES);
-        if ($guid === false) return null;
-
-        $guid = str_replace('${prefix}', $guid, static::OLE_GUID);
-        $guid = OleGuid::fromString($guid);
-
-        $lookup[$offset] = $guid;
-        return $guid;
-    }
-
-    public function __get($offset)
-    {
-        return static::get($offset);
-    }
-
-    public static function __callStatic($name, $args)
-    {
-        $ret = static::get($name);
-        if (is_null($ret)) {
-            throw new \RuntimeException('Unknown constant '.$name);
-        }
-        return $ret;
-    }
-
-
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Property;
+
+use Hfig\MAPI\OLE\Guid\OleGuid;
+use Ramsey\Uuid\UuidInterface as OleGuidInterface;
+
+// ruby-msg Mapi::PropertySet
+
+class PropertySetConstants
+{
+    // the property set guid constants
+    // these guids are all defined with the macro DEFINE_OLEGUID in mapiguid.h.
+    // see http://doc.ddart.net/msdn/header/include/mapiguid.h.html
+
+    public const NAMES = [
+        '00020328' => 'PS_MAPI',
+        '00020329' => 'PS_PUBLIC_STRINGS',
+        '00020380' => 'PS_ROUTING_EMAIL_ADDRESSES',
+        '00020381' => 'PS_ROUTING_ADDRTYPE',
+        '00020382' => 'PS_ROUTING_DISPLAY_NAME',
+        '00020383' => 'PS_ROUTING_ENTRYID',
+        '00020384' => 'PS_ROUTING_SEARCH_KEY',
+        // string properties in this namespace automatically get added to the internet headers
+        '00020386' => 'PS_INTERNET_HEADERS',
+        // theres are bunch of outlook ones i think
+        // http://blogs.msdn.com/stephen_griffin/archive/2006/05/10/outlook-2007-beta-documentation-notification-based-indexing-support.aspx
+        // IPM.Appointment
+        '00062002' => 'PSETID_Appointment',
+        // IPM.Task
+        '00062003' => 'PSETID_Task',
+        // used for IPM.Contact
+        '00062004' => 'PSETID_Address',
+        '00062008' => 'PSETID_Common',
+        // didn't find a source for this name. it is for IPM.StickyNote
+        '0006200e' => 'PSETID_Note',
+        // for IPM.Activity. also called the journal?
+        '0006200a' => 'PSETID_Log',
+    ];
+
+    private const OLE_GUID = '{${prefix}-0000-0000-c000-000000000046}';
+
+    protected static function get(string $offset): OleGuidInterface
+    {
+        static $lookup = [];
+        if (isset($lookup[$offset])) {
+            return $lookup[$offset];
+        }
+
+        $guid = array_search($offset, static::NAMES);
+        if ($guid === false) {
+            throw new \RuntimeException(sprintf('offset %s not found', $offset));
+        }
+
+        $guid = str_replace('${prefix}', $guid, self::OLE_GUID);
+        $guid = OleGuid::fromString($guid);
+
+        $lookup[$offset] = $guid;
+
+        return $guid;
+    }
+
+    public static function PS_MAPI(): OleGuidInterface
+    {
+        return self::get('PS_MAPI');
+    }
+
+    public static function PS_PUBLIC_STRINGS(): OleGuidInterface
+    {
+        return self::get('PS_PUBLIC_STRINGS');
+    }
+
+    public static function PS_ROUTING_EMAIL_ADDRESSES(): OleGuidInterface
+    {
+        return self::get('PS_ROUTING_EMAIL_ADDRESSES');
+    }
+
+    public static function PS_ROUTING_ADDRTYPE(): OleGuidInterface
+    {
+        return self::get('PS_ROUTING_ADDRTYPE');
+    }
+
+    public static function PS_ROUTING_DISPLAY_NAME(): OleGuidInterface
+    {
+        return self::get('PS_ROUTING_DISPLAY_NAME');
+    }
+
+    public static function PS_ROUTING_ENTRYID(): OleGuidInterface
+    {
+        return self::get('PS_ROUTING_ENTRYID');
+    }
+
+    public static function PS_ROUTING_SEARCH_KEY(): OleGuidInterface
+    {
+        return self::get('PS_ROUTING_SEARCH_KEY');
+    }
+
+    public static function PS_INTERNET_HEADERS(): OleGuidInterface
+    {
+        return self::get('PS_INTERNET_HEADERS');
+    }
+
+    public static function PSETID_Appointment(): OleGuidInterface
+    {
+        return self::get('PSETID_Appointment');
+    }
+
+    public static function PSETID_Task(): OleGuidInterface
+    {
+        return self::get('PSETID_Task');
+    }
+
+    public static function PSETID_Address(): OleGuidInterface
+    {
+        return self::get('PSETID_Address');
+    }
+
+    public static function PSETID_Common(): OleGuidInterface
+    {
+        return self::get('PSETID_Common');
+    }
+
+    public static function PSETID_Note(): OleGuidInterface
+    {
+        return self::get('PSETID_Note');
+    }
+
+    public static function PSETID_Log(): OleGuidInterface
+    {
+        return self::get('PSETID_Log');
+    }
+}
diff --git a/src/MAPI/Property/PropertyStore.php b/src/MAPI/Property/PropertyStore.php
index 23bceb8..fa837fc 100644
--- a/src/MAPI/Property/PropertyStore.php
+++ b/src/MAPI/Property/PropertyStore.php
@@ -1,434 +1,398 @@
-<?php
-
-namespace Hfig\MAPI\Property;
-
-use Psr\Log\LoggerInterface;
-use Psr\Log\NullLogger;
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-use Hfig\MAPI\OLE\Guid\OleGuid;
-use Hfig\MAPI\OLE\Time\OleTime;
-
-
-class PropertyStore 
-{
-    const SUBSTG_RX     = '/^__substg1\.0_([0-9A-F]{4})([0-9A-F]{4})(?:-([0-9A-F]{8}))?$/';
-    const PROPERTIES_RX = '/^__properties_version1\.0$/';
-    const NAMEID_RX     = '/^__nameid_version1\.0$/';
-    
-    const VALID_RX      = [
-        self::SUBSTG_RX, 
-        self::PROPERTIES_RX, 
-        self::NAMEID_RX
-    ];
-
-    /** @var PropertyCollection */
-    protected $cache;
-    protected $nameId;
-    protected $parentNameId;
-
-    /** @var LoggerInterface */
-    protected $logger;
-
-
-    public function __construct(Element $obj = null, $nameId = null, LoggerInterface $logger = null)
-    {
-        $this->cache = new PropertyCollection();
-        $this->nameId = null;
-        $this->parentNameId = $nameId;
-        $this->logger = $logger ?? new NullLogger();
-
-        if ($obj) {
-            $this->load($obj);
-        }
-    }
-
-    protected function load(Element $obj): void
-    {
-
-        //# find name_id first
-        foreach ($obj->getChildren() as $child) {
-
-            if (preg_match(self::NAMEID_RX, $child->getName())) {
-                $this->nameId = $this->parseNameId($child);
-            }
-        }
-        if (is_null($this->nameId)) {
-            $this->nameId = $this->parentNameId;
-        }
-
-
-        
-        foreach ($obj->getChildren() as $child) {
-            if ($child->isFile()) {
-                if (preg_match(self::PROPERTIES_RX, $child->getName())) {
-                    $this->parseProperties($child);
-                }
-                elseif (preg_match(self::SUBSTG_RX, $child->getName(), $matches)) {
-                    $key      = hexdec($matches[1]);
-                    $encoding = hexdec($matches[2]);
-                    $offset   = hexdec($matches[3] ?? '0');
-
-                    $this->parseSubstg($key, $encoding, $offset, $child);
-                }
-            }
-        }
-
-    }
-
-    /**
-     * @return array<PropertyKey>
-     */
-    protected function parseNameId($obj): array
-    {
-        // $remaining = clone $obj->getChildren()
-
-        $knownPpsAlias = [
-            'guids' => '__substg1.0_00020102', 
-            'props' => '__substg1.0_00030102', 
-            'names' => '__substg1.0_00040102'];
-
-        $knownPpsObj = array_combine(
-            array_keys($knownPpsAlias),
-            [null, null, null]
-        );
-
-        foreach ($obj->getChildren() as $child) {
-            $alias = array_search($child->getName(), $knownPpsAlias);
-            if ($alias !== false) {
-                $knownPpsObj[$alias] = $child;
-            }
-        }
-       
-
-        //# parse guids
-        //# this is the guids for named properities (other than builtin ones)
-        //# i think PS_PUBLIC_STRINGS, and PS_MAPI are builtin.
-        //# Scan using an ascii pattern - it's binary data we're looking
-        //# at, so we don't want to look for unicode characters        
-        $guids  = [PropertySetConstants::PS_PUBLIC_STRINGS()];
-        $rawGuid = str_split($knownPpsObj['guids']->getData(), 16);
-        foreach ($rawGuid as $guid) {
-            if (strlen($guid) == 16) {
-                $guids[] = OleGuid::fromBytes($guid);
-            }
-        }
-
-        //# parse names.
-        //# the string ids for named properties
-        //# they are no longer parsed, as they're referred to by offset not
-        //# index. they are simply sequentially packed, as a long, giving
-        //# the string length, then padding to 4 byte multiple, and repeat.
-        $namesData = $knownPpsObj['names']->getData();
-
-        //# parse actual props.
-        //# not sure about any of this stuff really.
-        //# should flip a few bits in the real msg, to get a better understanding of how this works.
-        //# Scan using an ascii pattern - it's binary data we're looking
-        //# at, so we don't want to look for unicode characters
-        $propsData = $knownPpsObj['props']->getData();
-        $properties = [];
-        foreach (str_split($propsData, 8) as $idx => $rawProp) {
-            if (strlen($rawProp) < 8) break;
-
-            $d      = unpack('vflags/voffset', substr($rawProp, 4));
-            $flags  = $d['flags'];
-            $offset = $d['offset'];
-
-            //# the property will be serialised as this pseudo property, mapping it to this named property
-            $pseudo_prop = 0x8000 + $offset;
-            $named = ($flags & 1 == 1);
-            $prop  = '';
-            if ($named) {
-                $str_off = unpack('V', $rawProp)[1];
-                if (strlen($namesData) - $str_off < 4) continue; // not sure with this, but at least it will not read outside the bounds and crash
-                $len = unpack('V', substr($namesData, $str_off, 4))[1];
-                $data = substr($namesData, $str_off + 4, $len);
-                $prop = mb_convert_encoding($data, 'UTF-8', 'UTF-16LE');
-            }
-            else {
-                $d = unpack('va/vb', $rawProp);
-                if ($d['b'] != 0) {
-                    $this->logger->Debug("b not 0");
-                }
-                $prop = $d['a'];
-            }
-
-            //# a bit sus
-            $guid_off = $flags >> 1;
-            $guid = $guids[$guid_off - 2];
-
-            /*$properties[] = [
-                'key' => new PropertyKey($prop, $guid),
-                'prop' => $pseudo_prop,
-            ];*/
-            $properties[$pseudo_prop] = new PropertyKey($prop, $guid);
-					
-        }
-        
-
-        //# this leaves a bunch of other unknown chunks of data with completely unknown meaning.
-		//#	pp [:unknown, child.name, child.data.unpack('H*')[0].scan(/.{16}/m)]
-        //print_r($properties);
-        return $properties;
-
-    }
-
-    protected function parseSubstg($key, $encoding, $offset, $obj): void
-    {
-        $MULTIVAL = 0x1000;
-        
-        if (($encoding & $MULTIVAL) != 0) {
-            if (!$offset) {
-                //# there is typically one with no offset first, whose data is a series of numbers
-                //# equal to the lengths of all the sub parts. gives an implied array size i suppose.
-                //# maybe you can initialize the array at this time. the sizes are the same as all the
-                //# ole object sizes anyway, its to pre-allocate i suppose.
-                //#p obj.data.unpack('V*')
-                //# ignore this one
-                return;
-            }
-            else {
-				// remove multivalue flag for individual pieces
-                $encoding = $encoding & ~$MULTIVAL;
-            }
-        }
-        else {
-            if ($offset) {
-                $this->logger->warning(sprintf('offset specified for non-multivalue encoding %s', $obj->getName()));
-            }
-            $offset = null;
-        }
-
-        $valueFn = PropertyStoreEncodings::decodeFunction($encoding, $obj);
-
-        //$property = [
-        //    'key' => $key,
-        //    'value' => $valueFn,
-        //    'offset' => $offset
-        //];
-
-        $this->addProperty($key, $valueFn, $offset);
-    }
-
-    //# For parsing the +properties+ file. Smaller properties are serialized in one chunk,
-    //# such as longs, bools, times etc. The parsing has problems.
-    protected function parseProperties($obj): void
-    {
-        $data = $obj->getData();
-        $pad  = $obj->getSize() % 16;
-
-        //# don't really understand this that well...
-        // it's also wrong
-        //if (!(($pad == 0 || $pad == 8) && substr($data, 0, $pad) == str_repeat("\0", 16))) {
-        //    $this->logger->warning('padding was not as expected', ['pad' => $pad, 'size' => $obj->getSize(), substr($data, 0, $pad)]);
-        //}
-
-        //# Scan using an ascii pattern - it's binary data we're looking
-        //# at, so we don't want to look for unicode characters
-        foreach (str_split(substr($data, $pad), 16) as $idx => $rawProp) {
-            
-            // copying ruby implementation's oddness to avoid any endianess issues
-            $rawData = unpack('V', $rawProp)[1];
-            [$property, $encoding] = str_split(sprintf('%08x', $rawData), 4);
-            $key = hexdec($property);
-
-            //# doesn't make any sense to me. probably because its a serialization of some internal
-            //# outlook structure..
-            if ($property == '0000') {
-                continue;
-            }
-
-            // improved from ruby-msg - handle more types
-            // https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/property-types
-            switch ($encoding) {
-                
-                case '0001':    // PT_NULL
-                    break;
-
-                
-                case '0002':    // PT_I2
-                case '1002':    // PT_MV_I2
-                    $value = unpack('v', substr($rawProp, 8, 2))[1];
-                    $this->addProperty($key, $value);
-                    break;
-
-                case '0003':    // PT_I4
-                case '1003':    // PT_MV_I4
-                    $value = unpack('V', substr($rawProp, 8, 4))[1];
-                    $this->addProperty($key, $value);
-                    break;
-
-                case '0004':    // PT_FLOAT
-                case '1004':    // PT_MV_FLOAT
-                    $value = unpack('f', substr($rawProp, 8, 4))[1];
-                    $this->addProperty($key, $value);
-                    break;
-
-                case '0005':    // PT_DOUBLE
-                case '1005':    // PT_MV_DOUBLE
-                    $value = unpack('e', substr($rawProp, 8, 8))[1];
-                    $this->addProperty($key, $value);
-                    break;                
-
-                case '0006':    // PT_CURRENCY
-                case '1006':    // PT_MV_CURRENCY
-                    // TODO work out how to interpret PT_CURRENCY (same as VB currency type, apparently)
-                    $value = unpack('a8', substr($rawProp, 8, 8))[1];
-                    $this->addProperty($key, $value);
-                    break;      
-
-                case '0007':    // PT_APPTIME 
-                case '1007':    // PT_MV_APPTIME
-                    // TODO work out how to interpret PT_APPTIME (same as VB time type, apparently)
-                    $value = unpack('a8', substr($rawProp, 8, 8))[1];
-                    $this->addProperty($key, $value);
-                    break;      
-
-                case '000a':    // PT_ERROR  
-                    $value = unpack('V', substr($rawProp, 8, 4))[1];
-                    $this->addProperty($key, $value);
-                    break;  
-
-                case '000b':    // PT_BOOLEAN 
-                case '100b':    // PT_MV_12
-                    // Windows 2-byte BOOL
-                    $value = unpack('v', substr($rawProp, 8, 2))[1];
-                    $this->addProperty($key, $value != 0);
-                    break;
-
-                case '000d':    // PT_OBJECT  
-                    // pointer to IUnknown - cannot exist in an Outlook property hopefully!!
-                    break;
-
-                case '0014':    // PT_I8   
-                case '1014':    // PT_MV_I8
-                    //$value = unpack('P', substr($rawProp, 8, 8))[1];
-                    // raw data, change endianess
-                    $raw = strrev(substr($rawProp, 8, 8));
-                    $value = ord($raw[7]);
-                    for ($i = 6; $i >= 0; $i--) {
-                        $fig = ord($raw[$i]);
-                        $order = abs(8 - $i);
-                        $value = bcadd($value, bcmul($fig, bcmul(10, $order)));
-                    }
-                    $this->addProperty($key, $value);
-                    break;
-
-                case '001e':    // PT_STRING8  
-                case '101e':    // PT_MV_STRING8
-                    // LPSTR - stored in a stream
-                    //$value = substr($rawProp, 8);
-                    //$this->addProperty($key, $value);
-                    break;
-
-                case '001f':    // PT_TSTRING   
-                case '101f':    // PT_MV_TSTRING
-                    //  LPWSTR - stored in a stream  
-                    //$value = substr($rawProp, 8);
-                    //$this->addProperty($key, $value);
-                    break;
-
-                case '0040':    // PT_SYSTIME 
-                case '1040':    // PT_MV_SYSTIME
-                    $value = OleTime::getTimeFromOleTime(substr($rawProp, 8));
-                    $this->addProperty($key, $value);
-                    break;
-
-                case '0048':    // PT_CLSID 
-                    $value = (string)OleGuid::fromBytes($rawProp);
-                    $this->addProperty($key, $value);
-                    break;
-
-                case '1048':    // PT_MV_CLSID
-                    $value = (string)OleGuid::fromBytes(substr($rawProp, 8));
-                    $this->addProperty($key, $value);
-                    break;
-
-                case '00fb':    // PT_SVREID 
-                    // Variable size, a 16-bit (2-byte) COUNT followed by a structure. 
-                    break;
-
-                case '00fd':    // PT_SRESTRICT 
-                    // Variable size, a byte array representing one or more Restriction structures. 
-                    break;
-
-                case '00fe':    // PT_ACTIONS 
-                    // Variable size, a 16-bit (2-byte) COUNT of actions (not bytes) followed by that many Rule Action structures. 
-                    break;
-
-                case '0102':    // PT_BINARY
-                case '1102':    // PT_MV_BINARY
-                    // assume this is also stored in a stream
-                    //$value = substr($rawProp, 8);
-                    //$this->addProperty($key, $value);
-                    break;
-
-
-                default:
-                    $this->logger->warning(sprintf('ignoring data in __properties section, encoding: %s', $encoding), unpack('H*', $rawProp));
-
-            }
-        }
-
-        
-    }
-
-    protected function addProperty($key, $value, $pos = null): void
-    {
-        
-        
-        //# map keys in the named property range through nameid
-        if (is_int($key) && $key >= 0x8000) {
-            if (!$this->nameId) {
-                $this->logger->warning('No nameid section yet named properties used');
-                $key = new PropertyKey($key);
-            }
-            elseif (isset($this->nameId[$key])) {                
-                $key = $this->nameId[$key];
-            }
-            else {
-                //# i think i hit these when i have a named property, in the PS_MAPI
-				//# guid
-				$this->logger->warning(sprintf('property in named range not in nameid %s', print_r($key, true)));
-				$key = new PropertyKey($key);
-            }
-        }
-        else {
-            $key = new PropertyKey($key);
-        }
-
-        
-        //$this->logger->debug(sprintf('Writing property %s', print_r($key, true)));
-        //$hash = $key->getHash();
-        if (!is_null($pos)) {
-            if (!$this->cache->has($key)) {
-                $this->cache->set($key, []);
-            }
-            if (!is_array($this->cache->get($key))) {
-                $this->logger->warning('Duplicate property');
-            }
-
-            $el = $this->cache->get($key);
-            $el[$pos] = $value;
-            $this->cache->set($key, $el);
-        }
-        else {
-            $this->cache->set($key, $value);
-        }
-        
-    }
-
-
-    public function getCollection(): PropertyCollection
-    {
-        return $this->cache;
-
-    }
-
-    public function getNameId()
-    {
-        return $this->nameId;
-    }
-
-
-}
+<?php
+
+namespace Hfig\MAPI\Property;
+
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+use Hfig\MAPI\OLE\Guid\OleGuid;
+use Hfig\MAPI\OLE\Time\OleTime;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+class PropertyStore
+{
+    public const SUBSTG_RX     = '/^__substg1\.0_([0-9A-F]{4})([0-9A-F]{4})(?:-([0-9A-F]{8}))?$/';
+    public const PROPERTIES_RX = '/^__properties_version1\.0$/';
+    public const NAMEID_RX     = '/^__nameid_version1\.0$/';
+
+    public const VALID_RX = [
+        self::SUBSTG_RX,
+        self::PROPERTIES_RX,
+        self::NAMEID_RX,
+    ];
+
+    protected PropertyCollection $cache;
+    protected $nameId;
+
+    protected LoggerInterface $logger;
+
+    public function __construct(?Element $obj = null, protected $parentNameId = null, ?LoggerInterface $logger = null)
+    {
+        $this->cache  = new PropertyCollection();
+        $this->logger = $logger ?? new NullLogger();
+
+        if ($obj instanceof Element) {
+            $this->load($obj);
+        }
+    }
+
+    protected function load(Element $obj): void
+    {
+        // # find name_id first
+        foreach ($obj->getChildren() as $child) {
+            if (preg_match(self::NAMEID_RX, (string) $child->getName())) {
+                $this->nameId = $this->parseNameId($child);
+            }
+        }
+        if (is_null($this->nameId)) {
+            $this->nameId = $this->parentNameId;
+        }
+
+        foreach ($obj->getChildren() as $child) {
+            if ($child->isFile()) {
+                if (preg_match(self::PROPERTIES_RX, (string) $child->getName())) {
+                    $this->parseProperties($child);
+                } elseif (preg_match(self::SUBSTG_RX, (string) $child->getName(), $matches)) {
+                    $key      = hexdec($matches[1]);
+                    $encoding = hexdec($matches[2]);
+                    $offset   = hexdec($matches[3] ?? '0');
+
+                    $this->parseSubstg($key, $encoding, $offset, $child);
+                }
+            }
+        }
+    }
+
+    /**
+     * @return array<PropertyKey>
+     */
+    protected function parseNameId($obj): array
+    {
+        // $remaining = clone $obj->getChildren()
+
+        $knownPpsAlias = [
+            'guids' => '__substg1.0_00020102',
+            'props' => '__substg1.0_00030102',
+            'names' => '__substg1.0_00040102'];
+
+        $knownPpsObj = array_combine(
+            array_keys($knownPpsAlias),
+            [null, null, null],
+        );
+
+        foreach ($obj->getChildren() as $child) {
+            $alias = array_search($child->getName(), $knownPpsAlias);
+            if ($alias !== false) {
+                $knownPpsObj[$alias] = $child;
+            }
+        }
+
+        // # parse guids
+        // # this is the guids for named properities (other than builtin ones)
+        // # i think PS_PUBLIC_STRINGS, and PS_MAPI are builtin.
+        // # Scan using an ascii pattern - it's binary data we're looking
+        // # at, so we don't want to look for unicode characters
+        $guids   = [PropertySetConstants::PS_PUBLIC_STRINGS()];
+        $rawGuid = str_split((string) $knownPpsObj['guids']->getData(), 16);
+        foreach ($rawGuid as $guid) {
+            if (strlen($guid) == 16) {
+                $guids[] = OleGuid::fromBytes($guid);
+            }
+        }
+
+        // # parse names.
+        // # the string ids for named properties
+        // # they are no longer parsed, as they're referred to by offset not
+        // # index. they are simply sequentially packed, as a long, giving
+        // # the string length, then padding to 4 byte multiple, and repeat.
+        $namesData = $knownPpsObj['names']->getData();
+
+        // # parse actual props.
+        // # not sure about any of this stuff really.
+        // # should flip a few bits in the real msg, to get a better understanding of how this works.
+        // # Scan using an ascii pattern - it's binary data we're looking
+        // # at, so we don't want to look for unicode characters
+        $propsData  = $knownPpsObj['props']->getData();
+        $properties = [];
+        foreach (str_split((string) $propsData, 8) as $idx => $rawProp) {
+            if (strlen($rawProp) < 8) {
+                break;
+            }
+
+            $d      = unpack('vflags/voffset', substr($rawProp, 4));
+            $flags  = $d['flags'];
+            $offset = $d['offset'];
+
+            // # the property will be serialised as this pseudo property, mapping it to this named property
+            $pseudo_prop = 0x8000 + $offset;
+            $named       = ($flags & 1) === 1;
+            if ($named) {
+                $str_off = unpack('V', $rawProp)[1];
+                if (strlen((string) $namesData) - $str_off < 4) {
+                    continue;
+                } // not sure with this, but at least it will not read outside the bounds and crash
+                $len  = unpack('V', substr((string) $namesData, $str_off, 4))[1];
+                $data = substr((string) $namesData, $str_off + 4, $len);
+                $prop = mb_convert_encoding($data, 'UTF-8', 'UTF-16LE');
+            } else {
+                $d = unpack('va/vb', $rawProp);
+                if ($d['b'] != 0) {
+                    $this->logger->Debug('b not 0');
+                }
+                $prop = $d['a'];
+            }
+
+            // # a bit sus
+            $guid_off = $flags >> 1;
+            $guid     = $guids[$guid_off - 2];
+
+            /*$properties[] = [
+                'key' => new PropertyKey($prop, $guid),
+                'prop' => $pseudo_prop,
+            ];*/
+            $properties[$pseudo_prop] = new PropertyKey($prop, $guid);
+        }
+
+        // # this leaves a bunch of other unknown chunks of data with completely unknown meaning.
+        // #	pp [:unknown, child.name, child.data.unpack('H*')[0].scan(/.{16}/m)]
+        // print_r($properties);
+        return $properties;
+    }
+
+    protected function parseSubstg($key, $encoding, $offset, Element $obj): void
+    {
+        $MULTIVAL = 0x1000;
+
+        if (($encoding & $MULTIVAL) != 0) {
+            if (!$offset) {
+                // # there is typically one with no offset first, whose data is a series of numbers
+                // # equal to the lengths of all the sub parts. gives an implied array size i suppose.
+                // # maybe you can initialize the array at this time. the sizes are the same as all the
+                // # ole object sizes anyway, its to pre-allocate i suppose.
+                // #p obj.data.unpack('V*')
+                // # ignore this one
+                return;
+            }
+
+            // remove multivalue flag for individual pieces
+            $encoding &= ~$MULTIVAL;
+        } else {
+            if ($offset) {
+                $this->logger->warning(sprintf('offset specified for non-multivalue encoding %s', $obj->getName()));
+            }
+            $offset = null;
+        }
+
+        $valueFn = PropertyStoreEncodings::decodeFunction($encoding, $obj);
+
+        // $property = [
+        //    'key' => $key,
+        //    'value' => $valueFn,
+        //    'offset' => $offset
+        // ];
+
+        $this->addProperty($key, $valueFn, $offset);
+    }
+
+    // # For parsing the +properties+ file. Smaller properties are serialized in one chunk,
+    // # such as longs, bools, times etc. The parsing has problems.
+    protected function parseProperties($obj): void
+    {
+        $data = $obj->getData();
+        $pad  = $obj->getSize() % 16;
+
+        // # don't really understand this that well...
+        // it's also wrong
+        // if (!(($pad == 0 || $pad == 8) && substr($data, 0, $pad) == str_repeat("\0", 16))) {
+        //    $this->logger->warning('padding was not as expected', ['pad' => $pad, 'size' => $obj->getSize(), substr($data, 0, $pad)]);
+        // }
+
+        // # Scan using an ascii pattern - it's binary data we're looking
+        // # at, so we don't want to look for unicode characters
+        foreach (str_split(substr((string) $data, $pad), 16) as $idx => $rawProp) {
+            // copying ruby implementation's oddness to avoid any endianess issues
+            $rawData               = unpack('V', $rawProp)[1];
+            [$property, $encoding] = str_split(sprintf('%08x', $rawData), 4);
+            $key                   = hexdec($property);
+
+            // # doesn't make any sense to me. probably because its a serialization of some internal
+            // # outlook structure..
+            if ($property === '0000') {
+                continue;
+            }
+
+            // improved from ruby-msg - handle more types
+            // https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/property-types
+            switch ($encoding) {
+                case '0001':    // PT_NULL
+                    break;
+
+                case '0002':    // PT_I2
+                case '1002':    // PT_MV_I2
+                    $value = unpack('v', substr($rawProp, 8, 2))[1];
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '0003':    // PT_I4
+                case '1003':    // PT_MV_I4
+                    $value = unpack('V', substr($rawProp, 8, 4))[1];
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '0004':    // PT_FLOAT
+                case '1004':    // PT_MV_FLOAT
+                    $value = unpack('f', substr($rawProp, 8, 4))[1];
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '0005':    // PT_DOUBLE
+                case '1005':    // PT_MV_DOUBLE
+                    $value = unpack('e', substr($rawProp, 8, 8))[1];
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '0006':    // PT_CURRENCY
+                case '1006':    // PT_MV_CURRENCY
+                    // TODO work out how to interpret PT_CURRENCY (same as VB currency type, apparently)
+                    $value = unpack('a8', substr($rawProp, 8, 8))[1];
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '0007':    // PT_APPTIME
+                case '1007':    // PT_MV_APPTIME
+                    // TODO work out how to interpret PT_APPTIME (same as VB time type, apparently)
+                    $value = unpack('a8', substr($rawProp, 8, 8))[1];
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '000a':    // PT_ERROR
+                    $value = unpack('V', substr($rawProp, 8, 4))[1];
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '000b':    // PT_BOOLEAN
+                case '100b':    // PT_MV_12
+                    // Windows 2-byte BOOL
+                    $value = unpack('v', substr($rawProp, 8, 2))[1];
+                    $this->addProperty($key, $value != 0);
+                    break;
+
+                case '000d':    // PT_OBJECT
+                    // pointer to IUnknown - cannot exist in an Outlook property hopefully!!
+                    break;
+
+                case '0014':    // PT_I8
+                case '1014':    // PT_MV_I8
+                    // $value = unpack('P', substr($rawProp, 8, 8))[1];
+                    // raw data, change endianess
+                    $raw   = strrev(substr($rawProp, 8, 8));
+                    $value = ord($raw[7]);
+                    for ($i = 6; $i >= 0; --$i) {
+                        $fig   = (string) ord($raw[$i]);
+                        $order = (string) abs(8 - $i);
+                        $value = bcadd($value, bcmul($fig, bcmul('10', $order)));
+                    }
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '001e':    // PT_STRING8
+                case '101e':    // PT_MV_STRING8
+                    // LPSTR - stored in a stream
+                    // $value = substr($rawProp, 8);
+                    // $this->addProperty($key, $value);
+                    break;
+
+                case '001f':    // PT_TSTRING
+                case '101f':    // PT_MV_TSTRING
+                    //  LPWSTR - stored in a stream
+                    // $value = substr($rawProp, 8);
+                    // $this->addProperty($key, $value);
+                    break;
+
+                case '0040':    // PT_SYSTIME
+                case '1040':    // PT_MV_SYSTIME
+                    $value = OleTime::getTimeFromOleTime(substr($rawProp, 8));
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '0048':    // PT_CLSID
+                    $value = (string) OleGuid::fromBytes($rawProp);
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '1048':    // PT_MV_CLSID
+                    $value = (string) OleGuid::fromBytes(substr($rawProp, 8));
+                    $this->addProperty($key, $value);
+                    break;
+
+                case '00fb':    // PT_SVREID
+                    // Variable size, a 16-bit (2-byte) COUNT followed by a structure.
+                    break;
+
+                case '00fd':    // PT_SRESTRICT
+                    // Variable size, a byte array representing one or more Restriction structures.
+                    break;
+
+                case '00fe':    // PT_ACTIONS
+                    // Variable size, a 16-bit (2-byte) COUNT of actions (not bytes) followed by that many Rule Action structures.
+                    break;
+
+                case '0102':    // PT_BINARY
+                case '1102':    // PT_MV_BINARY
+                    // assume this is also stored in a stream
+                    // $value = substr($rawProp, 8);
+                    // $this->addProperty($key, $value);
+                    break;
+
+                default:
+                    $this->logger->warning(sprintf('ignoring data in __properties section, encoding: %s', $encoding), unpack('H*', $rawProp));
+            }
+        }
+    }
+
+    protected function addProperty($key, $value, $pos = null): void
+    {
+        // # map keys in the named property range through nameid
+        if (is_int($key) && $key >= 0x8000) {
+            if (!$this->nameId) {
+                $this->logger->warning('No nameid section yet named properties used');
+                $key = new PropertyKey($key);
+            } elseif (isset($this->nameId[$key])) {
+                $key = $this->nameId[$key];
+            } else {
+                // # i think i hit these when i have a named property, in the PS_MAPI
+                // # guid
+                $this->logger->warning(sprintf('property in named range not in nameid %s', print_r($key, true)));
+                $key = new PropertyKey($key);
+            }
+        } else {
+            $key = new PropertyKey($key);
+        }
+
+        // $this->logger->debug(sprintf('Writing property %s', print_r($key, true)));
+        // $hash = $key->getHash();
+        if (!is_null($pos)) {
+            if (!$this->cache->has($key)) {
+                $this->cache->set($key, []);
+            }
+            if (!is_array($this->cache->get($key))) {
+                $this->logger->warning('Duplicate property');
+            }
+
+            $el       = $this->cache->get($key);
+            $el[$pos] = $value;
+            $this->cache->set($key, $el);
+        } else {
+            $this->cache->set($key, $value);
+        }
+    }
+
+    public function getCollection(): PropertyCollection
+    {
+        return $this->cache;
+    }
+
+    public function getNameId()
+    {
+        return $this->nameId;
+    }
+}
diff --git a/src/MAPI/Property/PropertyStoreEncodings.php b/src/MAPI/Property/PropertyStoreEncodings.php
index deed543..c8d5c48 100644
--- a/src/MAPI/Property/PropertyStoreEncodings.php
+++ b/src/MAPI/Property/PropertyStoreEncodings.php
@@ -1,66 +1,67 @@
-<?php
-
-namespace Hfig\MAPI\Property;
-
-use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
-
-class PropertyStoreEncodings
-{
-    public const ENCODERS = [
-        0x000d => 'decode0x000d',
-        0x001f => 'decode0x001f',
-        0x001e => 'decode0x001e',
-        0x0203 => 'decode0x0102',
-    ];
-
-    public static function decode0x000d(Element $e):Element
-    {
-        return $e;
-    }
-
-    public static function decode0x001f(Element $e)
-    {
-        return mb_convert_encoding( $e->getData(), 'UTF-8', 'UTF-16LE');
-    }
-
-    public static function decode0x001e(Element $e)
-    {
-        return trim($e->getData());
-    }
-
-    public static function decode0x0102(Element $e)
-    {
-        return $e->getData();
-    }
-
-    public static function decodeUnknown(Element $e)
-    {
-        return $e->getData();
-    }
-
-    public static function decode($encoding, Element $e)
-    {
-        if (isset(self::ENCODERS[$encoding])) {
-            $fn = self::ENCODERS[$encoding];
-            return self::$fn($e);
-        }
-        return self::decodeUnknown($e);
-
-    }
-
-    public static function getDecoder($encoding)
-    {
-        if (isset(self::ENCODERS[$encoding])) {
-            $fn = self::ENCODERS[$encoding];
-            return self::$fn;
-        }
-        return self::decodeUnknown;
-    }
-
-    public static function decodeFunction($encoding, Element $e)
-    {
-        return function() use ($encoding, $e) {
-            return PropertyStoreEncodings::decode($encoding, $e);
-        };
-    }
-}
\ No newline at end of file
+<?php
+
+namespace Hfig\MAPI\Property;
+
+use Hfig\MAPI\OLE\CompoundDocumentElement as Element;
+
+class PropertyStoreEncodings
+{
+    public const ENCODERS = [
+        0x000D => 'decode0x000d',
+        0x001F => 'decode0x001f',
+        0x001E => 'decode0x001e',
+        0x0203 => 'decode0x0102',
+    ];
+
+    public static function decode0x000d(Element $e): Element
+    {
+        return $e;
+    }
+
+    public static function decode0x001f(Element $e): string
+    {
+        return mb_convert_encoding($e->getData(), 'UTF-8', 'UTF-16LE');
+    }
+
+    public static function decode0x001e(Element $e): string
+    {
+        return trim((string) $e->getData());
+    }
+
+    public static function decode0x0102(Element $e): string
+    {
+        return $e->getData();
+    }
+
+    public static function decodeUnknown(Element $e): string
+    {
+        return $e->getData();
+    }
+
+    public static function decode($encoding, Element $e): Element|string
+    {
+        if (isset(self::ENCODERS[$encoding])) {
+            $fn = self::ENCODERS[$encoding];
+
+            return self::$fn($e);
+        }
+
+        return self::decodeUnknown($e);
+    }
+
+    public static function getDecoder($encoding): callable
+    {
+        if (isset(self::ENCODERS[$encoding])) {
+            $fn = self::ENCODERS[$encoding];
+
+            return [self::class, $fn];
+        }
+
+        return self::decodeUnknown(...);
+    }
+
+    public static function decodeFunction($encoding, Element $e): callable
+    {
+        return static fn () => PropertyStoreEncodings::decode($encoding, $e);
+    }
+}
diff --git a/tests/MAPI/MapiMessageFactoryTest.php b/tests/MAPI/MapiMessageFactoryTest.php
index 9be7fc8..688d11a 100644
--- a/tests/MAPI/MapiMessageFactoryTest.php
+++ b/tests/MAPI/MapiMessageFactoryTest.php
@@ -2,48 +2,48 @@
 
 namespace Hfig\MAPI\Tests;
 
-use Hfig\MAPI\OLE\Pear\DocumentFactory;
 use Hfig\MAPI\MapiMessageFactory;
+use Hfig\MAPI\OLE\Pear\DocumentFactory;
 use PHPUnit\Framework\TestCase;
 
 class MapiMessageFactoryTest extends TestCase
 {
-    public function testParseMessage()
+    public function testParseMessage(): void
     {
         $documentFactory = new DocumentFactory();
-        $messageFactory = new MapiMessageFactory();
+        $messageFactory  = new MapiMessageFactory();
 
         $ole = $documentFactory->createFromFile(__DIR__.'/../_files/sample.msg');
 
         $message = $messageFactory->parseMessage($ole);
 
-        $this->assertEquals('Testing Manuel Lemos\' MIME E-mail composing and sending PHP class: HTML message',$message->properties['subject']);
+        $this->assertEquals('Testing Manuel Lemos\' MIME E-mail composing and sending PHP class: HTML message', $message->properties['subject']);
         $this->assertEquals(
             "Testing Manuel Lemos' MIME E-mail composing and sending PHP class: HTML message\r\n________________________________\r\n\r\nHello Manuel,\r\n\r\nThis message is just to let you know that the MIME E-mail message composing and sending PHP class<http://www.phpclasses.org/mimemessage> is working as expected.\r\n\r\nHere is an image embedded in a message as a separate part:\r\n[cid:ae0357e57f04b8347f7621662cb63855.gif]\r\nThank you,\r\nmlemos\r\n\r\n",
-            $message->getBody()
+            $message->getBody(),
         );
-        $this->assertEquals('<20050430192829.0489.mlemos@acm.org>',$message->getInternetMessageId());
+        $this->assertEquals('<20050430192829.0489.mlemos@acm.org>', $message->getInternetMessageId());
 
         $attachments = $message->getAttachments();
-        $this->assertCount(3,$attachments);
+        $this->assertCount(3, $attachments);
 
-        $this->assertEquals('attachment.txt',$attachments[0]->getFilename());
+        $this->assertEquals('attachment.txt', $attachments[0]->getFilename());
         $this->assertNull($attachments[0]->getContentId());
-        $this->assertEquals('This is just a plain text attachment file named attachment.txt .',$attachments[0]->getData());
+        $this->assertEquals('This is just a plain text attachment file named attachment.txt .', $attachments[0]->getData());
 
-        $this->assertEquals('logo.gif',$attachments[1]->getFilename());
-        $this->assertEquals('ae0357e57f04b8347f7621662cb63855.gif',$attachments[1]->getContentId());
+        $this->assertEquals('logo.gif', $attachments[1]->getFilename());
+        $this->assertEquals('ae0357e57f04b8347f7621662cb63855.gif', $attachments[1]->getContentId());
 
-        $this->assertEquals('background.gif',$attachments[2]->getFilename());
-        $this->assertEquals('4c837ed463ad29c820668e835a270e8a.gif',$attachments[2]->getContentId());
+        $this->assertEquals('background.gif', $attachments[2]->getFilename());
+        $this->assertEquals('4c837ed463ad29c820668e835a270e8a.gif', $attachments[2]->getContentId());
 
         $this->assertEquals(new \DateTime('2005-04-30 22:28:29', new \DateTimeZone('UTC')), $message->getSendTime());
     }
 
-    public function testParseMessage2()
+    public function testParseMessage2(): void
     {
         $documentFactory = new DocumentFactory();
-        $messageFactory = new MapiMessageFactory();
+        $messageFactory  = new MapiMessageFactory();
 
         $ole = $documentFactory->createFromFile(__DIR__.'/../_files/Swetlana.msg');
 
diff --git a/tests/MAPI/OLE/Time/OleTimeTest.php b/tests/MAPI/OLE/Time/OleTimeTest.php
index f73b4e3..a95cd3d 100644
--- a/tests/MAPI/OLE/Time/OleTimeTest.php
+++ b/tests/MAPI/OLE/Time/OleTimeTest.php
@@ -7,17 +7,15 @@
 
 class OleTimeTest extends TestCase
 {
-    /**
-     * @dataProvider getTimeFromOleTimeProvider
-     */
-    public function testGetTimeFromOleTime(int $number, string $input, int $expected)
+    #[\PHPUnit\Framework\Attributes\DataProvider('getTimeFromOleTimeProvider')]
+    public function testGetTimeFromOleTime(int $number, string $input, int $expected): void
     {
         $actual = OleTime::getTimeFromOleTime($input);
 
-        $this->assertEquals($expected,$actual, sprintf('Failed test %d',$number));
+        $this->assertEquals($expected, $actual, sprintf('Failed test %d', $number));
     }
 
-    public function getTimeFromOleTimeProvider()
+    public static function getTimeFromOleTimeProvider(): array
     {
         return [
             [1, hex2bin('4012a294ea41c601'), 1141737919],
diff --git a/tests/MAPI/Property/PropertySetConstantsTest.php b/tests/MAPI/Property/PropertySetConstantsTest.php
new file mode 100644
index 0000000..a3e3723
--- /dev/null
+++ b/tests/MAPI/Property/PropertySetConstantsTest.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hfig\MAPI\Tests\Property;
+
+use Hfig\MAPI\Property\PropertySetConstants;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Ramsey\Uuid\UuidInterface as OleGuidInterface;
+
+class PropertySetConstantsTest extends TestCase
+{
+    #[DataProvider('provide_method_cases')]
+    public function testMethodExists(string $method): void
+    {
+        $guid = PropertySetConstants::{$method}();
+
+        $this->assertInstanceOf(OleGuidInterface::class, $guid);
+    }
+
+    #[DataProvider('provide_method_cases')]
+    public function testMethodReturnsSameObjectWhenCalledTwice(string $method): void
+    {
+        $this->assertSame(
+            PropertySetConstants::{$method}(),
+            PropertySetConstants::{$method}()
+        );
+    }
+
+    public static function provide_method_cases(): iterable
+    {
+        foreach (PropertySetConstants::NAMES as $name) {
+            yield $name => [$name];
+        }
+    }
+}