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",