[Twig] Add NotificationEmail

This commit is contained in:
Fabien Potencier 2019-08-23 22:48:48 +02:00
parent 1efae63e64
commit f6c6cf7dc9
15 changed files with 2098 additions and 15 deletions

View File

@ -29,6 +29,7 @@ install:
- echo max_execution_time=1200 >> php.ini-min
- echo date.timezone="America/Los_Angeles" >> php.ini-min
- echo extension_dir=ext >> php.ini-min
- echo extension=php_xsl.dll >> php.ini-min
- copy /Y php.ini-min php.ini-max
- echo zend_extension=php_opcache.dll >> php.ini-max
- echo opcache.enable_cli=1 >> php.ini-max

View File

@ -119,7 +119,10 @@
"egulias/email-validator": "~1.2,>=1.2.8|~2.0",
"symfony/phpunit-bridge": "^3.4.31|^4.3.4|~5.0",
"symfony/security-acl": "~2.8|~3.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0"
"phpdocumentor/reflection-docblock": "^3.0|^4.0",
"twig/cssinliner-extra": "^2.12",
"twig/inky-extra": "^2.12",
"twig/markdown-extra": "^2.12"
},
"conflict": {
"masterminds/html5": "<2.6",

View File

@ -47,7 +47,7 @@ final class BodyRenderer implements BodyRendererInterface
$messageContext = $message->getContext();
if (isset($messageContext['email'])) {
throw new InvalidArgumentException(sprintf('A "%s" context cannot have an "email" entry as this is a reserved variable.', TemplatedEmail::class));
throw new InvalidArgumentException(sprintf('A "%s" context cannot have an "email" entry as this is a reserved variable.', \get_class($message)));
}
$vars = array_merge($this->context, $messageContext, [

View File

@ -0,0 +1,216 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bridge\Twig\Mime;
use Symfony\Component\ErrorRenderer\Exception\FlattenException;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Part\AbstractPart;
use Twig\Extra\CssInliner\CssInlinerExtension;
use Twig\Extra\Inky\InkyExtension;
use Twig\Extra\Markdown\MarkdownExtension;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class NotificationEmail extends TemplatedEmail
{
public const IMPORTANCE_URGENT = 'urgent';
public const IMPORTANCE_HIGH = 'high';
public const IMPORTANCE_MEDIUM = 'medium';
public const IMPORTANCE_LOW = 'low';
private $theme = 'default';
private $context = [
'importance' => self::IMPORTANCE_LOW,
'content' => '',
'exception' => false,
'action_text' => null,
'action_url' => null,
'markdown' => false,
'raw' => false,
];
public function __construct(Headers $headers = null, AbstractPart $body = null)
{
if (!class_exists(CssInlinerExtension::class)) {
throw new \LogicException(sprintf('You cannot use "%s" if the CSS Inliner Twig extension is not available; try running "composer require twig/cssinliner-extra".', static::class));
}
if (!class_exists(InkyExtension::class)) {
throw new \LogicException(sprintf('You cannot use "%s" if the Inky Twig extension is not available; try running "composer require twig/inky-extra".', static::class));
}
parent::__construct($headers, $body);
}
/**
* @return $this
*/
public function markdown(string $content)
{
if (!class_exists(MarkdownExtension::class)) {
throw new \LogicException(sprintf('You cannot use "%s" if the Markdown Twig extension is not available; try running "composer require twig/markdown-extra".', __METHOD__));
}
$this->context['markdown'] = true;
return $this->content($content);
}
/**
* @return $this
*/
public function content(string $content, bool $raw = false)
{
$this->context['content'] = $content;
$this->context['raw'] = $raw;
return $this;
}
/**
* @return $this
*/
public function action(string $text, string $url)
{
$this->context['action_text'] = $text;
$this->context['action_url'] = $url;
return $this;
}
/**
* @return self
*/
public function importance(string $importance)
{
$this->context['importance'] = $importance;
return $this;
}
/**
* @param \Throwable|FlattenException
*
* @return $this
*/
public function exception($exception)
{
$exceptionAsString = $this->getExceptionAsString($exception);
$this->context['exception'] = true;
$this->attach($exceptionAsString, 'exception.txt', 'text/plain');
$this->importance(self::IMPORTANCE_URGENT);
if (!$this->getSubject()) {
$this->subject($exception->getMessage());
}
return $this;
}
/**
* @return $this
*/
public function theme(string $theme)
{
$this->theme = $theme;
return $this;
}
public function getTextTemplate(): ?string
{
if ($template = parent::getTextTemplate()) {
return $template;
}
return '@email/'.$this->theme.'/notification/body.txt.twig';
}
public function getHtmlTemplate(): ?string
{
if ($template = parent::getHtmlTemplate()) {
return $template;
}
return '@email/'.$this->theme.'/notification/body.html.twig';
}
public function getContext(): array
{
return array_merge($this->context, parent::getContext());
}
public function getPreparedHeaders(): Headers
{
$headers = parent::getPreparedHeaders();
$importance = $this->context['importance'] ?? IMPORTANCE_LOW;
$this->priority($this->determinePriority($importance));
$headers->setHeaderBody('Text', 'Subject', sprintf('[%s] %s', strtoupper($importance), $this->getSubject()));
return $headers;
}
private function determinePriority(string $importance): int
{
switch ($importance) {
case self::IMPORTANCE_URGENT:
return self::PRIORITY_HIGHEST;
case self::IMPORTANCE_HIGH:
return self::PRIORITY_HIGH;
case self::IMPORTANCE_MEDIUM:
return self::PRIORITY_NORMAL;
case self::IMPORTANCE_LOW:
default:
return self::PRIORITY_LOW;
}
}
private function getExceptionAsString($exception): string
{
if (class_exists(FlattenException::class)) {
$exception = $exception instanceof FlattenException ? $exception : FlattenException::createFromThrowable($exception);
return $exception->getAsString();
}
$message = \get_class($exception);
if ('' != $exception->getMessage()) {
$message .= ': '.$exception->getMessage();
}
$message .= ' in '.$exception->getFile().':'.$exception->getLine()."\n";
$message .= "Stack trace:\n".$exception->getTraceAsString()."\n\n";
return rtrim($message);
}
/**
* @internal
*/
public function __serialize(): array
{
return [$this->context, parent::__serialize()];
}
/**
* @internal
*/
public function __unserialize(array $data): void
{
[$this->context, $parentData] = $data;
parent::__unserialize($parentData);
}
}

View File

@ -0,0 +1 @@
{% extends "@email/zurb_2/notification/body.html.twig" %}

View File

@ -0,0 +1 @@
{% extends "@email/zurb_2/notification/body.txt.twig" %}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
{% filter inky_to_html|inline_css %}
<html>
<head>
<style>
{% block style %}
{{ source("@email/zurb_2/main.css") }}
{{ source("@email/zurb_2/notification/local.css") }}
{% endblock %}
</style>
</head>
<body>
<spacer size="32"></spacer>
<container class="body_{{ ("urgent" == importance ? "alert" : ("high" == importance ? "warning" : "default")) }}">
<spacer size="16"></spacer>
<row>
<columns large="12" small="12">
{% block lead %}
<small><strong>{{ importance|upper }}</strong></small>
<p class="lead">
{{ email.subject }}
</p>
{% endblock %}
{% block content %}
{% if markdown %}
{{ include('@email/zurb_2/notification/content_markdown.html.twig') }}
{% else %}
{{ (raw ? content|raw : content)|nl2br }}
{% endif %}
{% endblock %}
{% block action %}
{% if action_url %}
<spacer size="16"></spacer>
<button href="{{ action_url }}">{{ action_text }}</button>
{% endif %}
{% endblock %}
{% block exception %}
{% if exception %}
<spacer size="16"></spacer>
<p><em>Exception stack trace attached.</em></p>
{% endif %}
{% endblock %}
</columns>
</row>
<wrapper class="secondary">
<spacer size="16"></spacer>
{% block footer %}
<row>
<columns small="12" large="6">
{% block footer_content %}
<p><small>Notification e-mail sent by Symfony</small></p>
{% endblock %}
</columns>
</row>
{% endblock %}
</wrapper>
</container>
</body>
</html>
{% endfilter %}

View File

@ -0,0 +1,20 @@
{% block lead %}
{{ email.subject }}
{% endblock %}
{% block content %}
{{ content }}
{% endblock %}
{% block action %}
{% if action_url %}
{{ action_url }}: {{ action_text }}
{% endif %}
{% endblock %}
{% block exception %}
{% if exception %}
Exception stack trace attached.
{{ exception }}
{% endif %}
{% endblock %}

View File

@ -0,0 +1 @@
{{ content|markdown_to_html }}

View File

@ -0,0 +1,19 @@
body {
background: #f3f3f3;
}
.wrapper.secondary {
background: #f3f3f3;
}
.container.body_alert {
border-top: 8px solid #ec5840;
}
.container.body_warning {
border-top: 8px solid #ffae00;
}
.container.body_default {
border-top: 8px solid #aaaaaa;
}

View File

@ -0,0 +1,66 @@
<?php
namespace Symfony\Bridge\Twig\Tests\Mime;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Twig\Mime\NotificationEmail;
class NotificationEmailTest extends TestCase
{
public function test()
{
$email = (new NotificationEmail())
->markdown('Foo')
->exception(new \Exception())
->importance(NotificationEmail::IMPORTANCE_HIGH)
->action('Bar', 'http://example.com/')
->context(['a' => 'b'])
;
$this->assertEquals([
'importance' => NotificationEmail::IMPORTANCE_HIGH,
'content' => 'Foo',
'exception' => true,
'action_text' => 'Bar',
'action_url' => 'http://example.com/',
'markdown' => true,
'raw' => false,
'a' => 'b',
], $email->getContext());
}
public function testSerialize()
{
$email = unserialize(serialize((new NotificationEmail())
->content('Foo', true)
->exception(new \Exception())
->importance(NotificationEmail::IMPORTANCE_HIGH)
->action('Bar', 'http://example.com/')
->context(['a' => 'b'])
));
$this->assertEquals([
'importance' => NotificationEmail::IMPORTANCE_HIGH,
'content' => 'Foo',
'exception' => true,
'action_text' => 'Bar',
'action_url' => 'http://example.com/',
'markdown' => false,
'raw' => true,
'a' => 'b',
], $email->getContext());
}
public function testTheme()
{
$email = (new NotificationEmail())->theme('mine');
$this->assertSame('@email/mine/notification/body.html.twig', $email->getHtmlTemplate());
$this->assertSame('@email/mine/notification/body.txt.twig', $email->getTextTemplate());
}
public function testSubject()
{
$email = (new NotificationEmail())->from('me@example.com')->subject('Foo');
$headers = $email->getPreparedHeaders();
$this->assertSame('[LOW] Foo', $headers->get('Subject')->getValue());
}
}

View File

@ -43,7 +43,10 @@
"symfony/var-dumper": "^3.4|^4.0|^5.0",
"symfony/expression-language": "^3.4|^4.0|^5.0",
"symfony/web-link": "^4.4|^5.0",
"symfony/workflow": "^4.3|^5.0"
"symfony/workflow": "^4.3|^5.0",
"twig/cssinliner-extra": "^2.12",
"twig/inky-extra": "^2.12",
"twig/markdown-extra": "^2.12"
},
"conflict": {
"symfony/console": "<3.4",

View File

@ -40,24 +40,42 @@ class ExtensionPass implements CompilerPassInterface
$container->removeDefinition('twig.extension.yaml');
}
$viewDir = \dirname((new \ReflectionClass('Symfony\Bridge\Twig\Extension\FormExtension'))->getFileName(), 2).'/Resources/views';
$templateIterator = $container->getDefinition('twig.template_iterator');
$templatePaths = $templateIterator->getArgument(2);
$cacheWarmer = null;
if ($container->hasDefinition('twig.cache_warmer')) {
$cacheWarmer = $container->getDefinition('twig.cache_warmer');
$cacheWarmerPaths = $cacheWarmer->getArgument(2);
}
$loader = $container->getDefinition('twig.loader.native_filesystem');
if ($container->has('mailer')) {
$emailPath = $viewDir.'/Email';
$loader->addMethodCall('addPath', [$emailPath, 'email']);
$loader->addMethodCall('addPath', [$emailPath, '!email']);
$templatePaths[$emailPath] = 'email';
if ($cacheWarmer) {
$cacheWarmerPaths[$emailPath] = 'email';
}
}
if ($container->has('form.extension')) {
$container->getDefinition('twig.extension.form')->addTag('twig.extension');
$reflClass = new \ReflectionClass('Symfony\Bridge\Twig\Extension\FormExtension');
$coreThemePath = \dirname($reflClass->getFileName(), 2).'/Resources/views/Form';
$container->getDefinition('twig.loader.native_filesystem')->addMethodCall('addPath', [$coreThemePath]);
$paths = $container->getDefinition('twig.template_iterator')->getArgument(2);
$paths[$coreThemePath] = null;
$container->getDefinition('twig.template_iterator')->replaceArgument(2, $paths);
if ($container->hasDefinition('twig.cache_warmer')) {
$paths = $container->getDefinition('twig.cache_warmer')->getArgument(2);
$paths[$coreThemePath] = null;
$container->getDefinition('twig.cache_warmer')->replaceArgument(2, $paths);
$coreThemePath = $viewDir.'/Form';
$loader->addMethodCall('addPath', [$coreThemePath]);
$templatePaths[$coreThemePath] = null;
if ($cacheWarmer) {
$cacheWarmerPaths[$coreThemePath] = null;
}
}
$templateIterator->replaceArgument(2, $templatePaths);
if ($cacheWarmer) {
$container->getDefinition('twig.cache_warmer')->replaceArgument(2, $cacheWarmerPaths);
}
if ($container->has('router')) {
$container->getDefinition('twig.extension.routing')->addTag('twig.extension');
}

View File

@ -13,6 +13,7 @@ namespace Symfony\Bundle\TwigBundle\Tests\DependencyInjection\Compiler;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\ExtensionPass;
use Symfony\Bundle\TwigBundle\TemplateIterator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
@ -38,6 +39,9 @@ class ExtensionPassTest extends TestCase
$filesystemLoader->addMethodCall('addPath', []);
$container->setDefinition('twig.loader.filesystem', $filesystemLoader);
$templateIterator = new Definition(TemplateIterator::class, [null, null, null]);
$container->setDefinition('twig.template_iterator', $templateIterator);
$extensionPass = new ExtensionPass();
$extensionPass->process($container);