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]; + } + } +}