From d768b3a988e7a3dd8f074cfb5d3095e8366f6129 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 19 Sep 2024 12:52:37 +0200 Subject: [PATCH] Add support for images embedded in HTML (#361) --- UPGRADE.md | 1 - composer.json | 8 +- .../CompilerPass/AbstractGatewayPass.php | 2 + src/EventListener/ProcessFormDataListener.php | 2 +- src/Gateway/AbstractGateway.php | 32 +++- src/Gateway/MailerGateway.php | 91 +++++++--- src/NotificationCenter.php | 11 +- src/Parcel/Stamp/Mailer/EmailStamp.php | 28 ++- tests/BulkyItem/InMemoryDbafs.php | 68 +++++++ .../BulkyItem/VirtualFilesystemCollection.php | 29 +++ .../AdminEmailTokenListenerTest.php | 10 +- tests/Gateway/MailerGatewayTest.php | 168 ++++++++++++++++++ tests/Token/TokenTest.php | 17 +- 13 files changed, 427 insertions(+), 40 deletions(-) create mode 100644 tests/BulkyItem/InMemoryDbafs.php create mode 100644 tests/BulkyItem/VirtualFilesystemCollection.php create mode 100644 tests/Gateway/MailerGatewayTest.php diff --git a/UPGRADE.md b/UPGRADE.md index f0931e17..c1bdc33c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -3,7 +3,6 @@ * The built-in Postmark gateway has been removed. * The built-in queue gateway has been removed. * The built-in file gateway has been removed. -* Embedding images in e-mails is not supported anymore. * Attachment templates are not supported anymore. * The configurable flattening delimiter in the e-mail notification type has been removed. * The configurable template in the notification type has been removed. diff --git a/composer.json b/composer.json index 6ddbd2b5..8b30cef7 100644 --- a/composer.json +++ b/composer.json @@ -54,9 +54,11 @@ "require-dev": { "contao/manager-plugin": "^2.0", "contao/newsletter-bundle": "^5.0", - "phpunit/phpunit": "^10.0", - "terminal42/contao-build-tools": "dev-main", - "contao/test-case": "^4.9" + "contao/test-case": "^5.3", + "league/flysystem-memory": "^3.25", + "phpunit/phpunit": "^9.6", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0", + "terminal42/contao-build-tools": "dev-main" }, "suggest": { "terminal42/contao-notification-center-pro": "Turn your Notification Center 2 into a pro version and benefit from logs, various testing tools and your own Simple Tokens that can be completely customized with Twig." diff --git a/src/DependencyInjection/CompilerPass/AbstractGatewayPass.php b/src/DependencyInjection/CompilerPass/AbstractGatewayPass.php index df3441a9..069b2bee 100644 --- a/src/DependencyInjection/CompilerPass/AbstractGatewayPass.php +++ b/src/DependencyInjection/CompilerPass/AbstractGatewayPass.php @@ -9,6 +9,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Reference; +use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage; use Terminal42\NotificationCenterBundle\DependencyInjection\Terminal42NotificationCenterExtension; use Terminal42\NotificationCenterBundle\Gateway\AbstractGateway; use Terminal42\NotificationCenterBundle\NotificationCenter; @@ -20,6 +21,7 @@ public function process(ContainerBuilder $container): void $taggedServices = $container->findTaggedServiceIds(Terminal42NotificationCenterExtension::GATEWAY_TAG); $locateableServices = [ AbstractGateway::SERVICE_NAME_NOTIFICATION_CENTER => new Reference(NotificationCenter::class), + AbstractGateway::SERVICE_NAME_BULKY_ITEM_STORAGE => new Reference(BulkyItemStorage::class), AbstractGateway::SERVICE_NAME_SIMPLE_TOKEN_PARSER => new Reference('contao.string.simple_token_parser', ContainerInterface::NULL_ON_INVALID_REFERENCE), AbstractGateway::SERVICE_NAME_INSERT_TAG_PARSER => new Reference('contao.insert_tag.parser', ContainerInterface::NULL_ON_INVALID_REFERENCE), ]; diff --git a/src/EventListener/ProcessFormDataListener.php b/src/EventListener/ProcessFormDataListener.php index 3c2e74cf..01c98c52 100644 --- a/src/EventListener/ProcessFormDataListener.php +++ b/src/EventListener/ProcessFormDataListener.php @@ -76,7 +76,7 @@ public function __invoke(array $submittedData, array $formData, array|null $file FileItem::fromStream($file['stream'], $file['name'], $file['type'], $file['size']) : FileItem::fromPath($file['tmp_name'], $file['name'], $file['type'], $file['size']); - $vouchers[] = $this->notificationCenter->getBulkyGoodsStorage()->store($fileItem); + $vouchers[] = $this->notificationCenter->getBulkyItemStorage()->store($fileItem); } $tokens['form_'.$k] = implode(',', $vouchers); diff --git a/src/Gateway/AbstractGateway.php b/src/Gateway/AbstractGateway.php index 773e7e49..0f979f06 100644 --- a/src/Gateway/AbstractGateway.php +++ b/src/Gateway/AbstractGateway.php @@ -7,6 +7,7 @@ use Contao\CoreBundle\InsertTag\InsertTagParser; use Contao\CoreBundle\String\SimpleTokenParser; use Psr\Container\ContainerInterface; +use Terminal42\NotificationCenterBundle\BulkyItem\BulkyItemStorage; use Terminal42\NotificationCenterBundle\Exception\Parcel\CouldNotDeliverParcelException; use Terminal42\NotificationCenterBundle\Exception\Parcel\CouldNotSealParcelException; use Terminal42\NotificationCenterBundle\NotificationCenter; @@ -20,6 +21,8 @@ abstract class AbstractGateway implements GatewayInterface { public const SERVICE_NAME_NOTIFICATION_CENTER = 'notification_center'; + public const SERVICE_NAME_BULKY_ITEM_STORAGE = 'bulky_item_storage'; + public const SERVICE_NAME_SIMPLE_TOKEN_PARSER = 'simple_token_parser'; public const SERVICE_NAME_INSERT_TAG_PARSER = 'insert_tag_parser'; @@ -83,15 +86,23 @@ protected function replaceTokens(Parcel $parcel, string $value): string return $value; } - return $this->getSimpleTokenParser()?->parse( - $value, - $parcel->getStamp(TokenCollectionStamp::class)->tokenCollection->forSimpleTokenParser(), - ); + if ($simpleTokenParser = $this->getSimpleTokenParser()) { + return $simpleTokenParser->parse( + $value, + $parcel->getStamp(TokenCollectionStamp::class)->tokenCollection->forSimpleTokenParser(), + ); + } + + return $value; } protected function replaceInsertTags(string $value): string { - return $this->getInsertTagParser()?->replaceInline($value); + if ($insertTagParser = $this->getInsertTagParser()) { + return $insertTagParser->replaceInline($value); + } + + return $value; } protected function replaceTokensAndInsertTags(Parcel $parcel, string $value): string @@ -143,4 +154,15 @@ protected function getNotificationCenter(): NotificationCenter|null return !$notificationCenter instanceof NotificationCenter ? null : $notificationCenter; } + + protected function getBulkyItemStorage(): BulkyItemStorage|null + { + if (null === $this->container || !$this->container->has(self::SERVICE_NAME_BULKY_ITEM_STORAGE)) { + return null; + } + + $bulkyItemStorage = $this->container->get(self::SERVICE_NAME_BULKY_ITEM_STORAGE); + + return !$bulkyItemStorage instanceof BulkyItemStorage ? null : $bulkyItemStorage; + } } diff --git a/src/Gateway/MailerGateway.php b/src/Gateway/MailerGateway.php index 2b6c4231..0d971a3c 100644 --- a/src/Gateway/MailerGateway.php +++ b/src/Gateway/MailerGateway.php @@ -6,7 +6,7 @@ use Contao\Controller; use Contao\CoreBundle\Filesystem\Dbafs\UnableToResolveUuidException; -use Contao\CoreBundle\Filesystem\VirtualFilesystem; +use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface; use Contao\CoreBundle\Framework\ContaoFramework; use Contao\CoreBundle\Util\LocaleUtil; use Contao\FrontendTemplate; @@ -33,7 +33,7 @@ class MailerGateway extends AbstractGateway public function __construct( private readonly ContaoFramework $contaoFramework, - private readonly VirtualFilesystem $filesystem, + private readonly VirtualFilesystemInterface $filesStorage, private readonly MailerInterface $mailer, ) { } @@ -143,6 +143,7 @@ private function createEmailStamp(Parcel $parcel): EmailStamp $stamp = $stamp->withText($text); if ($html) { + $html = $this->embedImages($html, $stamp); $stamp = $stamp->withHtml($html); } @@ -176,7 +177,7 @@ private function createEmail(Parcel $parcel): Email // Attachments foreach ($emailStamp->getAttachmentVouchers() as $voucher) { - $item = $this->getNotificationCenter()->getBulkyGoodsStorage()->retrieve($voucher); + $item = $this->getBulkyItemStorage()?->retrieve($voucher); if ($item instanceof FileItem) { $email->attach( @@ -187,6 +188,14 @@ private function createEmail(Parcel $parcel): Email } } + // Embedded images + foreach ($emailStamp->getEmbeddedImageVouchers() as $voucher) { + $item = $this->getBulkyItemStorage()?->retrieve($voucher); + if ($item instanceof FileItem) { + $email->attach($item->getContents(), $this->encodeVoucherForContentId($voucher)); + } + } + return $email; } @@ -267,30 +276,17 @@ private function copyBackendAttachments(Parcel $parcel, LanguageConfig $language try { $uuidObject = Uuid::isValid($uuid) ? Uuid::fromString($uuid) : Uuid::fromBinary($uuid); - - if (null === ($item = $this->filesystem->get($uuidObject))) { - continue; - } } catch (\InvalidArgumentException|UnableToResolveUuidException) { continue; } - if (!$item->isFile()) { + $voucher = $this->createBulkyItemStorageVoucher($uuidObject, $this->filesStorage); + + if (null === $voucher) { continue; } - $voucher = $this->getNotificationCenter()?->getBulkyGoodsStorage()->store( - FileItem::fromStream( - $this->filesystem->readStream($uuidObject), - $item->getName(), - $item->getMimeType(), - $item->getFileSize(), - ), - ); - - if (null !== $voucher) { - $vouchers[] = $voucher; - } + $vouchers[] = $voucher; } if (0 === \count($vouchers)) { @@ -299,4 +295,59 @@ private function copyBackendAttachments(Parcel $parcel, LanguageConfig $language return $parcel->withStamp(new BackendAttachmentsStamp($vouchers)); } + + private function createBulkyItemStorageVoucher(Uuid|string $location, VirtualFilesystemInterface $filesystem): string|null + { + try { + if (null === ($item = $filesystem->get($location))) { + return null; + } + } catch (\InvalidArgumentException|UnableToResolveUuidException) { + return null; + } + + if (!$item->isFile()) { + return null; + } + + return $this->getBulkyItemStorage()?->store( + FileItem::fromStream( + $filesystem->readStream($location), + $item->getName(), + $item->getMimeType(), + $item->getFileSize(), + ), + ); + } + + private function encodeVoucherForContentId(string $voucher): string + { + return rawurlencode($voucher); + } + + private function embedImages(string $html, EmailStamp &$stamp): string + { + $prefixToStrip = ''; + + if (method_exists($this->filesStorage, 'getPrefix')) { + $prefixToStrip = $this->filesStorage->getPrefix(); + } + + return preg_replace_callback( + '/<[a-z][a-z0-9]*\b[^>]*((src=|background=|url\()["\']??)(.+\.(jpe?g|png|gif|bmp|tiff?|swf))(["\' ]??(\)??))[^>]*>/Ui', + function ($matches) use (&$stamp, $prefixToStrip) { + $location = ltrim(ltrim(ltrim($matches[3], '/'), $prefixToStrip), '/'); + $voucher = $this->createBulkyItemStorageVoucher($location, $this->filesStorage); + + if (null === $voucher) { + return $matches[0]; + } + + $stamp = $stamp->withEmbeddedImageVoucher($voucher); + + return str_replace($matches[3], 'cid:'.$this->encodeVoucherForContentId($voucher), $matches[0]); + }, + $html, + ); + } } diff --git a/src/NotificationCenter.php b/src/NotificationCenter.php index 5345ad38..a69bbe63 100644 --- a/src/NotificationCenter.php +++ b/src/NotificationCenter.php @@ -50,7 +50,7 @@ public function __construct( private readonly ConfigLoader $configLoader, private readonly EventDispatcherInterface $eventDispatcher, private readonly RequestStack $requestStack, - private readonly BulkyItemStorage $bulkyGoodsStorage, + private readonly BulkyItemStorage $bulkyItemStorage, private readonly StringParser $stringParser, private readonly LocaleSwitcher|null $localeSwitcher, ) { @@ -58,7 +58,14 @@ public function __construct( public function getBulkyGoodsStorage(): BulkyItemStorage { - return $this->bulkyGoodsStorage; + trigger_deprecation('terminal42/notification_center', '2.1', 'Using "getBulkyGoodsStorage()" is deprecated, use "getBulkyItemStorage()" instead.'); + + return $this->bulkyItemStorage; + } + + public function getBulkyItemStorage(): BulkyItemStorage + { + return $this->bulkyItemStorage; } /** diff --git a/src/Parcel/Stamp/Mailer/EmailStamp.php b/src/Parcel/Stamp/Mailer/EmailStamp.php index 4c46c214..e7a61bec 100644 --- a/src/Parcel/Stamp/Mailer/EmailStamp.php +++ b/src/Parcel/Stamp/Mailer/EmailStamp.php @@ -34,6 +34,11 @@ class EmailStamp implements StampInterface */ private array $attachmentVouchers = []; + /** + * @var array + */ + private array $embeddedImageVouchers = []; + public function withFromName(string $fromName): self { $clone = clone $this; @@ -106,6 +111,22 @@ public function withHtml(string $html): self return $clone; } + /** + * @return array + */ + public function getEmbeddedImageVouchers(): array + { + return $this->embeddedImageVouchers; + } + + public function withEmbeddedImageVoucher(string $voucher): self + { + $clone = clone $this; + $clone->embeddedImageVouchers[] = $voucher; + + return $clone; + } + public function withAttachmentVoucher(string $voucher): self { $clone = clone $this; @@ -170,6 +191,7 @@ public function toArray(): array 'text' => $this->text, 'html' => $this->html, 'attachmentVouchers' => $this->attachmentVouchers, + 'embeddedImageVouchers' => $this->embeddedImageVouchers, ]; } @@ -187,10 +209,14 @@ public static function fromArray(array $data): StampInterface ->withHtml($data['html']) ; - foreach ($data['attachmentVouchers'] as $voucher) { + foreach ($data['attachmentVouchers'] ?? [] as $voucher) { $stamp = $stamp->withAttachmentVoucher($voucher); } + foreach ($data['embeddedImageVouchers'] ?? [] as $voucher) { + $stamp = $stamp->withEmbeddedImageVoucher($voucher); + } + return $stamp; } } diff --git a/tests/BulkyItem/InMemoryDbafs.php b/tests/BulkyItem/InMemoryDbafs.php new file mode 100644 index 00000000..3c3568a4 --- /dev/null +++ b/tests/BulkyItem/InMemoryDbafs.php @@ -0,0 +1,68 @@ + + */ + private array $records = []; + + /** + * @var array> + */ + private array $meta = []; + + public function getPathFromUuid(Uuid $uuid): string|null + { + throw new \RuntimeException('Not implemented'); + } + + public function getRecord(string $path): FilesystemItem|null + { + if (isset($this->records[$path])) { + return new FilesystemItem( + true, + $path, + null, + null, + null, + $this->meta[$path] ?? [], + ); + } + + return null; + } + + public function getRecords(string $path, bool $deep = false): iterable + { + throw new \RuntimeException('Not implemented'); + } + + public function setExtraMetadata(string $path, array $metadata): void + { + $this->meta[$path] = $metadata; + } + + public function sync(string ...$paths): ChangeSet + { + foreach ($paths as $path) { + $this->records[$path] = true; + } + + return new ChangeSet([], [], []); + } + + public function getSupportedFeatures(): int + { + return DbafsInterface::FEATURES_NONE; + } +} diff --git a/tests/BulkyItem/VirtualFilesystemCollection.php b/tests/BulkyItem/VirtualFilesystemCollection.php new file mode 100644 index 00000000..594e95b6 --- /dev/null +++ b/tests/BulkyItem/VirtualFilesystemCollection.php @@ -0,0 +1,29 @@ + $vfs + */ + public function __construct(private array $vfs = []) + { + } + + public function get(string $name): VirtualFilesystem + { + return $this->vfs[$name]; + } + + public function add(VirtualFilesystem $vfs): self + { + $this->vfs[$vfs->getPrefix()] = $vfs; + + return $this; + } +} diff --git a/tests/EventListener/AdminEmailTokenListenerTest.php b/tests/EventListener/AdminEmailTokenListenerTest.php index 40d69242..aaf30e9a 100644 --- a/tests/EventListener/AdminEmailTokenListenerTest.php +++ b/tests/EventListener/AdminEmailTokenListenerTest.php @@ -8,7 +8,6 @@ use Contao\CoreBundle\Framework\ContaoFramework; use Contao\PageModel; use Contao\TestCase\ContaoTestCase; -use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Terminal42\NotificationCenterBundle\Config\MessageConfig; @@ -21,7 +20,9 @@ class AdminEmailTokenListenerTest extends ContaoTestCase { - #[DataProvider('adminEmailProvider')] + /** + * @dataProvider adminEmailProvider + */ public function testAddsAdminTokens(string $configFriendlyEmail, string $pageFriendlyEmail, string $expectedName, string $expectedEmail): void { $pageModel = $this->mockClassWithProperties(PageModel::class, [ @@ -46,7 +47,10 @@ public function testAddsAdminTokens(string $configFriendlyEmail, string $pageFri $this->assertSame($expectedEmail, $tokenCollection->getByName('admin_email')->getValue()); } - public static function adminEmailProvider(): \Generator + /** + * @return iterable + */ + public static function adminEmailProvider(): iterable { yield 'Basic admin email in config' => [ 'foobar-config@terminal42.ch', diff --git a/tests/Gateway/MailerGatewayTest.php b/tests/Gateway/MailerGatewayTest.php new file mode 100644 index 00000000..94d96de2 --- /dev/null +++ b/tests/Gateway/MailerGatewayTest.php @@ -0,0 +1,168 @@ + $mockFiles + * @param array $expectedAttachmentsContentsAndPath + */ + public function testEmbeddingHtmlImages(string $parsedTemplateHtml, array $mockFiles, array $expectedAttachmentsContentsAndPath): void + { + $vfsCollection = $this->createVfsCollection(); + + foreach ($mockFiles as $path => $contents) { + $vfsCollection->get('files')->write($path, $contents); + } + + $mailer = $this->createMock(MailerInterface::class); + $mailer + ->expects($this->once()) + ->method('send') + ->with($this->callback( + static function (Email $email) use ($parsedTemplateHtml, $expectedAttachmentsContentsAndPath): bool { + $attachments = []; + + foreach ($email->getAttachments() as $attachment) { + $attachments[$attachment->getBody()] = $attachment->getName(); + } + + $expectedHtml = $parsedTemplateHtml; + + foreach ($expectedAttachmentsContentsAndPath as $content => $path) { + $expectedHtml = str_replace($path, 'cid:'.$attachments[$content], $expectedHtml); + } + + return $expectedHtml === $email->getHtmlBody(); + }, + )) + ; + + $tokenCollection = new TokenCollection(); + $tokenCollection->addToken(Token::fromValue('admin_email', 'foobar@example.com')); + $tokenCollection->addToken(Token::fromValue('recipient_email', 'foobar@example.com')); + + $parcel = new Parcel(MessageConfig::fromArray([ + 'email_template' => 'mail_default', + ])); + $parcel = $parcel->withStamp(new LanguageConfigStamp(LanguageConfig::fromArray([ + 'recipients' => '##recipient_email##', + 'email_mode' => 'textAndHtml', + ]))); + $parcel = $parcel->withStamp(new TokenCollectionStamp($tokenCollection)); + + $gateway = new MailerGateway( + $this->createFrameWorkWithTemplate($parsedTemplateHtml), + $vfsCollection->get('files'), + $mailer, + ); + $container = new Container(); + $container->set(AbstractGateway::SERVICE_NAME_BULKY_ITEM_STORAGE, new BulkyItemStorage($vfsCollection->get('bulky_item'))); + $container->set(AbstractGateway::SERVICE_NAME_SIMPLE_TOKEN_PARSER, new SimpleTokenParser(new ExpressionLanguage())); + $gateway->setContainer($container); + + $parcel = $gateway->sealParcel($parcel); + $gateway->sendParcel($parcel); + } + + /** + * @return iterable, 2: array}> + */ + public static function embeddingHtmlImagesProvider(): iterable + { + yield 'Test embeds a relative upload path' => [ + '

', + [ + 'contaodemo/media/content-images/DSC_5276.jpg' => 'foobar', + ], + [ + 'foobar' => 'files/contaodemo/media/content-images/DSC_5276.jpg', + ], + ]; + + yield 'Test embeds an absolute upload path' => [ + '

', + [ + 'contaodemo/media/content-images/DSC_5276.jpg' => 'foobar', + ], + [ + 'foobar' => '/files/contaodemo/media/content-images/DSC_5276.jpg', + ], + ]; + } + + private function createVfsCollection(): VirtualFilesystemCollection + { + $mountManager = (new MountManager()) + ->mount(new InMemoryFilesystemAdapter(), 'files') + ->mount(new InMemoryFilesystemAdapter(), 'bulky_item') + ; + + $dbafsManager = new DbafsManager(); + $dbafsManager->register(new InMemoryDbafs(), 'files'); + $dbafsManager->register(new InMemoryDbafs(), 'bulky_item'); + + $vfsCollection = new VirtualFilesystemCollection(); + $vfsCollection->add(new VirtualFilesystem($mountManager, $dbafsManager, 'files')); + $vfsCollection->add(new VirtualFilesystem($mountManager, $dbafsManager, 'bulky_item')); + $vfsCollection->add(new VirtualFilesystem($mountManager, $dbafsManager, '')); // Global one + + return $vfsCollection; + } + + private function createFrameWorkWithTemplate(string $parsedTemplateHtml): ContaoFramework + { + $controllerAdapter = $this->mockAdapter(['convertRelativeUrls']); + $controllerAdapter + ->method('convertRelativeUrls') + ->willReturnCallback(static fn (string $template): string => $template) + ; + + $templateInstance = $this->createMock(FrontendTemplate::class); + $templateInstance + ->expects($this->once()) + ->method('parse') + ->willReturn($parsedTemplateHtml) + ; + + return $this->mockContaoFramework( + [ + Controller::class => $controllerAdapter, + ], + [ + FrontendTemplate::class => $templateInstance, + ], + ); + } +} diff --git a/tests/Token/TokenTest.php b/tests/Token/TokenTest.php index de5ab067..58e68c50 100644 --- a/tests/Token/TokenTest.php +++ b/tests/Token/TokenTest.php @@ -10,7 +10,9 @@ class TokenTest extends TestCase { - #[DataProvider('anythingProvider')] + /** + * @dataProvider anythingProvider + */ public function testFromAnything(mixed $value, string $expectedParserValue): void { $token = Token::fromValue('token', $value); @@ -19,16 +21,20 @@ public function testFromAnything(mixed $value, string $expectedParserValue): voi } /** + * @dataProvider arrayProvider + * * @param array $value */ - #[DataProvider('arrayProvider')] public function testArrayParserFormat(array $value, string $expectedParserValue): void { $token = Token::fromValue('form_foobar', $value); $this->assertSame($expectedParserValue, $token->getParserValue()); } - public static function arrayProvider(): \Generator + /** + * @return iterable, 1: string}> + */ + public static function arrayProvider(): iterable { yield 'Simple list array token' => [ [ @@ -62,7 +68,10 @@ public static function arrayProvider(): \Generator ]; } - public static function anythingProvider(): \Generator + /** + * @return iterable + */ + public static function anythingProvider(): iterable { yield [ 'foobar',