Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
}
},
"require": {
"shopware/core": "~6.5.8 || ~6.6.0 || ~6.7.0"
"shopware/core": "~6.5.8 || ~6.6.0 || ~6.7.0",
"spatie/mjml-php": "^1.0"
},
"scripts": {
"format": "docker run --rm -v $(pwd):/ext shopware/shopware-cli:latest extension format /ext",
Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder

Check failure on line 10 in src/DependencyInjection/Configuration.php

View workflow job for this annotation

GitHub Actions / check (lowest)

phpstan/missingType.generics

Method Frosh\TemplateMail\DependencyInjection\Configuration::getConfigTreeBuilder() return type with generic class Symfony\Component\Config\Definition\Builder\TreeBuilder does not specify its types: T
{
$treeBuilder = new TreeBuilder('frosh_platform_template_mail');

Expand All @@ -15,6 +15,10 @@
$rootNode
->children()
->scalarNode('mjml_server')->defaultValue('https://mjml.shyim.de')->end()
->enumNode('mjml_renderer')
->values(['api', 'local'])
->defaultValue('api')
->end()
->end()
;

Expand Down
12 changes: 11 additions & 1 deletion src/DependencyInjection/FroshPlatformTemplateMailExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Frosh\TemplateMail\DependencyInjection;

use Frosh\TemplateMail\Services\MjmlRenderer\ApiMjmlRenderer;
use Frosh\TemplateMail\Services\MjmlRenderer\LocalMjmlRenderer;
use Frosh\TemplateMail\Services\MjmlRenderer\MjmlRendererInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;

Expand All @@ -11,8 +14,15 @@ public function load(array $configs, ContainerBuilder $container): void
{
$configuration = $this->getConfiguration($configs, $container);
\assert($configuration instanceof Configuration);
/** @var array{mjml_server: string} $config */
/** @var array{mjml_server: string, mjml_renderer: string} $config */
$config = $this->processConfiguration($configuration, $configs);
$container->setParameter('frosh_platform_template_mail.mjml_server', $config['mjml_server']);

$rendererClass = match ($config['mjml_renderer']) {
'local' => LocalMjmlRenderer::class,
default => ApiMjmlRenderer::class,
};

$container->setAlias(MjmlRendererInterface::class, $rendererClass);
}
}
4 changes: 4 additions & 0 deletions src/FroshPlatformTemplateMail.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@

class FroshPlatformTemplateMail extends Plugin
{
public function executeComposerCommands(): bool
{
return true;
}
}
49 changes: 7 additions & 42 deletions src/Services/MailLoader/MjmlLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,67 +5,32 @@
namespace Frosh\TemplateMail\Services\MailLoader;

use Frosh\TemplateMail\Exception\MjmlCompileError;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Frosh\TemplateMail\Services\MjmlRenderer\MjmlRendererInterface;

class MjmlLoader implements LoaderInterface
{
private const MJML_INCLUDE = '/<mj-include.*?path=[\'|\"]([^"|\']*)[^>]*\/>/im';

public function __construct(
#[Autowire('%frosh_platform_template_mail.mjml_server%')]
private readonly string $mjmlServer,
private readonly LoggerInterface $logger,
private readonly Client $client = new Client(),
private readonly MjmlRendererInterface $mjmlRenderer,
) {
}

/**
* @throws GuzzleException
*/
public function load(string $path): string
{
$fileContent = file_get_contents($path);
if ($fileContent === false) {
// Return empty string to load shopware default templates.
if (!file_exists($path)) {
return '';
}

$mjmlTemplate = $this->parseIncludes($fileContent, \dirname($path));

try {
$response = $this->client->post($this->mjmlServer, [
'json' => [
'mjml' => $mjmlTemplate,
],
]);
} catch (ServerException $e) {
$this->logger->critical('MJML Api is not accessible', ['response' => $e->getResponse()->getBody(), 'code' => $e->getResponse()->getStatusCode()]);

// Return empty string to load shopware default templates.
return '';
}

/** @var array{errors?: array<string>, html?: string}|string $compileTemplate */
$compileTemplate = json_decode($response->getBody()->getContents(), true, 512, \JSON_THROW_ON_ERROR);

if (empty($compileTemplate) || !\is_array($compileTemplate)) {
$fileContent = file_get_contents($path);
if ($fileContent === false) {
// Return empty string to load shopware default templates.
return '';
}

if (\array_key_exists('errors', $compileTemplate) && !empty($compileTemplate['errors'])) {
foreach ($compileTemplate['errors'] as $error) {
$this->logger->critical('Error during compiling of MJML templates', ['response' => $error]);
}

throw new MjmlCompileError(implode('\n', $compileTemplate['errors']));
}
$mjmlTemplate = $this->parseIncludes($fileContent, \dirname($path));

return $compileTemplate['html'];
return $this->mjmlRenderer->render($mjmlTemplate);
}

/**
Expand Down
60 changes: 60 additions & 0 deletions src/Services/MjmlRenderer/ApiMjmlRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Frosh\TemplateMail\Services\MjmlRenderer;

use Frosh\TemplateMail\Exception\MjmlCompileError;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

class ApiMjmlRenderer implements MjmlRendererInterface
{
public function __construct(
#[Autowire('%frosh_platform_template_mail.mjml_server%')]
private readonly string $mjmlServer,
private readonly LoggerInterface $logger,
private readonly Client $client = new Client(),
) {
}

/**
* @throws GuzzleException
*/
public function render(string $mjml): string
{
try {
$response = $this->client->post($this->mjmlServer, [
'json' => [
'mjml' => $mjml,
],
]);
} catch (ServerException $e) {
$this->logger->critical('MJML Api is not accessible', ['response' => $e->getResponse()->getBody(), 'code' => $e->getResponse()->getStatusCode()]);

// Return empty string to load shopware default templates.
return '';
}

/** @var array{errors?: array<string>, html?: string}|string $compileTemplate */
$compileTemplate = json_decode($response->getBody()->getContents(), true, 512, \JSON_THROW_ON_ERROR);

if (empty($compileTemplate) || !\is_array($compileTemplate)) {
// Return empty string to load shopware default templates.
return '';
}

if (\array_key_exists('errors', $compileTemplate) && !empty($compileTemplate['errors'])) {
foreach ($compileTemplate['errors'] as $error) {
$this->logger->critical('Error during compiling of MJML templates', ['response' => $error]);
}

throw new MjmlCompileError(implode('\n', $compileTemplate['errors']));
}

return $compileTemplate['html'];

Check failure on line 58 in src/Services/MjmlRenderer/ApiMjmlRenderer.php

View workflow job for this annotation

GitHub Actions / check (lowest)

phpstan/offsetAccess.notFound

Offset 'html' might not exist on non-empty-array{errors?: array<string>, html?: string}.

Check failure on line 58 in src/Services/MjmlRenderer/ApiMjmlRenderer.php

View workflow job for this annotation

GitHub Actions / check (highest)

phpstan/offsetAccess.notFound

Offset 'html' might not exist on non-empty-array{errors?: array<string>, html?: string}.
}
}
43 changes: 43 additions & 0 deletions src/Services/MjmlRenderer/LocalMjmlRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Frosh\TemplateMail\Services\MjmlRenderer;

use Frosh\TemplateMail\Exception\MjmlCompileError;
use Psr\Log\LoggerInterface;
use Spatie\Mjml\Mjml;
use Spatie\Mjml\MjmlError;

class LocalMjmlRenderer implements MjmlRendererInterface
{
public function __construct(
private readonly LoggerInterface $logger,
) {
}

public function render(string $mjml): string
{
try {
$result = Mjml::new()->convert($mjml);
} catch (\Exception $e) {
$this->logger->critical('Local MJML rendering failed', ['error' => $e->getMessage()]);

// Return empty string to load shopware default templates.
return '';
}

if ($result->hasErrors()) {
foreach ($result->errors() as $error) {
$this->logger->critical('Error during local MJML compilation', ['error' => $error->formattedMessage()]);
}

throw new MjmlCompileError(implode("\n", array_map(
static fn (MjmlError $error): string => $error->formattedMessage(),
$result->errors()
)));
}

return $result->html();
}
}
15 changes: 15 additions & 0 deletions src/Services/MjmlRenderer/MjmlRendererInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Frosh\TemplateMail\Services\MjmlRenderer;

interface MjmlRendererInterface
{
/**
* Convert assembled MJML (includes already resolved) to HTML.
*
* @throws \Frosh\TemplateMail\Exception\MjmlCompileError
*/
public function render(string $mjml): string;
}
68 changes: 38 additions & 30 deletions tests/Services/MailLoader/MjmlLoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,61 @@

use Frosh\TemplateMail\Exception\MjmlCompileError;
use Frosh\TemplateMail\Services\MailLoader\MjmlLoader;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Frosh\TemplateMail\Services\MjmlRenderer\MjmlRendererInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;

class MjmlLoaderTest extends TestCase
{
public function testLoadingWorks(): void
public function testSupportedExtensions(): void
{
$loader = new MjmlLoader('https://mjml.shyim.de', new NullLogger());
static::assertSame(['mjml'], $loader->supportedExtensions());
$renderer = $this->createMock(MjmlRendererInterface::class);
$loader = new MjmlLoader($renderer);

$text = $loader->load(__DIR__ . '/_fixtures/test.mjml');
static::assertStringContainsString('<!doctype html>', $text);
static::assertStringContainsString('<tbody>', $text);
static::assertSame(['mjml'], $loader->supportedExtensions());
}

public function testApiIsNotAvailable(): void
public function testLoadDelegatesToRenderer(): void
{
$mock = new MockHandler([
new ServerException('Error Communicating with Server', new Request('GET', 'test'), new Response(500)),
]);
$renderer = $this->createMock(MjmlRendererInterface::class);
$renderer->expects(static::once())
->method('render')
->with(static::stringContains('<mjml>'))
->willReturn('<html>rendered</html>');

$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
$loader = new MjmlLoader($renderer);
$result = $loader->load(__DIR__ . '/_fixtures/test.mjml');

$loader = new MjmlLoader('https://mjml.shyim.de', new NullLogger(), $client);

static::assertSame('', $loader->load(__DIR__ . '/_fixtures/test.mjml'));
static::assertSame('<html>rendered</html>', $result);
}

public function testApiRespondsErrors(): void
public function testLoadReturnsEmptyStringForNonExistentFile(): void
{
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], json_encode(['errors' => ['some error happend']], JSON_THROW_ON_ERROR)),
]);
$renderer = $this->createMock(MjmlRendererInterface::class);
$renderer->expects(static::never())->method('render');

$loader = new MjmlLoader($renderer);
$result = $loader->load(__DIR__ . '/_fixtures/nonexistent.mjml');

$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
static::assertSame('', $result);
}

public function testLoadThrowsOnMissingInclude(): void
{
$renderer = $this->createMock(MjmlRendererInterface::class);
$loader = new MjmlLoader($renderer);

$loader = new MjmlLoader('https://mjml.shyim.de', new NullLogger(), $client);
// Create a temp file with an include that doesn't exist
$tempDir = sys_get_temp_dir() . '/mjml_test_' . uniqid();
mkdir($tempDir);
file_put_contents($tempDir . '/test.mjml', '<mjml><mj-body><mj-include path="missing.mjml" /></mj-body></mjml>');

static::expectException(MjmlCompileError::class);
$loader->load(__DIR__ . '/_fixtures/test.mjml');

try {
$loader->load($tempDir . '/test.mjml');
} finally {
unlink($tempDir . '/test.mjml');
rmdir($tempDir);
}
}
}
Loading
Loading