From 9d869b1ece06c548cd2873b0834142f6c6f606bf Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 14 Aug 2020 14:22:58 +0200 Subject: [PATCH] Fix Mime message serialization --- .../Twig/Tests/Mime/TemplatedEmailTest.php | 80 ++++++++++++ src/Symfony/Bridge/Twig/composer.json | 5 +- .../Resources/config/serializer.php | 18 +++ .../Bundle/FrameworkBundle/composer.json | 4 +- src/Symfony/Component/Mime/Email.php | 2 +- src/Symfony/Component/Mime/Header/Headers.php | 3 + src/Symfony/Component/Mime/Part/TextPart.php | 3 + .../Component/Mime/Tests/EmailTest.php | 74 +++++++++++ .../Component/Mime/Tests/MessageTest.php | 115 ++++++++++++++++ src/Symfony/Component/Mime/composer.json | 6 +- .../Normalizer/MimeMessageNormalizer.php | 123 ++++++++++++++++++ .../Component/Serializer/composer.json | 2 +- 12 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php diff --git a/src/Symfony/Bridge/Twig/Tests/Mime/TemplatedEmailTest.php b/src/Symfony/Bridge/Twig/Tests/Mime/TemplatedEmailTest.php index 999ca4d078d58..186f8b01b2bf9 100644 --- a/src/Symfony/Bridge/Twig/Tests/Mime/TemplatedEmailTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Mime/TemplatedEmailTest.php @@ -4,6 +4,13 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; +use Symfony\Component\Serializer\Serializer; class TemplatedEmailTest extends TestCase { @@ -33,4 +40,77 @@ public function testSerialize() $this->assertEquals('text.html.twig', $email->getHtmlTemplate()); $this->assertEquals($context, $email->getContext()); } + + public function testSymfonySerialize() + { + // we don't add from/sender to check that validation is not triggered to serialize an email + $e = new TemplatedEmail(); + $e->to('you@example.com'); + $e->textTemplate('email.txt.twig'); + $e->htmlTemplate('email.html.twig'); + $e->context(['foo' => 'bar']); + $e->attach('Some Text file', 'test.txt'); + $expected = clone $e; + + $expectedJson = <<serialize($e, 'json'); + $this->assertSame($expectedJson, json_encode(json_decode($serialized), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $n = $serializer->deserialize($serialized, TemplatedEmail::class, 'json'); + $serialized = $serializer->serialize($e, 'json'); + $this->assertSame($expectedJson, json_encode(json_decode($serialized), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $n->from('fabien@symfony.com'); + $expected->from('fabien@symfony.com'); + $this->assertEquals($expected->getHeaders(), $n->getHeaders()); + $this->assertEquals($expected->getBody(), $n->getBody()); + } } diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index f90018f48b3de..4d19c35bf4753 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -23,14 +23,16 @@ }, "require-dev": { "egulias/email-validator": "^2.1.10", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "symfony/asset": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0", "symfony/finder": "^4.4|^5.0", "symfony/form": "^5.1", "symfony/http-foundation": "^4.4|^5.0", "symfony/http-kernel": "^4.4|^5.0", - "symfony/mime": "^4.4|^5.0", + "symfony/mime": "^5.2", "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^4.4|^5.1", "symfony/routing": "^4.4|^5.0", "symfony/translation": "^5.0", "symfony/yaml": "^4.4|^5.0", @@ -38,6 +40,7 @@ "symfony/security-core": "^4.4|^5.0", "symfony/security-csrf": "^4.4|^5.0", "symfony/security-http": "^4.4|^5.0", + "symfony/serializer": "^5.2", "symfony/stopwatch": "^4.4|^5.0", "symfony/console": "^4.4|^5.0", "symfony/expression-language": "^4.4|^5.0", diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index fbeb348b6e550..a0c5be34b993b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -39,9 +39,11 @@ use Symfony\Component\Serializer\Normalizer\DateTimeZoneNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; +use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; +use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; @@ -76,6 +78,10 @@ ->args([[], service('serializer.name_converter.metadata_aware')]) ->tag('serializer.normalizer', ['priority' => -915]) + ->set('serializer.normalizer.mime_message', MimeMessageNormalizer::class) + ->args([service('serializer.normalizer.property')]) + ->tag('serializer.normalizer', ['priority' => -915]) + ->set('serializer.normalizer.datetimezone', DateTimeZoneNormalizer::class) ->tag('serializer.normalizer', ['priority' => -915]) @@ -114,6 +120,18 @@ ->alias(ObjectNormalizer::class, 'serializer.normalizer.object') + ->set('serializer.normalizer.property', PropertyNormalizer::class) + ->args([ + service('serializer.mapping.class_metadata_factory'), + service('serializer.name_converter.metadata_aware'), + service('property_info')->ignoreOnInvalid(), + service('serializer.mapping.class_discriminator_resolver')->ignoreOnInvalid(), + null, + [], + ]) + + ->alias(PropertyNormalizer::class, 'serializer.normalizer.property') + ->set('serializer.denormalizer.array', ArrayDenormalizer::class) ->tag('serializer.normalizer', ['priority' => -990]) diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 0d84c344aa422..cd49e43511ea9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -52,7 +52,7 @@ "symfony/security-bundle": "^5.1", "symfony/security-csrf": "^4.4|^5.0", "symfony/security-http": "^4.4|^5.0", - "symfony/serializer": "^4.4|^5.0", + "symfony/serializer": "^5.2", "symfony/stopwatch": "^4.4|^5.0", "symfony/string": "^5.0", "symfony/translation": "^5.0", @@ -62,7 +62,7 @@ "symfony/yaml": "^4.4|^5.0", "symfony/property-info": "^4.4|^5.0", "symfony/web-link": "^4.4|^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "paragonie/sodium_compat": "^1.8", "twig/twig": "^2.10|^3.0" }, diff --git a/src/Symfony/Component/Mime/Email.php b/src/Symfony/Component/Mime/Email.php index e5f9f11b36fc4..b21e99e8961d5 100644 --- a/src/Symfony/Component/Mime/Email.php +++ b/src/Symfony/Component/Mime/Email.php @@ -378,7 +378,7 @@ public function attachPart(DataPart $part) } /** - * @return DataPart[] + * @return array|DataPart[] */ public function getAttachments(): array { diff --git a/src/Symfony/Component/Mime/Header/Headers.php b/src/Symfony/Component/Mime/Header/Headers.php index 3f1efcbbebe81..9493e2c2da234 100644 --- a/src/Symfony/Component/Mime/Header/Headers.php +++ b/src/Symfony/Component/Mime/Header/Headers.php @@ -39,6 +39,9 @@ final class Headers 'return-path' => PathHeader::class, ]; + /** + * @var HeaderInterface[][] + */ private $headers = []; private $lineLength = 76; diff --git a/src/Symfony/Component/Mime/Part/TextPart.php b/src/Symfony/Component/Mime/Part/TextPart.php index 72c7d4f695962..8772a3367c1b6 100644 --- a/src/Symfony/Component/Mime/Part/TextPart.php +++ b/src/Symfony/Component/Mime/Part/TextPart.php @@ -28,6 +28,9 @@ class TextPart extends AbstractPart private $body; private $charset; private $subtype; + /** + * @var ?string + */ private $disposition; private $name; private $encoding; diff --git a/src/Symfony/Component/Mime/Tests/EmailTest.php b/src/Symfony/Component/Mime/Tests/EmailTest.php index 230df0791e15b..117c19e4f8388 100644 --- a/src/Symfony/Component/Mime/Tests/EmailTest.php +++ b/src/Symfony/Component/Mime/Tests/EmailTest.php @@ -19,6 +19,13 @@ use Symfony\Component\Mime\Part\Multipart\MixedPart; use Symfony\Component\Mime\Part\Multipart\RelatedPart; use Symfony\Component\Mime\Part\TextPart; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; +use Symfony\Component\Serializer\Serializer; class EmailTest extends TestCase { @@ -384,4 +391,71 @@ public function testSerialize() $this->assertEquals($expected->getHeaders(), $n->getHeaders()); $this->assertEquals($e->getBody(), $n->getBody()); } + + public function testSymfonySerialize() + { + // we don't add from/sender to check that validation is not triggered to serialize an email + $e = new Email(); + $e->to('you@example.com'); + $e->text('Text content'); + $e->html('HTML content'); + $e->attach('Some Text file', 'test.txt'); + $expected = clone $e; + + $expectedJson = <<content", + "htmlCharset": "utf-8", + "attachments": [ + { + "body": "Some Text file", + "name": "test.txt", + "content-type": null, + "inline": false + } + ], + "headers": { + "to": [ + { + "addresses": [ + { + "address": "you@example.com", + "name": "" + } + ], + "name": "To", + "lineLength": 76, + "lang": null, + "charset": "utf-8" + } + ] + }, + "body": null, + "message": null +} +EOF; + + $extractor = new PhpDocExtractor(); + $propertyNormalizer = new PropertyNormalizer(null, null, $extractor); + $serializer = new Serializer([ + new ArrayDenormalizer(), + new MimeMessageNormalizer($propertyNormalizer), + new ObjectNormalizer(null, null, null, $extractor), + $propertyNormalizer, + ], [new JsonEncoder()]); + + $serialized = $serializer->serialize($e, 'json'); + $this->assertSame($expectedJson, json_encode(json_decode($serialized), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $n = $serializer->deserialize($serialized, Email::class, 'json'); + $serialized = $serializer->serialize($e, 'json'); + $this->assertSame($expectedJson, json_encode(json_decode($serialized), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $n->from('fabien@symfony.com'); + $expected->from('fabien@symfony.com'); + $this->assertEquals($expected->getHeaders(), $n->getHeaders()); + $this->assertEquals($expected->getBody(), $n->getBody()); + } } diff --git a/src/Symfony/Component/Mime/Tests/MessageTest.php b/src/Symfony/Component/Mime/Tests/MessageTest.php index bd5d7ca8903d5..ed9b8e614246a 100644 --- a/src/Symfony/Component/Mime/Tests/MessageTest.php +++ b/src/Symfony/Component/Mime/Tests/MessageTest.php @@ -17,7 +17,17 @@ use Symfony\Component\Mime\Header\MailboxListHeader; use Symfony\Component\Mime\Header\UnstructuredHeader; use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\AlternativePart; +use Symfony\Component\Mime\Part\Multipart\MixedPart; use Symfony\Component\Mime\Part\TextPart; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; +use Symfony\Component\Serializer\Serializer; class MessageTest extends TestCase { @@ -147,4 +157,109 @@ public function testToString() $this->assertStringMatchesFormat($expected, str_replace("\r\n", "\n", $message->toString())); $this->assertStringMatchesFormat($expected, str_replace("\r\n", "\n", implode('', iterator_to_array($message->toIterable(), false)))); } + + public function testSymfonySerialize() + { + // we don't add from/sender to check that it's not needed to serialize an email + $body = new MixedPart( + new AlternativePart( + new TextPart('Text content'), + new TextPart('HTML content', 'utf-8', 'html') + ), + new DataPart('text data', 'text.txt') + ); + $e = new Message((new Headers())->addMailboxListHeader('To', ['you@example.com']), $body); + $expected = clone $e; + + $expectedJson = <<serialize($e, 'json'); + $this->assertSame($expectedJson, json_encode(json_decode($serialized), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $n = $serializer->deserialize($serialized, Message::class, 'json'); + $this->assertEquals($expected->getHeaders(), $n->getHeaders()); + + $serialized = $serializer->serialize($e, 'json'); + $this->assertSame($expectedJson, json_encode(json_decode($serialized), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } } diff --git a/src/Symfony/Component/Mime/composer.json b/src/Symfony/Component/Mime/composer.json index 9e4b0e5803e14..62a3d49e44dff 100644 --- a/src/Symfony/Component/Mime/composer.json +++ b/src/Symfony/Component/Mime/composer.json @@ -17,10 +17,14 @@ ], "require": { "php": ">=7.2.5", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "symfony/deprecation-contracts": "^2.1", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.15" + "symfony/polyfill-php80": "^1.15", + "symfony/property-access": "^4.4|^5.1", + "symfony/property-info": "^4.4|^5.1", + "symfony/serializer": "^5.2" }, "require-dev": { "egulias/email-validator": "^2.1.10", diff --git a/src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php new file mode 100644 index 0000000000000..a1c4f169bbf5e --- /dev/null +++ b/src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Normalizer; + +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\Header\UnstructuredHeader; +use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\Part\AbstractPart; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Normalize Mime message classes. + * + * It forces the use of a PropertyNormalizer instance for normalization + * of all data objects composing a Message. + * + * Emails using resources for any parts are not serializable. + */ +final class MimeMessageNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +{ + private $serializer; + private $normalizer; + private $headerClassMap; + private $headersProperty; + + public function __construct(PropertyNormalizer $normalizer) + { + $this->normalizer = $normalizer; + $this->headerClassMap = (new \ReflectionClassConstant(Headers::class, 'HEADER_CLASS_MAP'))->getValue(); + $this->headersProperty = new \ReflectionProperty(Headers::class, 'headers'); + $this->headersProperty->setAccessible(true); + } + + public function setSerializer(SerializerInterface $serializer) + { + $this->serializer = $serializer; + $this->normalizer->setSerializer($serializer); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, ?string $format = null, array $context = []) + { + if ($object instanceof Headers) { + $ret = []; + foreach ($this->headersProperty->getValue($object) as $name => $header) { + $ret[$name] = $this->serializer->normalize($header, $format, $context); + } + + return $ret; + } + + if ($object instanceof AbstractPart) { + $ret = $this->normalizer->normalize($object, $format, $context); + $ret['class'] = \get_class($object); + + return $ret; + } + + return $this->normalizer->normalize($object, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, string $type, ?string $format = null, array $context = []) + { + if (Headers::class === $type) { + $ret = []; + foreach ($data as $headers) { + foreach ($headers as $header) { + $ret[] = $this->serializer->denormalize($header, $this->headerClassMap[strtolower($header['name'])] ?? UnstructuredHeader::class, $format, $context); + } + } + + return new Headers(...$ret); + } + + if (AbstractPart::class === $type) { + $type = $data['class']; + unset($data['class']); + } + + return $this->normalizer->denormalize($data, $type, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, string $format = null) + { + return $data instanceof Message || $data instanceof Headers || $data instanceof HeaderInterface || $data instanceof Address || $data instanceof AbstractPart; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, string $type, ?string $format = null) + { + return is_a($type, Message::class, true) || Headers::class === $type || AbstractPart::class === $type; + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return __CLASS__ === static::class; + } +} diff --git a/src/Symfony/Component/Serializer/composer.json b/src/Symfony/Component/Serializer/composer.json index f2de3df93df7f..9e6b2d60ab5e4 100644 --- a/src/Symfony/Component/Serializer/composer.json +++ b/src/Symfony/Component/Serializer/composer.json @@ -23,7 +23,7 @@ "require-dev": { "doctrine/annotations": "~1.0", "doctrine/cache": "~1.0", - "phpdocumentor/reflection-docblock": "^3.2|^4.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "symfony/cache": "^4.4|^5.0", "symfony/config": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy